From fae411d2c36ffbd8c31bdef978414d648d5e1ab2 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 10 Sep 2026 16:19:29 -0700 Subject: [PATCH 01/53] Prototype discoverable plugin RPC and usage sources --- .../settings/UsageLimitsSettingsSection.tsx | 164 +++++++++------ .../UsageSourcesSettingsSection.test.tsx | 100 +++++++++ .../src/hooks/queries/usage-source-queries.ts | 74 +++++++ apps/app/src/lib/usage-source-contract.ts | 80 +++++++ apps/cli/src/commands/plugin.ts | 98 ++++++++- apps/server/src/routes/plugins.ts | 8 + .../server/src/services/plugins/plugin-api.ts | 3 + .../src/services/plugins/plugin-service.ts | 31 +++ .../public/public-host-management.test.ts | 1 + .../test/services/plugins/plugin-sdk.test.ts | 39 ++++ docs/api_to_audit.md | 9 + ...iscoverable-rpc-and-provider-usage-plan.md | 197 ++++++++++++++++++ packages/plugin-api-map/src/surfaces.ts | 3 + packages/plugin-sdk/src/backend-contract.ts | 4 + .../plugin-sdk/src/internal/host-policy.ts | 93 ++++++++- packages/plugin-sdk/src/rpc-contract.ts | 5 + .../testing/__tests__/rpc-discovery.test.ts | 132 ++++++++++++ .../src/testing/fake-plugin-host.ts | 3 + packages/sdk/src/areas/plugins.ts | 18 +- .../sdk/test/plugin-rpc-discovery.test.ts | 55 +++++ packages/sdk/test/public-types.test.ts | 1 + packages/server-contract/src/api/plugins.ts | 24 +++ .../src/templates/bb-guide-plugins.md | 6 + .../references/accounts-and-routing.md | 4 + plugins/account-pool/src/server.test.ts | 31 +++ plugins/account-pool/src/server.ts | 2 + plugins/account-pool/src/usage-contract.ts | 80 +++++++ plugins/account-pool/src/usage-source.ts | 118 +++++++++++ .../skills/bb-cli/references/plugins.md | 6 + plugins/provider-claude-code/server.ts | 2 + .../skills/claude-code-provider/SKILL.md | 4 + .../src/usage-contract.ts | 80 +++++++ .../src/usage-source.test.ts | 57 +++++ .../provider-claude-code/src/usage-source.ts | 134 ++++++++++++ plugins/provider-codex/server.ts | 2 + .../skills/codex-provider/SKILL.md | 4 + plugins/provider-codex/src/usage-contract.ts | 80 +++++++ .../provider-codex/src/usage-source.test.ts | 57 +++++ plugins/provider-codex/src/usage-source.ts | 134 ++++++++++++ 39 files changed, 1882 insertions(+), 61 deletions(-) create mode 100644 apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx create mode 100644 apps/app/src/hooks/queries/usage-source-queries.ts create mode 100644 apps/app/src/lib/usage-source-contract.ts create mode 100644 docs/discoverable-rpc-and-provider-usage-plan.md create mode 100644 packages/plugin-sdk/src/testing/__tests__/rpc-discovery.test.ts create mode 100644 packages/sdk/test/plugin-rpc-discovery.test.ts create mode 100644 plugins/account-pool/src/usage-contract.ts create mode 100644 plugins/account-pool/src/usage-source.ts create mode 100644 plugins/provider-claude-code/src/usage-contract.ts create mode 100644 plugins/provider-claude-code/src/usage-source.test.ts create mode 100644 plugins/provider-claude-code/src/usage-source.ts create mode 100644 plugins/provider-codex/src/usage-contract.ts create mode 100644 plugins/provider-codex/src/usage-source.test.ts create mode 100644 plugins/provider-codex/src/usage-source.ts diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx index ca6b5df5dc..e2220228eb 100644 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx +++ b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx @@ -1,4 +1,4 @@ -import { useId, useMemo, useState } from "react"; +import { useId } from "react"; import type { Host, ProviderInfo } from "@bb/domain"; import type { ProviderUsage, @@ -21,16 +21,10 @@ import { } from "@bb/shared-ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; import { - useSystemConfig, - useSystemProviderUsageLimits, useSystemProviders, type ProviderUsageQueryState, } from "@/hooks/queries/system-queries"; -import { - selectHosts, - selectPrimaryHost, - useHosts, -} from "@/hooks/queries/host-queries"; +import { useUsageSources } from "@/hooks/queries/usage-source-queries"; import { getProviderIconInfo } from "@/lib/provider-icon"; import { ProviderIconMark } from "./ProviderIconMark"; import { cn } from "@bb/shared-ui/lib/utils"; @@ -90,7 +84,7 @@ function UsageWindowRow({ window }: { window: ProviderUsageWindow }) { "h-full rounded-full", usageBarColorClass(window.usedPercent), )} - style={{ width: `${Math.max(window.usedPercent, 2)}%` }} + style={{ width: `${Math.min(100, Math.max(window.usedPercent, 2))}%` }} /> {reset ?

{reset}

: null} @@ -401,56 +395,110 @@ export function UsageLimitsSettingsSectionContent({ } export function UsageLimitsSettingsSection() { - const systemConfigQuery = useSystemConfig(); - const hostsQuery = useHosts(); - const hosts = useMemo( - () => selectHosts(hostsQuery.data, "persistent"), - [hostsQuery.data], - ); - const [selectedHostId, setSelectedHostId] = useState(null); - const primaryHost = selectPrimaryHost( - hosts, - systemConfigQuery.data?.primaryHostId ?? null, - ); - const selectedHost = - hosts.find((host) => host.id === selectedHostId) ?? primaryHost; - const usageHostId = - selectedHost?.id ?? systemConfigQuery.data?.primaryHostId ?? undefined; - const providersQuery = useSystemProviders( - usageHostId === undefined - ? { - capability: "usage", - enabled: systemConfigQuery.data !== undefined, - } - : { - capability: "usage", - enabled: systemConfigQuery.data !== undefined, - hostId: usageHostId, - }, + const usage = useUsageSources(); + const providersQuery = useSystemProviders({}); + const providers = new Map( + (providersQuery.data ?? []).map((provider) => [provider.id, provider]), ); - const providers = providersQuery.data ?? []; - const usageQuery = useSystemProviderUsageLimits({ - ...(usageHostId === undefined ? {} : { hostId: usageHostId }), - enabled: systemConfigQuery.data !== undefined && providersQuery.isSuccess, - providerIds: providers.map((provider) => provider.id), - }); - return ( - { - void usageQuery.refetch(); - }} - providerStates={usageQuery.providerStates} - providers={providers} - hosts={hosts} - selectedHostId={selectedHost?.id ?? null} - onSelectHost={setSelectedHostId} - /> + { + void usage.refresh(); + }} + aria-label="Reload usage data" + > + + + } + > + {usage.discovery.isError ? ( +

+ Usage sources could not be loaded. +

+ ) : null} + {usage.discovery.isPending ? ( +

Loading usage sources…

+ ) : null} + {usage.discovery.isSuccess && usage.sources.length === 0 ? ( +

+ No usage sources available. +

+ ) : null} +
+ {usage.sources.map(({ source, query }) => ( +
+

{source.displayName}

+ {query.isPending ? ( +

Loading usage…

+ ) : null} + {query.isError ? ( +

+ This source could not be refreshed. Any values below are from + the previous observation. +

+ ) : null} + {query.data?.resources.length === 0 ? ( +

+ No accounts reported. +

+ ) : null} + + {query.data?.resources.map((resource) => { + const config = providerConfig( + resource.providerId, + providers.get(resource.providerId), + ); + const observation = + resource.observedAt === null + ? "Not yet observed" + : `Observed ${new Date(resource.observedAt).toLocaleString()}`; + return ( +
+

+ {resource.scope.kind === "shared" + ? "Shared across machines" + : resource.scope.hostName} + {" · "} + {observation} +

+ + cost === null ? window : { ...window, cost }, + ), + } + : resource.usage + } + isLoading={false} + isError={false} + /> +
+ ); + })} +
+
+ ))} +
+
); } diff --git a/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx b/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx new file mode 100644 index 0000000000..61ca11bf92 --- /dev/null +++ b/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { UsageLimitsSettingsSection } from "./UsageLimitsSettingsSection"; + +const calls = vi.hoisted(() => ({ discover: vi.fn(), rpc: vi.fn() })); +vi.mock("@/lib/sdk", () => ({ + sdk: { + plugins: { experimental_discoverRpc: calls.discover, callRpc: calls.rpc }, + }, +})); +vi.mock("@/hooks/queries/system-queries", () => ({ + useSystemProviders: () => ({ data: [] }), +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +it("renders independent sources with shared and host scope and refreshes through the copied contract", async () => { + calls.discover.mockResolvedValue([ + { pluginId: "pool", displayName: "Account Pooler" }, + { pluginId: "local", displayName: "Codex provider" }, + { pluginId: "broken", displayName: "Unavailable provider" }, + ]); + calls.rpc.mockImplementation(async ({ pluginId }) => { + if (pluginId === "broken") throw new Error("Unavailable"); + return { + resources: [ + { + id: "same-local-id", + providerId: "codex", + label: pluginId === "pool" ? "Pooled account" : "Local account", + scope: + pluginId === "pool" + ? { kind: "shared" } + : { kind: "host", hostId: "host-a", hostName: "Build machine" }, + observedAt: 1_700_000_000_000, + usage: { + status: "ok", + accountEmail: null, + planLabel: null, + windows: [ + { + id: "weekly", + label: "Weekly", + usedPercent: pluginId === "pool" ? 42 : 81, + resetsAt: null, + model: null, + cost: null, + }, + ], + }, + }, + ], + }; + }); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + try { + render( + + + , + ); + expect(await screen.findByText("42% used")).toBeTruthy(); + expect(await screen.findByText("81% used")).toBeTruthy(); + expect(screen.getByText(/Shared across machines/)).toBeTruthy(); + expect(screen.getByText(/Build machine/)).toBeTruthy(); + expect( + await screen.findByText(/This source could not be refreshed/), + ).toBeTruthy(); + await waitFor(() => + expect( + screen.getByLabelText("Reload usage data").hasAttribute("disabled"), + ).toBe(false), + ); + fireEvent.click(screen.getByLabelText("Reload usage data")); + await waitFor(() => + expect(calls.rpc).toHaveBeenCalledWith( + expect.objectContaining({ + pluginId: "pool", + method: "provider-usage.v1.get", + input: { refresh: true }, + }), + ), + ); + } finally { + client.clear(); + } +}); diff --git a/apps/app/src/hooks/queries/usage-source-queries.ts b/apps/app/src/hooks/queries/usage-source-queries.ts new file mode 100644 index 0000000000..caa879c752 --- /dev/null +++ b/apps/app/src/hooks/queries/usage-source-queries.ts @@ -0,0 +1,74 @@ +import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import { sdk } from "@/lib/sdk"; +import { usageSnapshotSchema } from "@/lib/usage-source-contract"; + +const method = "provider-usage.v1.get"; +const discoveryKey = ["pluginRpcDiscovery", method] as const; +const sourceKey = (pluginId: string) => + ["pluginUsageSource", pluginId, method] as const; +let active = 0; +const waiting: Array<() => void> = []; + +async function loadSource( + pluginId: string, + refresh: boolean, + signal: AbortSignal, +) { + if (active >= 3) await new Promise((resolve) => waiting.push(resolve)); + else active++; + try { + signal.throwIfAborted(); + return await sdk.plugins.callRpc({ + pluginId, + method, + input: { refresh }, + outputSchema: usageSnapshotSchema, + signal: AbortSignal.any([signal, AbortSignal.timeout(45_000)]), + }); + } finally { + const next = waiting.shift(); + if (next === undefined) active--; + else next(); + } +} + +export function useUsageSources() { + const client = useQueryClient(); + const discovery = useQuery({ + queryKey: discoveryKey, + queryFn: () => sdk.plugins.experimental_discoverRpc({ method }), + staleTime: 10_000, + refetchInterval: 30_000, + }); + const sources = discovery.data ?? []; + const queries = useQueries({ + queries: sources.map((source) => ({ + queryKey: sourceKey(source.pluginId), + queryFn: ({ signal }: { signal: AbortSignal }) => + loadSource(source.pluginId, false, signal), + staleTime: 30_000, + retry: false, + })), + }); + return { + discovery, + sources: sources.map((source, index) => ({ + source, + query: queries[index]!, + })), + isFetching: + discovery.isFetching || queries.some((query) => query.isFetching), + async refresh() { + const result = await discovery.refetch(); + await Promise.allSettled( + (result.data ?? []).map((source) => + client.fetchQuery({ + queryKey: sourceKey(source.pluginId), + queryFn: ({ signal }) => loadSource(source.pluginId, true, signal), + staleTime: 0, + }), + ), + ); + }, + }; +} diff --git a/apps/app/src/lib/usage-source-contract.ts b/apps/app/src/lib/usage-source-contract.ts new file mode 100644 index 0000000000..9adf55ce87 --- /dev/null +++ b/apps/app/src/lib/usage-source-contract.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; + +const accountFields = { + accountEmail: z.string().nullable(), + planLabel: z.string().nullable(), +}; +const usageWindowSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1), + usedPercent: z + .number() + .nonnegative() + .describe("Percentage consumed; may exceed 100 for overage."), + resetsAt: z + .string() + .nullable() + .describe("ISO timestamp, or null when no reset is known."), + model: z + .string() + .nullable() + .describe("Applicable model family, or null for all models."), + cost: z + .object({ + usedUsdCents: z.number().nonnegative(), + limitUsdCents: z.number().positive(), + }) + .nullable(), +}); +const usageSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("ok"), + ...accountFields, + windows: z.array(usageWindowSchema), + }), + z.object({ status: z.literal("not_installed"), ...accountFields }), + z.object({ status: z.literal("unauthenticated"), ...accountFields }), + z.object({ status: z.literal("expired"), ...accountFields }), + z.object({ + status: z.literal("error"), + ...accountFields, + message: z.string(), + }), +]); +export const usageSnapshotSchema = z.object({ + resources: z.array( + z.object({ + id: z + .string() + .min(1) + .describe("Stable resource ID within the reporting plugin."), + providerId: z.string().min(1), + label: z.string().min(1), + scope: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("shared") }), + z.object({ + kind: z.literal("host"), + hostId: z.string().min(1), + hostName: z.string().min(1), + }), + ]), + observedAt: z + .number() + .int() + .nonnegative() + .nullable() + .describe( + "Measurement time in epoch milliseconds; null if never observed.", + ), + usage: usageSchema, + }), + ), +}); +export type UsageSnapshot = z.infer; +export const usageInputSchema = z.object({ + refresh: z + .boolean() + .describe( + "Request fresh collection and wait for the attempt; false permits cached observations.", + ), +}); diff --git a/apps/cli/src/commands/plugin.ts b/apps/cli/src/commands/plugin.ts index 99d8236088..f8a6783115 100644 --- a/apps/cli/src/commands/plugin.ts +++ b/apps/cli/src/commands/plugin.ts @@ -6,7 +6,7 @@ import { createInterface } from "node:readline/promises"; import { setTimeout as sleep } from "node:timers/promises"; import { Command } from "commander"; import { z } from "zod"; -import { derivePluginId } from "@bb/domain"; +import { derivePluginId, jsonValueSchema } from "@bb/domain"; import { pluginCliCall, RESERVED_BB_CLI_COMMANDS } from "@bb/domain/plugin-cli"; import type { InstalledPlugin as PluginEntry, @@ -786,6 +786,102 @@ export function registerPluginCommands( .description("Manage BB plugins") .enablePositionalOptions(); + const rpc = plugin + .command("rpc") + .description("Inspect discoverable plugin RPC methods"); + rpc + .command("list") + .option("--method ", "Filter by exact method name") + .option("--json", "Output JSON") + .action( + action(async (opts: JsonOutputOptions & { method?: string }) => { + const methods = await createCliBbSdk( + getUrl(), + ).plugins.experimental_discoverRpc({ method: opts.method }); + if (opts.json) { + outputJson(opts, methods); + return; + } + if (methods.length === 0) console.log("No discoverable RPC methods."); + for (const method of methods) + console.log( + `${method.pluginId} ${method.method} ${method.methodDescription ?? method.registrationDescription ?? ""}`, + ); + }), + ); + rpc + .command("call ") + .description("Call a plugin RPC method with server-side schema validation") + .option( + "--input-file ", + "Read JSON input from a file; defaults to null", + ) + .option("--json", "Output JSON") + .action( + action( + async ( + pluginId: string, + method: string, + opts: JsonOutputOptions & { inputFile?: string }, + ) => { + const input = + opts.inputFile === undefined + ? null + : jsonValueSchema.parse( + JSON.parse(await readFile(opts.inputFile, "utf8")), + ); + const result = await createCliBbSdk(getUrl()).plugins.callRpc({ + pluginId, + method, + input, + outputSchema: jsonValueSchema, + }); + if (opts.json) { + outputJson(opts, result); + return; + } + console.log(JSON.stringify(result, null, 2)); + }, + ), + ); + + rpc + .command("inspect ") + .option("--method ", "Filter by exact method name") + .option("--json", "Output JSON") + .action( + action( + async ( + pluginId: string, + opts: JsonOutputOptions & { method?: string }, + ) => { + const methods = await createCliBbSdk( + getUrl(), + ).plugins.experimental_discoverRpc({ pluginId, method: opts.method }); + if (opts.json) { + outputJson(opts, methods); + return; + } + if (methods.length === 0) console.log("No discoverable RPC methods."); + for (const method of methods) { + console.log(`${method.pluginId} · ${method.method}`); + if (method.registrationDescription !== null) + console.log(method.registrationDescription); + if (method.methodDescription !== null) + console.log(method.methodDescription); + console.log( + "Input schema:", + JSON.stringify(method.inputSchema, null, 2), + ); + console.log( + "Output schema:", + JSON.stringify(method.outputSchema, null, 2), + ); + } + }, + ), + ); + plugin .command("search ") .description( diff --git a/apps/server/src/routes/plugins.ts b/apps/server/src/routes/plugins.ts index fb6a84f10c..871254bf67 100644 --- a/apps/server/src/routes/plugins.ts +++ b/apps/server/src/routes/plugins.ts @@ -35,6 +35,7 @@ import { } from "./plugin-image-response.js"; import { pluginApplyUpdateRequestSchema, + pluginRpcDiscoveryQuerySchema, pluginInstallRequestSchema, pluginSettingsUpdateRequestSchema, pluginTokenRequestSchema, @@ -380,6 +381,13 @@ export function registerPluginRoutes( }); }); + app.get("/plugins/rpc", (context) => { + const query = pluginRpcDiscoveryQuerySchema.safeParse(context.req.query()); + if (!query.success) + return context.json({ error: "Invalid RPC discovery query" }, 400); + return context.json(plugins.discoverRpc(query.data)); + }); + app.get("/plugins", (context) => context.json({ plugins: plugins.list() })); app.get("/plugins/contributions", (context) => diff --git a/apps/server/src/services/plugins/plugin-api.ts b/apps/server/src/services/plugins/plugin-api.ts index 1be0f2061c..2603ef23eb 100644 --- a/apps/server/src/services/plugins/plugin-api.ts +++ b/apps/server/src/services/plugins/plugin-api.ts @@ -82,6 +82,7 @@ import { normalizeMentionProviderRegistration, normalizeRealtimePayload, normalizeRpcRegistration, + publishRpcMethod, normalizeWebSocketRouteRegistration, pluginCliCollisionWarning, registerSettingDescriptors, @@ -182,6 +183,7 @@ export interface PluginWebSocketRouteRecord { } export interface PluginRpcHandler { + publication: ReturnType; inputSchema: StandardSchemaV1; outputSchema: StandardSchemaV1; handler: (input: unknown) => unknown; @@ -784,6 +786,7 @@ export function createPluginApi(options: { contract, handlers, rpcHandlers, + options, )) { rpcHandlers.set(name, record); } diff --git a/apps/server/src/services/plugins/plugin-service.ts b/apps/server/src/services/plugins/plugin-service.ts index bf8c863dd7..1a31e3f945 100644 --- a/apps/server/src/services/plugins/plugin-service.ts +++ b/apps/server/src/services/plugins/plugin-service.ts @@ -1,3 +1,7 @@ +import type { + PluginRpcDiscoveryQuery, + PublishedPluginRpcMethod, +} from "@bb/server-contract"; import { watch } from "node:fs"; import { readFile, rm } from "node:fs/promises"; import { join } from "node:path"; @@ -298,6 +302,7 @@ export interface PluginService { id: string, path: string, ): PluginWireLookup; + discoverRpc(query: PluginRpcDiscoveryQuery): PublishedPluginRpcMethod[]; getRpcHandler(id: string, method: string): PluginWireLookup; invokeHttpRoute( id: string, @@ -1724,6 +1729,32 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { ); }, + discoverRpc(query) { + return [...loaded.entries()] + .flatMap(([pluginId, plugin]) => { + if (query.pluginId !== undefined && query.pluginId !== pluginId) + return []; + return [...plugin.handle.rpcHandlers.values()].flatMap( + ({ publication }) => { + if ( + publication === null || + (query.method !== undefined && + publication.method !== query.method) + ) + return []; + return [ + { pluginId, displayName: plugin.manifest.name, ...publication }, + ]; + }, + ); + }) + .sort( + (a, b) => + a.pluginId.localeCompare(b.pluginId) || + a.method.localeCompare(b.method), + ); + }, + getRpcHandler(id, method) { return wireLookup(id, (plugin) => plugin.handle.rpcHandlers.get(method)); }, diff --git a/apps/server/test/public/public-host-management.test.ts b/apps/server/test/public/public-host-management.test.ts index 99b5c45ed9..23b8a43fdf 100644 --- a/apps/server/test/public/public-host-management.test.ts +++ b/apps/server/test/public/public-host-management.test.ts @@ -531,6 +531,7 @@ describe("public host management", () => { }); const revokeHandler = vi.fn(async () => ({ ok: true })); const revokeRecord = { + publication: null, inputSchema: z.object({ machineId: z.string() }), outputSchema: z.object({ ok: z.literal(true) }), handler: revokeHandler, diff --git a/apps/server/test/services/plugins/plugin-sdk.test.ts b/apps/server/test/services/plugins/plugin-sdk.test.ts index 2e57fad5e3..4e8fa42b63 100644 --- a/apps/server/test/services/plugins/plugin-sdk.test.ts +++ b/apps/server/test/services/plugins/plugin-sdk.test.ts @@ -131,6 +131,45 @@ describe("plugin bb.sdk bind gate", () => { ) => ({ pong: true }), ); const disposePluginHost = vi.fn(async () => undefined); + it("discovers only published methods from live implementations and removes them on disable", async () => { + for (const id of ["usage-a", "usage-b"]) { + const rootDir = await writePlugin(workDir, { + name: `bb-plugin-${id}`, + serverSource: `export default function plugin() {}`, + }); + await service.installPath(rootDir); + requireApi(service, id).rpc.register( + defineRpcContract({ + "usage.v1.get": { + input: z.null(), + output: z.object({ percent: z.number() }), + experimental_description: "Current usage", + }, + }), + { "usage.v1.get": () => ({ percent: 42 }) }, + { + experimental_discoverable: true, + experimental_description: "Usage source", + }, + ); + requireApi(service, id).rpc.register( + { internal: { input: z.null(), output: z.null() } }, + { internal: () => null }, + ); + } + expect( + service + .discoverRpc({ method: "usage.v1.get" }) + .map((item) => item.pluginId), + ).toEqual(["usage-a", "usage-b"]); + expect(service.discoverRpc({ method: "internal" })).toEqual([]); + expect(service.discoverRpc({ pluginId: "usage-a" })).toHaveLength(1); + await service.setEnabled("usage-a", false); + expect(service.discoverRpc({}).map((item) => item.pluginId)).toEqual([ + "usage-b", + ]); + }); + beforeEach(async () => { db = createConnection(":memory:"); migrate(db); diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index 02cca0c5ee..2a0b5c2abb 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -1,5 +1,14 @@ # APIs To Audit +## Discoverable RPC + +`bb.rpc.register` accepts optional `experimental_discoverable` and `experimental_description` options. Method definitions accept `experimental_description`. Discoverable registration exports wire schemas through Standard JSON Schema; validation-only schemas remain usable without publication. Descriptions are published separately and absent descriptions become null. Discovery advertises methods without changing RPC authorization or dispatch. + +`bb.sdk.plugins.experimental_discoverRpc({ pluginId?, method? })` lists published methods from loaded plugins. Methods disappear on unload; callers handle the race between discovery and invocation. The SDK RPC caller accepts an optional abort signal. The fake host exposes `experimental_publishedRpcMethods` on its registration inspection surface. + +Before stabilization, audit schema export fidelity (especially refinements and transforms), descriptor size and reference limits, lifecycle races, and cross-plugin copied-schema compatibility. Verify `bb plugin rpc list|inspect` is sufficient to implement a consumer without a shared contract package. Method names carry optional versions; there is no negotiation. + + ## `bb.http.experimental_websocket` **What it does.** Registers an exact-path WebSocket upgrade in the plugin's diff --git a/docs/discoverable-rpc-and-provider-usage-plan.md b/docs/discoverable-rpc-and-provider-usage-plan.md new file mode 100644 index 0000000000..2dfeced6f7 --- /dev/null +++ b/docs/discoverable-rpc-and-provider-usage-plan.md @@ -0,0 +1,197 @@ +# Discoverable RPC and replaceable provider usage displays + +Status: prototype implemented for discoverable RPC, Account Pooler, Codex, Claude Code, and the core `/settings/usage` page. The broader Provider Usage plugin migration remains planned. + +## Prototype verification + +- Relevant typechecks passed across the server, app, CLI, SDK, Plugin SDK, and three source plugins (13 Turbo tasks). +- Focused tests passed: 3 RPC publication tests, 10 server SDK tests, 1 SDK discovery/call test, 2 provider-source tests, 141 Account Pooler server tests, and 13 settings usage tests. +- The isolated dev server advertises all three implementations with registration/method descriptions and JSON Schemas. CLI inspection and invocation passed. +- Desktop and 390-pixel mobile browser checks passed, including refresh and no horizontal overflow. The fresh dev store has no pooled accounts; live host usage and authentication-error states were exercised. +- The prototype trusts the Standard JSON Schema exporter for semantic fidelity; exhaustive refinement/transform fidelity auditing remains a stabilization task. The existing Provider Usage sidebar plugin and `bb settings usage` remain on their previous collection paths. +- The repository verification inventory reports an existing unmapped `browser` CLI family; this prototype does not rewrite that unrelated baseline. + + +## Outcome + +Plugins can opt into publishing their RPC methods for discovery and inspection. Developers and agents can inspect a plugin repository or use the BB CLI to obtain its published contract, then copy the relevant schemas into their own source. + +Provider Usage establishes a usage method convention. Account Pooler and individual provider plugins implement it. Provider Usage and an alternative display such as Provider Usage Plus Plush discover and consume the same implementations. Disabling either display does not affect the sources. + +The first implementation covers usage information. Thread routing attribution, availability decisions, and Provider Retry integration remain separate follow-up work. + +## API + +Keep the existing `defineRpcContract` shape, method addressing, and calls. Add optional descriptions to method definitions and an optional registration argument: + +```ts +const usageContract = defineRpcContract({ + "provider-usage.v1.get": { + experimental_description: + "Returns a complete usage snapshot. refresh=true requests fresh collection and waits for the attempt; individual resource failures are included in the result.", + input: usageInputSchema, + output: usageSnapshotSchema, + }, +}); + +bb.rpc.register(usageContract, { + "provider-usage.v1.get": getUsage, +}, { + experimental_discoverable: true, + experimental_description: + "Usage windows for accounts managed by Account Pooler.", +}); +``` + +The option publishes all methods in that registration. Plugins register internal methods separately. Omitting the option preserves current behavior: methods are callable by name but are not advertised. Discovery is not an authorization boundary. + +Registration-level `experimental_description` explains the purpose and scope of that implementation. Method-level `experimental_description` explains how to call an individual method and interpret its result. Both are optional; descriptions alone never make a registration discoverable. Existing definitions without descriptions continue to work. + +Add a proposed SDK query: + +```ts +const sources = await bb.sdk.plugins.experimental_discoverRpc({ + method: "provider-usage.v1.get", +}); +``` + +Support optional `pluginId` and exact `method` filters; omitting both lists published methods. Return one serializable descriptor per matching method: + +```ts +{ + pluginId: "account-pool", + displayName: "Account Pooler", + method: "provider-usage.v1.get", + registrationDescription: "Usage windows for accounts managed by Account Pooler.", + methodDescription: + "Returns a complete usage snapshot. refresh=true requests fresh collection and waits for the attempt; individual resource failures are included in the result.", + inputSchema: publishedInputJsonSchema, + outputSchema: publishedOutputJsonSchema, +} +``` + +Publish both descriptions separately, without merging them or using one as a fallback for the other. Normalize omitted descriptions to `null` at the server boundary. Validate supplied descriptions as nonempty, bounded strings and include them in descriptor size limits. + +Field descriptions embedded in schemas must survive JSON Schema export. For example, `usedPercent` can explain its units and range. Method descriptions must explain behavior that field types cannot express, such as refresh and caching semantics, side effects, and partial failures. Registration descriptions explain implementation-specific scope. The published descriptions and schemas should provide enough information to call the method using CLI inspection alone. Source and Plugin Guide examples can add detail, but must not be the only place essential calling semantics are documented. Descriptions document behavior; they do not replace schema validation or make an otherwise unsupported schema export valid. + +Consumers retain their own expected schemas and use the existing call API: + +```ts +const results = await Promise.allSettled( + sources.map((source) => bb.sdk.plugins.callRpc({ + pluginId: source.pluginId, + method: "provider-usage.v1.get", + input: { refresh: false }, + outputSchema: usageSnapshotSchema, + })), +); +``` + +Discovery does not replace the consumer's expected schema with the producer's schema. It advertises implementations; normal server and caller validation still applies. + +## Publishing schemas + +Current RPC contracts accept Standard Schema validators. Validation support alone does not guarantee JSON Schema export. Resolve this before exposing the discovery flag: + +1. Add a publication adapter for the installed Zod version and support a validator-neutral JSON Schema export capability where available. +2. Publish portable input and output JSON Schemas with a declared dialect and locally resolvable references. Do not publish executable validators or fetch remote references during inspection. +3. Describe wire values: request JSON before handler-side parsing and response JSON after server-side output parsing. Do not silently treat transformed handler types as wire schemas. +4. Fail discoverable registration with a method-specific error when export is unsupported or lossy. Preserve anonymous registration for all currently supported validators. +5. If supporting another validator requires explicit publication schemas, design that escape hatch separately rather than guessing schemas or advertising an unrestricted object. + +JSON Schema does not encode every semantic restriction. Custom refinements and transformations need deliberate handling; unsupported cases must not silently disappear from the published contract. The initial usage contract should use schemas that can be exported faithfully. + +Runtime descriptors have size limits and are generated at registration, not on every discovery request. Schema inspection never invokes plugin handlers. No schema hashes, shared schema packages, dynamic schema imports, or version negotiation are required. + +## Lifecycle and compatibility + +- Publish methods only after successful plugin load. A failed registration or failed load publishes nothing from that candidate load. +- Remove descriptors on unload and replace them consistently with handlers on reload. Avoid mixing descriptors and handlers from different generations. +- Discover only currently callable implementations. Return deterministic ordering by plugin ID and method. No matches returns an empty list. +- Keep duplicate method rejection within a plugin. Different plugins may publish the same method name. +- Preserve existing authentication for inspection and calls. Never include credentials, settings values, or handler results in descriptors. +- Discovery is a snapshot. A source may unload before invocation; callers handle individual failures. Do not retry arbitrary RPC calls automatically. +- Preserve plugin-and-method addressing. Optional versioning uses names such as `provider-usage.v1.get` and `provider-usage.v2.get`. Both can coexist. +- A method-name match does not prove compatibility. Breaking schema or behavioral changes require a new method name by convention; compatible changes retain the name. +- No runtime dependency on the plugin that originally authored the convention is introduced. + +## CLI and sharing workflow + +Add discoverable CLI surfaces backed by the same SDK query: + +```sh +bb plugin rpc list --json +bb plugin rpc list --method provider-usage.v1.get --json +bb plugin rpc inspect account-pool --json +bb plugin rpc inspect account-pool --method provider-usage.v1.get --json +``` + +Listing presents identities and method names; inspection includes registration descriptions, method descriptions, and published schemas with field descriptions preserved. JSON output is sufficient for copying or generating local schema definitions. TypeScript generation is outside the initial scope because JSON Schema cannot reconstruct arbitrary validator source. + +A consumer author inspects a producer's source or CLI output, copies the relevant contract into their plugin, and calls the known method. Updates remain explicit source changes reviewed and tested by that consumer. The contract author's plugin package does not become a dependency. + +## Usage pilot + +Use `provider-usage.v1.get` as the shared method. Provider Usage documents the canonical convention in its source; each producer and alternative consumer owns a local definition. Producers publish its calling semantics in the method description and describe their own resource scope in the registration description. + +Request: `{ refresh: boolean }`. + +Response: a complete snapshot of the resources owned by that implementation. Each resource includes a stable source-local ID, provider ID, display label, host or shared scope, observation timestamp, and a discriminated collection result. Successful results contain individually identified windows with utilization, reset time, optional cost information, and model applicability. Authentication failures, collection failures, and unobserved data must be explicit states rather than zero usage. Finalize and copy one concrete schema before implementing adapters. + +Use the same milliseconds-based timestamp convention throughout. An observation timestamp describes the underlying measurement, not the time the RPC was called. If stale values are retained after a collection failure, preserve their original timestamp and expose the failed refresh separately. + +`refresh: false` permits the source's cached measurements. `refresh: true` requests fresh collection and waits for the attempt; collection failures must remain visible. Coalesce overlapping refreshes in sources. The display owns its own fetch cache, but does not relabel cached measurements as freshly observed. + +### Source implementations + +- Account Pooler exposes its accounts and existing quota state with shared scope. Refresh delegates to its existing collection logic; it does not alter routing. +- Provider plugins expose host-local resources by calling their existing host usage maintenance capability. A disconnected host or failed account collection becomes an individual resource outcome and does not discard other results. +- Both register the discoverable method regardless of whether Provider Usage is installed or enabled. + +### Display implementation + +Provider Usage discovers sources whenever it loads or refreshes data, invokes them with bounded concurrency and bounded wait, and renders successful results even if another source fails. It groups observations by provider and reporting source, with explicit host/shared labels and freshness. + +Namespace resource keys by reporting plugin ID. Do not deduplicate by email or sum unrelated quota percentages. A shared pool appears once; a local account and a pool account may both appear even when their labels match. Source removal evicts its current display entries on the next reconciliation. + +An alternative display uses the same discovery query and its own copied response schema. It may render richer UI without changing any producer. Both displays can run at once; producer refresh coalescing limits duplicate work. + +### Existing SDK and CLI usage surfaces + +Preserve `bb.sdk.system.usageLimits()` and `bb settings usage --json` as existing host-provider maintenance views during the first rollout. Do not silently change their response shape or use them as the unified view. + +Expose Provider Usage's unified display snapshot through its RPC and a plugin-owned CLI command, proposed as `bb provider-usage status [--refresh] [--json]`. Document the distinction. Consumers needing replacement-independent data can discover and call the source methods directly. Provider Usage must stop collecting the same host usage independently once provider plugins supply it through discovery. + +## Delivery sequence + +1. **Schema publication:** implement export support and registration validation. Verify portable wire schemas for real usage types and unchanged anonymous RPC behavior. +2. **Registry and inspection:** add opt-in publication, lifecycle-safe descriptors, targeted discovery route, SDK query, and CLI listing/inspection. +3. **Contracts and documentation:** publish copyable examples, method naming guidance, schema limitations, and lifecycle semantics in Plugin Guide. Add SDK surfaces to `packages/plugin-api-map/src/surfaces.ts` and audit entries to `docs/api_to_audit.md`; update CLI guide templates and skills. +4. **Usage sources:** implement the convention in Account Pooler and provider plugins using existing collection primitives. +5. **Usage display:** migrate Provider Usage to discovery, expose the unified snapshot through its RPC/CLI, and verify a second display consumer against the same sources. + +Keep the work server-side unless inspection proves host wire changes are necessary. Existing host usage maintenance remains a primitive. If any server/daemon wire fields change, increment `HOST_DAEMON_PROTOCOL_VERSION` unless previous-daemon compatibility is deliberately preserved and tested. + +## Verification and completion criteria + +Use Turbo for relevant package typechecks and tests. Extend the plugin test harness to inspect published descriptors and exercise discovery across plugins. + +- Existing unnamed registrations and calls behave unchanged. +- Unadvertised methods remain callable but absent from discovery and inspection. +- Unsupported schema export fails clearly and publishes no partial registration. +- Exported schemas accurately describe representative input/output wire values. +- Registration, method, and field descriptions survive publication and CLI inspection independently; omitted descriptions are explicit `null` values in descriptors. +- Descriptions alone do not advertise methods. Existing contracts without descriptions remain valid, and invalid or oversized descriptions fail clearly. +- Usage inspection explains refresh behavior, resource scope, and partial failures without requiring repository access. +- Duplicate methods, failed load, unload, and reload preserve registry consistency. +- Two implementations of one method and v1/v2 methods coexist without new routing rules. +- A consumer with a copied incompatible schema gets a validation failure rather than trusted data. +- CLI and SDK inspection agree and do not execute handlers. +- An externally built fixture with copied schemas works without a shared contract package or a display plugin dependency. +- Account Pooler and local providers appear together with correct scope, errors, and observation times. +- One source failing, disconnecting, or unloading does not hide successful sources. +- Refresh reaches sources and overlapping refreshes coalesce. +- Disabling Provider Usage leaves source discovery and calls functional. A replacement display produces the same underlying observations. +- Relevant Provider Usage UI journeys and plugin CLI flows pass the repository's verification workflow. + +The result is complete when discovery and inspection are generally usable, usage producers implement the public convention, and either display can consume them independently. Provider Retry and thread-specific quota attribution are not prerequisites. diff --git a/packages/plugin-api-map/src/surfaces.ts b/packages/plugin-api-map/src/surfaces.ts index cc5c20d354..2b31584aaf 100644 --- a/packages/plugin-api-map/src/surfaces.ts +++ b/packages/plugin-api-map/src/surfaces.ts @@ -659,11 +659,14 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ "Connects the plugin's own UI, its server code, and outside services. With this, a plugin can:", bullets: [ "Call its server from its UI over RPC, with arguments and results checked against a schema", + "Publish RPC methods with experimental_discoverable and registration/method experimental_description; other plugins discover implementations and copy their published JSON Schemas using bb plugin rpc inspect", "Serve exact-path HTTP and WebSocket routes other systems can call, webhooks included", "Push messages to every open bb window, so the UI does not have to poll", ], apiSymbols: [ "PluginRpc", + "PluginRpcMethodContract", + "PluginsArea.experimental_discoverRpc", "PluginHttp", "PluginRealtime", "ExperimentalPluginWebSocket", diff --git a/packages/plugin-sdk/src/backend-contract.ts b/packages/plugin-sdk/src/backend-contract.ts index 7188340c09..94bf6fb03f 100644 --- a/packages/plugin-sdk/src/backend-contract.ts +++ b/packages/plugin-sdk/src/backend-contract.ts @@ -824,6 +824,10 @@ export interface PluginRpc { register( contract: Contract, handlers: PluginRpcHandlers, + options?: { + experimental_discoverable?: boolean; + experimental_description?: string; + }, ): void; } diff --git a/packages/plugin-sdk/src/internal/host-policy.ts b/packages/plugin-sdk/src/internal/host-policy.ts index 34bbf47bb7..b372ae2dc3 100644 --- a/packages/plugin-sdk/src/internal/host-policy.ts +++ b/packages/plugin-sdk/src/internal/host-policy.ts @@ -1688,6 +1688,20 @@ export function isStandardSchema(value: unknown): value is StandardSchemaV1 { ); } +const rpcDescriptionSchema = z + .string() + .trim() + .min(1) + .max(4096) + .optional() + .transform((value) => value ?? null); +const rpcPublicationOptionsSchema = z + .object({ + experimental_discoverable: z.boolean().default(false), + experimental_description: rpcDescriptionSchema, + }) + .strict(); + function readRpcMethodContract( method: string, value: unknown, @@ -1709,7 +1723,80 @@ function readRpcMethodContract( `rpc method "${method}" output must be a Standard Schema v1 validator`, ); } - return { input, output }; + const description = rpcDescriptionSchema.parse( + Reflect.get(value, "experimental_description"), + ); + return description === null + ? { input, output } + : { input, output, experimental_description: description }; +} + +export function readRpcPublicationOptions(value: unknown) { + return rpcPublicationOptionsSchema.parse(value ?? {}); +} + +function publishedRpcSchema( + schema: StandardSchemaV1, + direction: "input" | "output", +) { + const converter = schema["~standard"].jsonSchema; + if (converter === undefined || typeof converter[direction] !== "function") { + throw new Error( + "discoverable RPC requires Standard JSON Schema export support", + ); + } + const serialized = JSON.stringify( + converter[direction]({ target: "draft-2020-12" }), + ); + if ( + serialized === undefined || + new TextEncoder().encode(serialized).byteLength > 128 * 1024 + ) { + throw new Error("published RPC schema must be JSON and at most 128 KiB"); + } + const result = z + .record(z.string(), jsonValueSchema) + .parse(JSON.parse(serialized)); + const inspect = (value: JsonValue): void => { + if (value === null || typeof value !== "object") return; + if (Array.isArray(value)) { + for (const item of value) inspect(item); + return; + } + for (const [key, item] of Object.entries(value)) { + if ( + (key === "$ref" || key === "$dynamicRef") && + typeof item === "string" && + !item.startsWith("#") + ) { + throw new Error("published RPC schemas must use local references"); + } + inspect(item); + } + }; + inspect(result); + return result; +} + +export function publishRpcMethod( + method: string, + contract: PluginRpcMethodContract, + options: ReturnType, +) { + if (!options.experimental_discoverable) return null; + try { + return { + method, + registrationDescription: options.experimental_description, + methodDescription: contract.experimental_description ?? null, + inputSchema: publishedRpcSchema(contract.input, "input"), + outputSchema: publishedRpcSchema(contract.output, "output"), + }; + } catch (error) { + throw new Error( + `rpc method "${method}" cannot be published: ${error instanceof Error ? error.message : String(error)}`, + ); + } } /** Duck-typed zod detection: plugin sources may carry their own zod copy, @@ -2792,6 +2879,7 @@ export function normalizeWebSocketRouteRegistration( } type RpcRegistrationRecord = { + publication: ReturnType; inputSchema: StandardSchemaV1; outputSchema: StandardSchemaV1; handler: (input: unknown) => unknown; @@ -2801,6 +2889,7 @@ export function normalizeRpcRegistration( contract: unknown, handlers: unknown, registered: ReadonlyMap, + options: unknown, ): Array<[string, RpcRegistrationRecord]> { if ( typeof contract !== "object" || @@ -2816,6 +2905,7 @@ export function normalizeRpcRegistration( ) { throw new Error("rpc.register handlers must be an object"); } + const publicationOptions = readRpcPublicationOptions(options); const pending: Array<[string, RpcRegistrationRecord]> = []; const contractEntries = Object.entries(contract); const contractNames = new Set(contractEntries.map(([name]) => name)); @@ -2843,6 +2933,7 @@ export function normalizeRpcRegistration( pending.push([ name, { + publication: publishRpcMethod(name, methodContract, publicationOptions), inputSchema: methodContract.input, outputSchema: methodContract.output, handler, diff --git a/packages/plugin-sdk/src/rpc-contract.ts b/packages/plugin-sdk/src/rpc-contract.ts index 46d2345ebb..7e1d45a5c9 100644 --- a/packages/plugin-sdk/src/rpc-contract.ts +++ b/packages/plugin-sdk/src/rpc-contract.ts @@ -37,6 +37,10 @@ export interface StandardSchemaV1 { ) => | StandardSchemaV1Result | Promise>; + readonly jsonSchema?: { + readonly input: (options: { target: string }) => Record; + readonly output: (options: { target: string }) => Record; + }; readonly types?: { readonly input: Input; readonly output: Output; @@ -65,6 +69,7 @@ export interface PluginRpcMethodContract< InputSchema extends StandardSchemaV1 = StandardSchemaV1, OutputSchema extends StandardSchemaV1 = StandardSchemaV1, > { + readonly experimental_description?: string; readonly input: InputSchema; readonly output: OutputSchema; } diff --git a/packages/plugin-sdk/src/testing/__tests__/rpc-discovery.test.ts b/packages/plugin-sdk/src/testing/__tests__/rpc-discovery.test.ts new file mode 100644 index 0000000000..5e496d81d3 --- /dev/null +++ b/packages/plugin-sdk/src/testing/__tests__/rpc-discovery.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + defineRpcContract, + type StandardSchemaV1, +} from "../../rpc-contract.js"; +import { createFakePluginHost } from "../index.js"; + +const exported = defineRpcContract({ + "usage.v1.get": { + experimental_description: "Reads cached usage unless refresh is true.", + input: z.object({ + refresh: z.boolean().describe("Request fresh measurements."), + }), + output: z.object({ percent: z.number() }), + }, +}); + +describe("discoverable RPC registration", () => { + it("publishes descriptions and wire schemas while leaving other methods private", async () => { + const { bb, harness } = createFakePluginHost(); + try { + bb.rpc.register( + exported, + { "usage.v1.get": () => ({ percent: 42 }) }, + { + experimental_discoverable: true, + experimental_description: "Shared accounts", + }, + ); + bb.rpc.register( + { private: { input: z.null(), output: z.null() } }, + { private: () => null }, + { + experimental_description: + "A description alone does not publish this method.", + }, + ); + expect(harness.registrations.experimental_publishedRpcMethods).toEqual([ + { + method: "usage.v1.get", + registrationDescription: "Shared accounts", + methodDescription: "Reads cached usage unless refresh is true.", + inputSchema: expect.objectContaining({ + properties: { + refresh: { + type: "boolean", + description: "Request fresh measurements.", + }, + }, + }), + outputSchema: expect.objectContaining({ + properties: { percent: { type: "number" } }, + }), + }, + ]); + await expect( + harness.behavior.callRpc("private", null), + ).resolves.toBeNull(); + await expect( + harness.behavior.callRpc("usage.v1.get", { refresh: false }), + ).resolves.toEqual({ percent: 42 }); + } finally { + await harness.lifecycle.dispose(); + } + }); + + it("rejects unexportable methods atomically but accepts them without publication", async () => { + const { bb, harness } = createFakePluginHost(); + const validator: StandardSchemaV1 = { + "~standard": { + version: 1, + vendor: "validation-only", + validate: (value) => ({ value }), + }, + }; + const contract = defineRpcContract({ + first: { input: z.null(), output: z.null() }, + second: { input: validator, output: validator }, + }); + try { + expect(() => + bb.rpc.register( + contract, + { first: () => null, second: () => null }, + { experimental_discoverable: true }, + ), + ).toThrow('rpc method "second" cannot be published'); + expect(harness.registrations.rpcMethods).toEqual([]); + expect(harness.registrations.experimental_publishedRpcMethods).toEqual( + [], + ); + bb.rpc.register(contract, { first: () => null, second: () => null }); + await expect( + harness.behavior.callRpc("second", null), + ).resolves.toBeNull(); + } finally { + await harness.lifecycle.dispose(); + } + }); + + it("replaces descriptors on reload and keeps null descriptions explicit", async () => { + let host = createFakePluginHost(); + try { + host = await host.harness.lifecycle.reload((bb) => { + bb.rpc.register( + exported, + { "usage.v1.get": () => ({ percent: 42 }) }, + { experimental_discoverable: true }, + ); + }); + expect( + host.harness.registrations.experimental_publishedRpcMethods[0] + ?.registrationDescription, + ).toBeNull(); + host = await host.harness.lifecycle.reload((bb) => { + bb.rpc.register( + { "usage.v2.get": { input: z.null(), output: z.null() } }, + { "usage.v2.get": () => null }, + { experimental_discoverable: true }, + ); + }); + expect( + host.harness.registrations.experimental_publishedRpcMethods.map( + (item) => item.method, + ), + ).toEqual(["usage.v2.get"]); + } finally { + await host.harness.lifecycle.dispose(); + } + }); +}); diff --git a/packages/plugin-sdk/src/testing/fake-plugin-host.ts b/packages/plugin-sdk/src/testing/fake-plugin-host.ts index c3aed2f11a..a6edafca4a 100644 --- a/packages/plugin-sdk/src/testing/fake-plugin-host.ts +++ b/packages/plugin-sdk/src/testing/fake-plugin-host.ts @@ -34,6 +34,7 @@ import { normalizeRealtimePayload, normalizeRpcJsonResult, normalizeRpcRegistration, + publishRpcMethod, normalizeWebSocketRouteRegistration, pluginCliCollisionWarning, providerAlreadyRegisteredMessage, @@ -582,6 +583,7 @@ function jsonRoundTrip(value: unknown, what: string): unknown { } interface FakeRpcRecord { + publication: ReturnType; inputSchema: StandardSchemaV1; outputSchema: StandardSchemaV1; handler: (input: never) => unknown; @@ -841,6 +843,7 @@ function createFakePluginHostInternal( contract, handlers, rpcHandlers, + options, )) { rpcHandlers.set(name, record); } diff --git a/packages/sdk/src/areas/plugins.ts b/packages/sdk/src/areas/plugins.ts index 8707f73df0..0c11a9a7cc 100644 --- a/packages/sdk/src/areas/plugins.ts +++ b/packages/sdk/src/areas/plugins.ts @@ -1,6 +1,10 @@ import { jsonValueSchema, type JsonValue } from "@bb/domain"; import { installedPluginSchema, + pluginRpcDiscoveryQuerySchema, + pluginRpcDiscoveryResponseSchema, + type PluginRpcDiscoveryQuery, + type PublishedPluginRpcMethod, pluginCatalogInstallPlanResponseSchema, pluginCatalogInstallRequestSchema, pluginCatalogSearchResponseSchema, @@ -142,6 +146,7 @@ export interface PluginCheckUpdatesArgs { } export interface PluginRpcArgs extends PluginIdArgs { + signal?: AbortSignal; input?: JsonValue; method: string; outputSchema: z.ZodType; @@ -218,6 +223,9 @@ export interface PluginMarketplacesArea { } export interface PluginsArea { + experimental_discoverRpc( + args?: PluginRpcDiscoveryQuery, + ): Promise; applyUpdate(args: PluginIdArgs): Promise; callRpc(args: PluginRpcArgs): Promise; checkUpdates( @@ -376,11 +384,19 @@ export function createPluginsArea(args: CreateSdkAreaArgs): PluginsArea { jsonInit("POST", body), ); }, + async experimental_discoverRpc(input = {}) { + const query = pluginRpcDiscoveryQuerySchema.parse(input); + const params = new URLSearchParams(query); + return requestParsed( + `/api/v1/plugins/rpc?${params}`, + pluginRpcDiscoveryResponseSchema, + ); + }, async callRpc(input) { const envelope = await requestParsed( pluginPath(input.pluginId, `/rpc/${encodeURIComponent(input.method)}`), z.object({ ok: z.literal(true), result: jsonValueSchema }), - jsonInit("POST", input.input ?? null), + { ...jsonInit("POST", input.input ?? null), signal: input.signal }, ); return input.outputSchema.parse(envelope.result); }, diff --git a/packages/sdk/test/plugin-rpc-discovery.test.ts b/packages/sdk/test/plugin-rpc-discovery.test.ts new file mode 100644 index 0000000000..47e5e51847 --- /dev/null +++ b/packages/sdk/test/plugin-rpc-discovery.test.ts @@ -0,0 +1,55 @@ +import { expect, it } from "vitest"; +import { z } from "zod"; +import { createBbSdk } from "../src/core.js"; +import { createHttpTransport } from "../src/transport-http.js"; + +it("discovers published methods with filters and calls using a copied response schema", async () => { + const requests: string[] = []; + const sdk = createBbSdk({ + transport: createHttpTransport({ + baseUrl: "http://bb.test", + runtime: "node", + fetch: async (input) => { + const url = String(input); + requests.push(url); + return Response.json( + url.includes("/rpc?") + ? [ + { + pluginId: "pool", + displayName: "Pool", + method: "usage.v1.get", + registrationDescription: "Shared accounts", + methodDescription: null, + inputSchema: { type: "null" }, + outputSchema: { type: "object" }, + }, + ] + : { ok: true, result: { percent: 42 } }, + ); + }, + }), + }); + const [source] = await sdk.plugins.experimental_discoverRpc({ + pluginId: "pool", + method: "usage.v1.get", + }); + expect(source?.registrationDescription).toBe("Shared accounts"); + expect(requests[0]).toContain("pluginId=pool&method=usage.v1.get"); + await expect( + sdk.plugins.callRpc({ + pluginId: "pool", + method: "usage.v1.get", + input: null, + outputSchema: z.object({ percent: z.number() }), + }), + ).resolves.toEqual({ percent: 42 }); + await expect( + sdk.plugins.callRpc({ + pluginId: "pool", + method: "usage.v1.get", + input: null, + outputSchema: z.object({ percent: z.string() }), + }), + ).rejects.toThrow(); +}); diff --git a/packages/sdk/test/public-types.test.ts b/packages/sdk/test/public-types.test.ts index 47b5ec9612..e6359b5063 100644 --- a/packages/sdk/test/public-types.test.ts +++ b/packages/sdk/test/public-types.test.ts @@ -293,6 +293,7 @@ type ExpectedHostsKey = | "update"; type ExpectedPluginsKey = + | "experimental_discoverRpc" | "applyUpdate" | "callRpc" | "catalog" diff --git a/packages/server-contract/src/api/plugins.ts b/packages/server-contract/src/api/plugins.ts index 6421cb7a78..d6e6fd04dd 100644 --- a/packages/server-contract/src/api/plugins.ts +++ b/packages/server-contract/src/api/plugins.ts @@ -530,3 +530,27 @@ export type PluginMarketplaceRefreshResult = z.infer< export const pluginMarketplaceRefreshResponseSchema = z.object({ results: z.array(pluginMarketplaceRefreshResultSchema), }); + +export const pluginRpcDiscoveryQuerySchema = z.object({ + pluginId: z.string().min(1).optional(), + method: z.string().min(1).optional(), +}); +export type PluginRpcDiscoveryQuery = z.infer< + typeof pluginRpcDiscoveryQuerySchema +>; + +export const publishedPluginRpcMethodSchema = z.object({ + pluginId: z.string().min(1), + displayName: z.string().min(1), + method: z.string().min(1), + registrationDescription: z.string().nullable(), + methodDescription: z.string().nullable(), + inputSchema: z.record(z.string(), jsonValueSchema), + outputSchema: z.record(z.string(), jsonValueSchema), +}); +export type PublishedPluginRpcMethod = z.infer< + typeof publishedPluginRpcMethodSchema +>; +export const pluginRpcDiscoveryResponseSchema = z.array( + publishedPluginRpcMethodSchema, +); diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index 280184190c..f7e03fa856 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -921,3 +921,9 @@ Contributed commands may accept `--stdin`: the calling CLI transfers up to The existing `---stdin` form still accepts one line. Modal image debugging: `bb modal image build [--json]` prepares the saved image; `bb modal sandbox run [--json]` starts a 30-minute standalone sandbox; `bb modal sandbox exec ID [--json] -- COMMAND...` runs a command (60-second timeout); `bb modal sandbox stop ID [--json]` cleans up. These debug sandboxes skip BB enrollment, clone and setup. Logs are returned after the build finishes. + +## Inspect plugin RPC + +`bb plugin rpc list [--method ] [--json]` lists discoverable methods from running plugins. `bb plugin rpc inspect [--method ] [--json]` dumps registration and method descriptions plus input/output JSON Schemas. Copy the relevant schema into your consumer and call the existing plugin RPC endpoint. Discovery is opt-in advertising, not access control; method names may carry versions such as `provider-usage.v1.get`. + +`bb plugin rpc call [--input-file ] [--json]` invokes a method using server-side schema validation. Omitting the input file sends JSON null. Input files avoid putting sensitive values in command arguments. diff --git a/plugins/account-pool/skills/account-pool/references/accounts-and-routing.md b/plugins/account-pool/skills/account-pool/references/accounts-and-routing.md index f6c39ae838..bfeb7cef59 100644 --- a/plugins/account-pool/skills/account-pool/references/accounts-and-routing.md +++ b/plugins/account-pool/skills/account-pool/references/accounts-and-routing.md @@ -78,3 +78,7 @@ one provider. Include disabled accounts too. Reordering changes the next failove sequence without moving the current account. `bb pool account priority ` sets an individual priority; the same operations are available through the `account.reorder` and `account.setPriority` plugin RPCs. + +## Discoverable usage + +This plugin exposes `provider-usage.v1.get` for independent usage displays. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect account-pool --method provider-usage.v1.get --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. diff --git a/plugins/account-pool/src/server.test.ts b/plugins/account-pool/src/server.test.ts index bb06e7bb44..22b70412f9 100644 --- a/plugins/account-pool/src/server.test.ts +++ b/plugins/account-pool/src/server.test.ts @@ -1,3 +1,4 @@ +import { usageSnapshotSchema } from "./usage-contract.js"; import fs from "node:fs/promises"; import http, { type IncomingMessage, type ServerResponse } from "node:http"; import path from "node:path"; @@ -5812,3 +5813,33 @@ it("drains a streamed response before disposing the owned transport", async () = await disposing; } }); + +it("publishes pooled usage without a display plugin and does not invent unobserved utilization", async () => { + const upstream = await startUpstream((_request, response) => { + response.end(); + }); + cleanups.push(upstream.close); + const fixture = await createFixture({ upstreamUrl: upstream.url }); + const result = usageSnapshotSchema.parse( + await fixture.host.harness.behavior.callRpc("provider-usage.v1.get", { + refresh: false, + }), + ); + expect(result.resources).toEqual([ + expect.objectContaining({ + id: fixture.account.id, + providerId: "claude-code", + scope: { kind: "shared" }, + observedAt: null, + usage: expect.objectContaining({ + status: "error", + message: "Usage has not been observed for this account.", + }), + }), + ]); + expect( + fixture.host.harness.registrations.experimental_publishedRpcMethods.map( + (entry) => entry.method, + ), + ).toEqual(["provider-usage.v1.get"]); +}); diff --git a/plugins/account-pool/src/server.ts b/plugins/account-pool/src/server.ts index 5a9079139d..4f6d62281a 100644 --- a/plugins/account-pool/src/server.ts +++ b/plugins/account-pool/src/server.ts @@ -1,3 +1,4 @@ +import { registerUsageSource } from "./usage-source.js"; import { createUpstreamTransport, transportErrorCode, @@ -162,6 +163,7 @@ export function createAccountPoolPlugin( "Add and enable a Claude or Codex account with `bb pool account add`.", ); } + registerUsageSource(bb, hub); bb.rpc.register( accountPoolRpcContract, createRpcHandlers(operations, login, codexLogin, config), diff --git a/plugins/account-pool/src/usage-contract.ts b/plugins/account-pool/src/usage-contract.ts new file mode 100644 index 0000000000..9adf55ce87 --- /dev/null +++ b/plugins/account-pool/src/usage-contract.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; + +const accountFields = { + accountEmail: z.string().nullable(), + planLabel: z.string().nullable(), +}; +const usageWindowSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1), + usedPercent: z + .number() + .nonnegative() + .describe("Percentage consumed; may exceed 100 for overage."), + resetsAt: z + .string() + .nullable() + .describe("ISO timestamp, or null when no reset is known."), + model: z + .string() + .nullable() + .describe("Applicable model family, or null for all models."), + cost: z + .object({ + usedUsdCents: z.number().nonnegative(), + limitUsdCents: z.number().positive(), + }) + .nullable(), +}); +const usageSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("ok"), + ...accountFields, + windows: z.array(usageWindowSchema), + }), + z.object({ status: z.literal("not_installed"), ...accountFields }), + z.object({ status: z.literal("unauthenticated"), ...accountFields }), + z.object({ status: z.literal("expired"), ...accountFields }), + z.object({ + status: z.literal("error"), + ...accountFields, + message: z.string(), + }), +]); +export const usageSnapshotSchema = z.object({ + resources: z.array( + z.object({ + id: z + .string() + .min(1) + .describe("Stable resource ID within the reporting plugin."), + providerId: z.string().min(1), + label: z.string().min(1), + scope: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("shared") }), + z.object({ + kind: z.literal("host"), + hostId: z.string().min(1), + hostName: z.string().min(1), + }), + ]), + observedAt: z + .number() + .int() + .nonnegative() + .nullable() + .describe( + "Measurement time in epoch milliseconds; null if never observed.", + ), + usage: usageSchema, + }), + ), +}); +export type UsageSnapshot = z.infer; +export const usageInputSchema = z.object({ + refresh: z + .boolean() + .describe( + "Request fresh collection and wait for the attempt; false permits cached observations.", + ), +}); diff --git a/plugins/account-pool/src/usage-source.ts b/plugins/account-pool/src/usage-source.ts new file mode 100644 index 0000000000..744d2b2b77 --- /dev/null +++ b/plugins/account-pool/src/usage-source.ts @@ -0,0 +1,118 @@ +import { defineRpcContract, type BbPluginApi } from "@get-bb/plugin-sdk"; +import type { AccountPoolHub } from "./hub.js"; +import { + usageInputSchema, + usageSnapshotSchema, + type UsageSnapshot, +} from "./usage-contract.js"; + +export function registerUsageSource(bb: BbPluginApi, hub: AccountPoolHub) { + bb.rpc.register( + defineRpcContract({ + "provider-usage.v1.get": { + experimental_description: + "Returns a complete snapshot of pooled account usage across hosts. refresh=true asks the pool to refresh eligible OAuth accounts; busy accounts retain their previous observation time. API key accounts may not expose usage. Unknown measurements are not reported as zero.", + input: usageInputSchema, + output: usageSnapshotSchema, + }, + }), + { + async "provider-usage.v1.get"({ refresh }) { + await hub.refreshUsage(undefined, refresh); + const { accounts } = await hub.status(); + const resources: UsageSnapshot["resources"] = accounts.map( + (account) => { + const windows: Extract< + UsageSnapshot["resources"][number]["usage"], + { status: "ok" } + >["windows"] = []; + const add = ( + id: string, + label: string, + utilization: number | null, + reset: number | null, + model: string | null, + ) => { + if (utilization === null) return; + windows.push({ + id, + label, + usedPercent: Math.round(utilization * 100), + resetsAt: reset === null ? null : new Date(reset).toISOString(), + model, + cost: null, + }); + }; + if (account.limitWindows.length > 0) { + for (const window of account.limitWindows) { + add( + window.slot, + window.windowMinutes === null + ? window.slot + : `${window.windowMinutes / 60} hour window`, + window.utilization, + window.resetAt, + null, + ); + } + } else { + add( + "five-hour", + "5 hours", + account.fiveHourUtilization, + account.fiveHourResetAt, + null, + ); + add( + "weekly", + "Weekly", + account.sevenDayUtilization, + account.sevenDayResetAt, + null, + ); + } + for (const [family, window] of Object.entries( + account.familyWeekly, + )) { + if (window !== null) + add( + `weekly:${family}`, + `Weekly · ${family}`, + window.utilization, + window.resetAt, + family, + ); + } + const accountFields = { + accountEmail: account.email, + planLabel: account.subscriptionType, + }; + const error = + account.error ?? + (account.observedAt === null + ? "Usage has not been observed for this account." + : null); + return { + id: account.id, + providerId: + account.provider === "claude" ? "claude-code" : "codex", + label: account.label, + scope: { kind: "shared" }, + observedAt: account.observedAt, + usage: + error === null + ? { status: "ok", ...accountFields, windows } + : { status: "error", ...accountFields, message: error }, + }; + }, + ); + return { resources }; + }, + }, + { + experimental_discoverable: true, + experimental_description: + "Usage windows for Account Pooler's shared accounts, independent of routing settings and host-local credentials.", + }, + ); +} diff --git a/plugins/bb-guide/skills/bb-cli/references/plugins.md b/plugins/bb-guide/skills/bb-cli/references/plugins.md index 3a55acdad3..9109247de5 100644 --- a/plugins/bb-guide/skills/bb-cli/references/plugins.md +++ b/plugins/bb-guide/skills/bb-cli/references/plugins.md @@ -216,3 +216,9 @@ tools and context, host-rendered UI, lifecycle) and the frontend `@get-bb/plugin-sdk/app` contract (slots, hooks, UI kit), with working patterns and gotchas. `bb guide plugins` has the short walkthrough. + +## Inspect plugin RPC + +`bb plugin rpc list [--method ] [--json]` lists discoverable methods from running plugins. `bb plugin rpc inspect [--method ] [--json]` dumps registration and method descriptions plus input/output JSON Schemas. Copy the relevant schema into your consumer and call the existing plugin RPC endpoint. Discovery is opt-in advertising, not access control; method names may carry versions such as `provider-usage.v1.get`. + +`bb plugin rpc call [--input-file ] [--json]` invokes a method using server-side schema validation. Omitting the input file sends JSON null. Input files avoid putting sensitive values in command arguments. diff --git a/plugins/provider-claude-code/server.ts b/plugins/provider-claude-code/server.ts index 28e2ee95ee..bf08984c7f 100644 --- a/plugins/provider-claude-code/server.ts +++ b/plugins/provider-claude-code/server.ts @@ -1,3 +1,4 @@ +import { registerUsageSource } from "./src/usage-source.js"; import type { BbPluginApi } from "@get-bb/plugin-sdk"; import { CLAUDE_CODE_ACTIVE_CATALOG_DATA, @@ -7,6 +8,7 @@ import { import { CLAUDE_NATIVE_ROOTS_DECLARATION } from "./src/native-roots.js"; export default function plugin(bb: BbPluginApi) { + registerUsageSource(bb); bb.settings.define({ memoryEnabled: { type: "boolean", diff --git a/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md b/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md index 48823e8cca..c1a572cbd3 100644 --- a/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md +++ b/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md @@ -18,3 +18,7 @@ with `bb plugin config provider-claude-code set `. Inspect the thread and provider state after a change; do not restart unrelated threads or change settings merely to answer a question. + +## Discoverable usage + +This plugin exposes `provider-usage.v1.get` for independent usage displays. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect provider-claude-code --method provider-usage.v1.get --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. diff --git a/plugins/provider-claude-code/src/usage-contract.ts b/plugins/provider-claude-code/src/usage-contract.ts new file mode 100644 index 0000000000..9adf55ce87 --- /dev/null +++ b/plugins/provider-claude-code/src/usage-contract.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; + +const accountFields = { + accountEmail: z.string().nullable(), + planLabel: z.string().nullable(), +}; +const usageWindowSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1), + usedPercent: z + .number() + .nonnegative() + .describe("Percentage consumed; may exceed 100 for overage."), + resetsAt: z + .string() + .nullable() + .describe("ISO timestamp, or null when no reset is known."), + model: z + .string() + .nullable() + .describe("Applicable model family, or null for all models."), + cost: z + .object({ + usedUsdCents: z.number().nonnegative(), + limitUsdCents: z.number().positive(), + }) + .nullable(), +}); +const usageSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("ok"), + ...accountFields, + windows: z.array(usageWindowSchema), + }), + z.object({ status: z.literal("not_installed"), ...accountFields }), + z.object({ status: z.literal("unauthenticated"), ...accountFields }), + z.object({ status: z.literal("expired"), ...accountFields }), + z.object({ + status: z.literal("error"), + ...accountFields, + message: z.string(), + }), +]); +export const usageSnapshotSchema = z.object({ + resources: z.array( + z.object({ + id: z + .string() + .min(1) + .describe("Stable resource ID within the reporting plugin."), + providerId: z.string().min(1), + label: z.string().min(1), + scope: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("shared") }), + z.object({ + kind: z.literal("host"), + hostId: z.string().min(1), + hostName: z.string().min(1), + }), + ]), + observedAt: z + .number() + .int() + .nonnegative() + .nullable() + .describe( + "Measurement time in epoch milliseconds; null if never observed.", + ), + usage: usageSchema, + }), + ), +}); +export type UsageSnapshot = z.infer; +export const usageInputSchema = z.object({ + refresh: z + .boolean() + .describe( + "Request fresh collection and wait for the attempt; false permits cached observations.", + ), +}); diff --git a/plugins/provider-claude-code/src/usage-source.test.ts b/plugins/provider-claude-code/src/usage-source.test.ts new file mode 100644 index 0000000000..4c4cf57f25 --- /dev/null +++ b/plugins/provider-claude-code/src/usage-source.test.ts @@ -0,0 +1,57 @@ +import { expect, it, vi } from "vitest"; +import { + createFakePluginHost, + makeHostResponse, +} from "@get-bb/plugin-sdk/testing"; +import { registerUsageSource } from "./usage-source.js"; +import { usageSnapshotSchema } from "./usage-contract.js"; + +it("publishes usage independently of displays and isolates disconnected hosts", async () => { + const collect = vi.fn(async () => ({ + "claude-code": { + status: "ok" as const, + accountEmail: "user@example.com", + planLabel: "Team", + windows: [{ label: "Weekly", usedPercent: 42, resetsAt: null }], + }, + })); + const { bb, harness } = createFakePluginHost({ + sdk: { + hosts: { + list: async () => [ + makeHostResponse({ id: "online", status: "connected" }), + makeHostResponse({ id: "offline", status: "disconnected" }), + ], + }, + system: { usageLimits: collect }, + }, + }); + try { + registerUsageSource(bb); + expect( + harness.registrations.experimental_publishedRpcMethods.map( + (item) => item.method, + ), + ).toEqual(["provider-usage.v1.get"]); + const read = async (refresh: boolean) => + usageSnapshotSchema.parse( + await harness.behavior.callRpc("provider-usage.v1.get", { refresh }), + ); + const result = await read(false); + expect(result.resources[0]).toMatchObject({ + providerId: "claude-code", + scope: { kind: "host", hostId: "online" }, + usage: { status: "ok", windows: [{ usedPercent: 42 }] }, + }); + expect(result.resources[1]).toMatchObject({ + observedAt: null, + usage: { status: "error" }, + }); + await read(false); + expect(collect).toHaveBeenCalledTimes(1); + await read(true); + expect(collect).toHaveBeenCalledTimes(2); + } finally { + await harness.lifecycle.dispose(); + } +}); diff --git a/plugins/provider-claude-code/src/usage-source.ts b/plugins/provider-claude-code/src/usage-source.ts new file mode 100644 index 0000000000..e1003e2ca2 --- /dev/null +++ b/plugins/provider-claude-code/src/usage-source.ts @@ -0,0 +1,134 @@ +import { defineRpcContract, type BbPluginApi } from "@get-bb/plugin-sdk"; +import { + usageInputSchema, + usageSnapshotSchema, + type UsageSnapshot, +} from "./usage-contract.js"; + +const contract = defineRpcContract({ + "provider-usage.v1.get": { + experimental_description: + "Returns local Claude Code account usage for each host. refresh=true requests fresh collection. Host failures are returned individually; observedAt remains the time of the last successful measurement.", + input: usageInputSchema, + output: usageSnapshotSchema, + }, +}); + +export function registerUsageSource(bb: BbPluginApi) { + const cache = new Map(); + const pending = new Map< + string, + Promise + >(); + bb.rpc.register( + contract, + { + async "provider-usage.v1.get"({ refresh }) { + const hosts = await bb.sdk.hosts.list(); + for (const id of cache.keys()) + if (!hosts.some((host) => host.id === id)) cache.delete(id); + const resources: UsageSnapshot["resources"] = []; + for (let offset = 0; offset < hosts.length; offset += 3) { + resources.push( + ...(await Promise.all( + hosts.slice(offset, offset + 3).map(async (host) => { + const previous = cache.get(host.id); + const base = { + id: host.id, + providerId: "claude-code", + label: "Claude Code", + scope: { + kind: "host" as const, + hostId: host.id, + hostName: host.name, + }, + observedAt: previous?.observedAt ?? null, + }; + if (host.status === "disconnected") { + return { + ...base, + usage: { + status: "error" as const, + accountEmail: null, + planLabel: null, + message: "Machine is disconnected.", + }, + }; + } + if ( + !refresh && + previous?.usage.status === "ok" && + previous.observedAt !== null && + Date.now() - previous.observedAt < 60_000 + ) { + return { ...previous, scope: base.scope }; + } + const running = pending.get(host.id); + if (running !== undefined) return running; + const load = (async (): Promise< + UsageSnapshot["resources"][number] + > => { + try { + const result = await bb.sdk.system.usageLimits({ + hostId: host.id, + providerId: "claude-code", + }); + const usage = result["claude-code"]; + if (usage === undefined) + throw new Error( + "Provider returned no usage information.", + ); + const resource = { + ...base, + observedAt: + usage.status === "ok" ? Date.now() : base.observedAt, + usage: + usage.status === "ok" + ? { + ...usage, + windows: usage.windows.map((window, index) => ({ + ...window, + id: `${index}:${window.label}`, + model: null, + cost: window.cost ?? null, + })), + } + : usage.status === "error" + ? usage + : { + status: usage.status, + accountEmail: null, + planLabel: null, + }, + }; + cache.set(host.id, resource); + return resource; + } catch { + return { + ...base, + usage: { + status: "error", + accountEmail: null, + planLabel: null, + message: + "Usage could not be collected from this machine.", + }, + }; + } + })().finally(() => pending.delete(host.id)); + pending.set(host.id, load); + return load; + }), + )), + ); + } + return { resources }; + }, + }, + { + experimental_discoverable: true, + experimental_description: + "Claude Code usage from host-local credentials. Independent of pooled accounts and display plugins.", + }, + ); +} diff --git a/plugins/provider-codex/server.ts b/plugins/provider-codex/server.ts index bb0bfb4e2a..50bddc585a 100644 --- a/plugins/provider-codex/server.ts +++ b/plugins/provider-codex/server.ts @@ -1,8 +1,10 @@ +import { registerUsageSource } from "./src/usage-source.js"; import type { BbPluginApi } from "@get-bb/plugin-sdk"; import { codexExtensionKinds } from "./src/extension-kinds.js"; import { CODEX_NATIVE_ROOTS_DECLARATION } from "./src/native-roots.js"; export default function plugin(bb: BbPluginApi) { + registerUsageSource(bb); bb.experimental_aiServices.register({ id: "codex", displayName: "Codex (ChatGPT account or API key)", diff --git a/plugins/provider-codex/skills/codex-provider/SKILL.md b/plugins/provider-codex/skills/codex-provider/SKILL.md index c3fdc2cc45..8c30907c9d 100644 --- a/plugins/provider-codex/skills/codex-provider/SKILL.md +++ b/plugins/provider-codex/skills/codex-provider/SKILL.md @@ -16,3 +16,7 @@ account access. Inspect models on the actual execution host with Use the core CLI skill for command syntax and official Codex guidance for upstream product behavior. + +## Discoverable usage + +This plugin exposes `provider-usage.v1.get` for independent usage displays. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect provider-codex --method provider-usage.v1.get --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. diff --git a/plugins/provider-codex/src/usage-contract.ts b/plugins/provider-codex/src/usage-contract.ts new file mode 100644 index 0000000000..9adf55ce87 --- /dev/null +++ b/plugins/provider-codex/src/usage-contract.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; + +const accountFields = { + accountEmail: z.string().nullable(), + planLabel: z.string().nullable(), +}; +const usageWindowSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1), + usedPercent: z + .number() + .nonnegative() + .describe("Percentage consumed; may exceed 100 for overage."), + resetsAt: z + .string() + .nullable() + .describe("ISO timestamp, or null when no reset is known."), + model: z + .string() + .nullable() + .describe("Applicable model family, or null for all models."), + cost: z + .object({ + usedUsdCents: z.number().nonnegative(), + limitUsdCents: z.number().positive(), + }) + .nullable(), +}); +const usageSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("ok"), + ...accountFields, + windows: z.array(usageWindowSchema), + }), + z.object({ status: z.literal("not_installed"), ...accountFields }), + z.object({ status: z.literal("unauthenticated"), ...accountFields }), + z.object({ status: z.literal("expired"), ...accountFields }), + z.object({ + status: z.literal("error"), + ...accountFields, + message: z.string(), + }), +]); +export const usageSnapshotSchema = z.object({ + resources: z.array( + z.object({ + id: z + .string() + .min(1) + .describe("Stable resource ID within the reporting plugin."), + providerId: z.string().min(1), + label: z.string().min(1), + scope: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("shared") }), + z.object({ + kind: z.literal("host"), + hostId: z.string().min(1), + hostName: z.string().min(1), + }), + ]), + observedAt: z + .number() + .int() + .nonnegative() + .nullable() + .describe( + "Measurement time in epoch milliseconds; null if never observed.", + ), + usage: usageSchema, + }), + ), +}); +export type UsageSnapshot = z.infer; +export const usageInputSchema = z.object({ + refresh: z + .boolean() + .describe( + "Request fresh collection and wait for the attempt; false permits cached observations.", + ), +}); diff --git a/plugins/provider-codex/src/usage-source.test.ts b/plugins/provider-codex/src/usage-source.test.ts new file mode 100644 index 0000000000..3b50cad6a3 --- /dev/null +++ b/plugins/provider-codex/src/usage-source.test.ts @@ -0,0 +1,57 @@ +import { expect, it, vi } from "vitest"; +import { + createFakePluginHost, + makeHostResponse, +} from "@get-bb/plugin-sdk/testing"; +import { registerUsageSource } from "./usage-source.js"; +import { usageSnapshotSchema } from "./usage-contract.js"; + +it("publishes usage independently of displays and isolates disconnected hosts", async () => { + const collect = vi.fn(async () => ({ + codex: { + status: "ok" as const, + accountEmail: "user@example.com", + planLabel: "Team", + windows: [{ label: "Weekly", usedPercent: 42, resetsAt: null }], + }, + })); + const { bb, harness } = createFakePluginHost({ + sdk: { + hosts: { + list: async () => [ + makeHostResponse({ id: "online", status: "connected" }), + makeHostResponse({ id: "offline", status: "disconnected" }), + ], + }, + system: { usageLimits: collect }, + }, + }); + try { + registerUsageSource(bb); + expect( + harness.registrations.experimental_publishedRpcMethods.map( + (item) => item.method, + ), + ).toEqual(["provider-usage.v1.get"]); + const read = async (refresh: boolean) => + usageSnapshotSchema.parse( + await harness.behavior.callRpc("provider-usage.v1.get", { refresh }), + ); + const result = await read(false); + expect(result.resources[0]).toMatchObject({ + providerId: "codex", + scope: { kind: "host", hostId: "online" }, + usage: { status: "ok", windows: [{ usedPercent: 42 }] }, + }); + expect(result.resources[1]).toMatchObject({ + observedAt: null, + usage: { status: "error" }, + }); + await read(false); + expect(collect).toHaveBeenCalledTimes(1); + await read(true); + expect(collect).toHaveBeenCalledTimes(2); + } finally { + await harness.lifecycle.dispose(); + } +}); diff --git a/plugins/provider-codex/src/usage-source.ts b/plugins/provider-codex/src/usage-source.ts new file mode 100644 index 0000000000..09e1c6aba9 --- /dev/null +++ b/plugins/provider-codex/src/usage-source.ts @@ -0,0 +1,134 @@ +import { defineRpcContract, type BbPluginApi } from "@get-bb/plugin-sdk"; +import { + usageInputSchema, + usageSnapshotSchema, + type UsageSnapshot, +} from "./usage-contract.js"; + +const contract = defineRpcContract({ + "provider-usage.v1.get": { + experimental_description: + "Returns local Codex account usage for each host. refresh=true requests fresh collection. Host failures are returned individually; observedAt remains the time of the last successful measurement.", + input: usageInputSchema, + output: usageSnapshotSchema, + }, +}); + +export function registerUsageSource(bb: BbPluginApi) { + const cache = new Map(); + const pending = new Map< + string, + Promise + >(); + bb.rpc.register( + contract, + { + async "provider-usage.v1.get"({ refresh }) { + const hosts = await bb.sdk.hosts.list(); + for (const id of cache.keys()) + if (!hosts.some((host) => host.id === id)) cache.delete(id); + const resources: UsageSnapshot["resources"] = []; + for (let offset = 0; offset < hosts.length; offset += 3) { + resources.push( + ...(await Promise.all( + hosts.slice(offset, offset + 3).map(async (host) => { + const previous = cache.get(host.id); + const base = { + id: host.id, + providerId: "codex", + label: "Codex", + scope: { + kind: "host" as const, + hostId: host.id, + hostName: host.name, + }, + observedAt: previous?.observedAt ?? null, + }; + if (host.status === "disconnected") { + return { + ...base, + usage: { + status: "error" as const, + accountEmail: null, + planLabel: null, + message: "Machine is disconnected.", + }, + }; + } + if ( + !refresh && + previous?.usage.status === "ok" && + previous.observedAt !== null && + Date.now() - previous.observedAt < 60_000 + ) { + return { ...previous, scope: base.scope }; + } + const running = pending.get(host.id); + if (running !== undefined) return running; + const load = (async (): Promise< + UsageSnapshot["resources"][number] + > => { + try { + const result = await bb.sdk.system.usageLimits({ + hostId: host.id, + providerId: "codex", + }); + const usage = result["codex"]; + if (usage === undefined) + throw new Error( + "Provider returned no usage information.", + ); + const resource = { + ...base, + observedAt: + usage.status === "ok" ? Date.now() : base.observedAt, + usage: + usage.status === "ok" + ? { + ...usage, + windows: usage.windows.map((window, index) => ({ + ...window, + id: `${index}:${window.label}`, + model: null, + cost: window.cost ?? null, + })), + } + : usage.status === "error" + ? usage + : { + status: usage.status, + accountEmail: null, + planLabel: null, + }, + }; + cache.set(host.id, resource); + return resource; + } catch { + return { + ...base, + usage: { + status: "error", + accountEmail: null, + planLabel: null, + message: + "Usage could not be collected from this machine.", + }, + }; + } + })().finally(() => pending.delete(host.id)); + pending.set(host.id, load); + return load; + }), + )), + ); + } + return { resources }; + }, + }, + { + experimental_discoverable: true, + experimental_description: + "Codex usage from host-local credentials. Independent of pooled accounts and display plugins.", + }, + ); +} From cc60ebe912ca059f1a00ee87a4430cf4266ab054 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 10 Sep 2026 16:39:41 -0700 Subject: [PATCH 02/53] Preserve existing usage settings presentation with RPC sources --- .../settings/UsageLimitsSettingsSection.tsx | 216 +++++++++--------- .../UsageSourcesSettingsSection.test.tsx | 27 ++- ...iscoverable-rpc-and-provider-usage-plan.md | 4 +- 3 files changed, 131 insertions(+), 116 deletions(-) diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx index e2220228eb..cc880e7a14 100644 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx +++ b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx @@ -1,4 +1,6 @@ -import { useId } from "react"; +import { useUsageSources } from "@/hooks/queries/usage-source-queries"; +import type { UsageSnapshot } from "@/lib/usage-source-contract"; +import { useId, useState } from "react"; import type { Host, ProviderInfo } from "@bb/domain"; import type { ProviderUsage, @@ -21,10 +23,11 @@ import { } from "@bb/shared-ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; import { + useSystemConfig, useSystemProviders, type ProviderUsageQueryState, } from "@/hooks/queries/system-queries"; -import { useUsageSources } from "@/hooks/queries/usage-source-queries"; +import { selectPrimaryHost, useHosts } from "@/hooks/queries/host-queries"; import { getProviderIconInfo } from "@/lib/provider-icon"; import { ProviderIconMark } from "./ProviderIconMark"; import { cn } from "@bb/shared-ui/lib/utils"; @@ -84,7 +87,7 @@ function UsageWindowRow({ window }: { window: ProviderUsageWindow }) { "h-full rounded-full", usageBarColorClass(window.usedPercent), )} - style={{ width: `${Math.min(100, Math.max(window.usedPercent, 2))}%` }} + style={{ width: `${Math.max(window.usedPercent, 2)}%` }} /> {reset ?

{reset}

: null} @@ -100,6 +103,10 @@ interface ProviderUsageBlockProps { } export interface UsageLimitsSettingsSectionContentProps { + resources?: Array<{ + key: string; + resource: UsageSnapshot["resources"][number]; + }>; usage: ProviderUsageResponse; isLoading: boolean; isError: boolean; @@ -300,6 +307,7 @@ function ProviderUsageBody({ export function UsageLimitsSettingsSectionContent({ usage, + resources, isLoading, isError, isProviderListLoading = false, @@ -374,7 +382,37 @@ export function UsageLimitsSettingsSectionContent({ } > - {providerConfigs.length === 0 ? ( + {resources !== undefined && resources.length > 0 ? ( + resources.map(({ key, resource }) => { + const config = providerConfig( + resource.providerId, + providerById.get(resource.providerId), + ); + return ( + + cost === null ? window : { ...window, cost }, + ), + } + : resource.usage + } + isLoading={false} + isError={false} + /> + ); + }) + ) : providerConfigs.length === 0 ? (

{emptyMessage}

) : ( providerConfigs.map((config) => ( @@ -395,110 +433,76 @@ export function UsageLimitsSettingsSectionContent({ } export function UsageLimitsSettingsSection() { - const usage = useUsageSources(); - const providersQuery = useSystemProviders({}); - const providers = new Map( - (providersQuery.data ?? []).map((provider) => [provider.id, provider]), + const systemConfigQuery = useSystemConfig(); + const hostsQuery = useHosts(); + const hosts = hostsQuery.data ?? []; + const [selectedHostId, setSelectedHostId] = useState(null); + const primaryHost = selectPrimaryHost( + hosts, + systemConfigQuery.data?.primaryHostId ?? null, + ); + const selectedHost = + hosts.find((host) => host.id === selectedHostId) ?? primaryHost; + const usageHostId = + selectedHost?.id ?? systemConfigQuery.data?.primaryHostId ?? undefined; + const providersQuery = useSystemProviders( + usageHostId === undefined + ? { + capability: "usage", + enabled: systemConfigQuery.data !== undefined, + } + : { + capability: "usage", + enabled: systemConfigQuery.data !== undefined, + hostId: usageHostId, + }, ); + const providers = providersQuery.data ?? []; + const usageQuery = useUsageSources(); + const resources = usageQuery.sources + .flatMap(({ source, query }) => + (query.data?.resources ?? []) + .filter( + (resource) => + resource.usage.status !== "not_installed" && + (resource.scope.kind === "shared" || + resource.scope.hostId === usageHostId), + ) + .map((resource) => ({ + key: `${source.pluginId}:${resource.id}`, + resource, + })), + ) + .sort((a, b) => { + const rank = (id: string) => { + const index = providers.findIndex((provider) => provider.id === id); + return index < 0 ? providers.length : index; + }; + return rank(a.resource.providerId) - rank(b.resource.providerId); + }); + return ( - { - void usage.refresh(); - }} - aria-label="Reload usage data" - > - - + query.isPending) } - > - {usage.discovery.isError ? ( -

- Usage sources could not be loaded. -

- ) : null} - {usage.discovery.isPending ? ( -

Loading usage sources…

- ) : null} - {usage.discovery.isSuccess && usage.sources.length === 0 ? ( -

- No usage sources available. -

- ) : null} -
- {usage.sources.map(({ source, query }) => ( -
-

{source.displayName}

- {query.isPending ? ( -

Loading usage…

- ) : null} - {query.isError ? ( -

- This source could not be refreshed. Any values below are from - the previous observation. -

- ) : null} - {query.data?.resources.length === 0 ? ( -

- No accounts reported. -

- ) : null} - - {query.data?.resources.map((resource) => { - const config = providerConfig( - resource.providerId, - providers.get(resource.providerId), - ); - const observation = - resource.observedAt === null - ? "Not yet observed" - : `Observed ${new Date(resource.observedAt).toLocaleString()}`; - return ( -
-

- {resource.scope.kind === "shared" - ? "Shared across machines" - : resource.scope.hostName} - {" · "} - {observation} -

- - cost === null ? window : { ...window, cost }, - ), - } - : resource.usage - } - isLoading={false} - isError={false} - /> -
- ); - })} -
-
- ))} -
-
+ isError={ + usageQuery.discovery.isError || + usageQuery.sources.some(({ query }) => query.isError) + } + isProviderListLoading={providersQuery.isLoading} + isProviderListError={providersQuery.isError} + isFetching={usageQuery.isFetching} + onRefresh={() => { + void usageQuery.refresh(); + }} + providers={providers} + hosts={hosts} + selectedHostId={selectedHost?.id ?? null} + onSelectHost={setSelectedHostId} + /> ); } diff --git a/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx b/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx index 61ca11bf92..44bbaf665f 100644 --- a/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx +++ b/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx @@ -8,6 +8,8 @@ import { waitFor, } from "@testing-library/react"; import { afterEach, expect, it, vi } from "vitest"; +import { TooltipProvider } from "@bb/shared-ui/tooltip"; +import { makeHost } from "@bb/test-helpers/domain-fixtures"; import { UsageLimitsSettingsSection } from "./UsageLimitsSettingsSection"; const calls = vi.hoisted(() => ({ discover: vi.fn(), rpc: vi.fn() })); @@ -17,7 +19,15 @@ vi.mock("@/lib/sdk", () => ({ }, })); vi.mock("@/hooks/queries/system-queries", () => ({ - useSystemProviders: () => ({ data: [] }), + useSystemProviders: () => ({ data: [], isSuccess: true }), + useSystemConfig: () => ({ data: { primaryHostId: "host-a" } }), +})); + +vi.mock("@/hooks/queries/host-queries", () => ({ + useHosts: () => ({ + data: [makeHost({ id: "host-a", name: "Build machine" })], + }), + selectPrimaryHost: (hosts: unknown[]) => hosts[0], })); afterEach(() => { @@ -25,7 +35,7 @@ afterEach(() => { vi.clearAllMocks(); }); -it("renders independent sources with shared and host scope and refreshes through the copied contract", async () => { +it("keeps the existing presentation for discovered shared and host usage and refreshes through the copied contract", async () => { calls.discover.mockResolvedValue([ { pluginId: "pool", displayName: "Account Pooler" }, { pluginId: "local", displayName: "Codex provider" }, @@ -69,16 +79,17 @@ it("renders independent sources with shared and host scope and refreshes through try { render( - + + + , ); expect(await screen.findByText("42% used")).toBeTruthy(); expect(await screen.findByText("81% used")).toBeTruthy(); - expect(screen.getByText(/Shared across machines/)).toBeTruthy(); - expect(screen.getByText(/Build machine/)).toBeTruthy(); - expect( - await screen.findByText(/This source could not be refreshed/), - ).toBeTruthy(); + expect(screen.queryByText(/Shared across machines/)).toBeNull(); + expect(screen.queryByText("Account Pooler")).toBeNull(); + expect(screen.queryByText(/Observed/)).toBeNull(); + expect(screen.getByText("Your provider subscription usage.")).toBeTruthy(); await waitFor(() => expect( screen.getByLabelText("Reload usage data").hasAttribute("disabled"), diff --git a/docs/discoverable-rpc-and-provider-usage-plan.md b/docs/discoverable-rpc-and-provider-usage-plan.md index 2dfeced6f7..54f0aaaa39 100644 --- a/docs/discoverable-rpc-and-provider-usage-plan.md +++ b/docs/discoverable-rpc-and-provider-usage-plan.md @@ -1,6 +1,6 @@ # Discoverable RPC and replaceable provider usage displays -Status: prototype implemented for discoverable RPC, Account Pooler, Codex, Claude Code, and the core `/settings/usage` page. The broader Provider Usage plugin migration remains planned. +Status: prototype implemented for discoverable RPC, Account Pooler, Codex, Claude Code, and the core `/settings/usage` page. The broader Provider Usage plugin migration remains planned. The core settings page preserves its existing presentation and machine picker; only its data source changes. ## Prototype verification @@ -150,7 +150,7 @@ Use the same milliseconds-based timestamp convention throughout. An observation ### Display implementation -Provider Usage discovers sources whenever it loads or refreshes data, invokes them with bounded concurrency and bounded wait, and renders successful results even if another source fails. It groups observations by provider and reporting source, with explicit host/shared labels and freshness. +Provider Usage discovers sources whenever it loads or refreshes data, invokes them with bounded concurrency and bounded wait, and renders successful results even if another source fails. The core settings prototype preserves the existing provider-card presentation, refresh control, and machine picker. Host-local observations follow the selected machine; shared accounts use the same cards. Source-group headings and observation timestamps are not added to this page. Other display plugins can choose their own presentation using the same metadata. Namespace resource keys by reporting plugin ID. Do not deduplicate by email or sum unrelated quota percentages. A shared pool appears once; a local account and a pool account may both appear even when their labels match. Source removal evicts its current display entries on the next reconciliation. From 378b9d8578e3cab7fde2add0b45613ab412a66e9 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 10 Sep 2026 16:58:33 -0700 Subject: [PATCH 03/53] Have Provider Usage define and consume the usage source contract --- ...iscoverable-rpc-and-provider-usage-plan.md | 4 +- plugins/provider-usage/server.test.ts | 184 ++++++++++--- plugins/provider-usage/server.ts | 241 +++++++++++++----- plugins/provider-usage/usage-schema.ts | 4 +- .../provider-usage/usage-source-contract.ts | 91 +++++++ 5 files changed, 423 insertions(+), 101 deletions(-) create mode 100644 plugins/provider-usage/usage-source-contract.ts diff --git a/docs/discoverable-rpc-and-provider-usage-plan.md b/docs/discoverable-rpc-and-provider-usage-plan.md index 54f0aaaa39..eebdee63ff 100644 --- a/docs/discoverable-rpc-and-provider-usage-plan.md +++ b/docs/discoverable-rpc-and-provider-usage-plan.md @@ -1,6 +1,6 @@ # Discoverable RPC and replaceable provider usage displays -Status: prototype implemented for discoverable RPC, Account Pooler, Codex, Claude Code, and the core `/settings/usage` page. The broader Provider Usage plugin migration remains planned. The core settings page preserves its existing presentation and machine picker; only its data source changes. +Status: prototype implemented for discoverable RPC, Account Pooler, Codex, Claude Code, and the core `/settings/usage` page. Provider Usage defines the canonical contract in `plugins/provider-usage/usage-source-contract.ts` and consumes discovered sources through it. The core settings page preserves its existing presentation and machine picker; only its data source changes. ## Prototype verification @@ -8,7 +8,7 @@ Status: prototype implemented for discoverable RPC, Account Pooler, Codex, Claud - Focused tests passed: 3 RPC publication tests, 10 server SDK tests, 1 SDK discovery/call test, 2 provider-source tests, 141 Account Pooler server tests, and 13 settings usage tests. - The isolated dev server advertises all three implementations with registration/method descriptions and JSON Schemas. CLI inspection and invocation passed. - Desktop and 390-pixel mobile browser checks passed, including refresh and no horizontal overflow. The fresh dev store has no pooled accounts; live host usage and authentication-error states were exercised. -- The prototype trusts the Standard JSON Schema exporter for semantic fidelity; exhaustive refinement/transform fidelity auditing remains a stabilization task. The existing Provider Usage sidebar plugin and `bb settings usage` remain on their previous collection paths. +- The prototype trusts the Standard JSON Schema exporter for semantic fidelity; exhaustive refinement/transform fidelity auditing remains a stabilization task. `bb settings usage` remains on its previous collection path. Provider Usage uses discovery through its existing `getUsage` display RPC; call it with `bb plugin rpc call provider-usage getUsage`. Shared sources appear once in the existing sidebar picker. - The repository verification inventory reports an existing unmapped `browser` CLI family; this prototype does not rewrite that unrelated baseline. diff --git a/plugins/provider-usage/server.test.ts b/plugins/provider-usage/server.test.ts index acb0fc048f..034ee43832 100644 --- a/plugins/provider-usage/server.test.ts +++ b/plugins/provider-usage/server.test.ts @@ -4,6 +4,12 @@ import { makeThreadResponse, } from "@get-bb/plugin-sdk/testing"; import plugin from "./server.js"; +import { + usageSnapshotSchema, + usageSourceMethod, +} from "./usage-source-contract.js"; + +const discovery = [{ pluginId: "local-source", method: usageSourceMethod }]; afterEach(() => { vi.useRealTimers(); @@ -59,22 +65,47 @@ describe("provider usage backend", () => { }, ], }, - system: { - usageLimits: async () => ({ - "claude-code": { - status: "ok", - accountEmail: "dev@example.com", - planLabel: "Max", - windows: [ + plugins: { + experimental_discoverRpc: async () => discovery, + callRpc: async () => + usageSnapshotSchema.parse({ + resources: [ + { + id: "claude-code", + providerId: "claude-code", + label: "Claude Code", + scope: { kind: "host", hostId: "host-m4", hostName: "M4" }, + observedAt: 1, + usage: { + status: "ok", + accountEmail: "dev@example.com", + planLabel: "Max", + windows: [ + { + id: "five-hour", + label: "Five-hour limit", + usedPercent: 82, + resetsAt: "2026-09-02T18:42:00.000Z", + model: null, + cost: null, + }, + ], + }, + }, { - label: "Five-hour limit", - usedPercent: 82, - resetsAt: "2026-09-02T18:42:00.000Z", + id: "codex", + providerId: "codex", + label: "Codex", + scope: { kind: "host", hostId: "host-m4", hostName: "M4" }, + observedAt: null, + usage: { + status: "unauthenticated", + accountEmail: null, + planLabel: null, + }, }, ], - }, - codex: { status: "unauthenticated" }, - }), + }), }, }, }); @@ -95,7 +126,7 @@ describe("provider usage backend", () => { error: null, providers: [ { - id: "claude-code", + id: "local-source:claude-code", displayName: "Claude Code", logoUrl: "/api/v1/system/providers/claude-code/logo?h=claude", icon: null, @@ -117,7 +148,7 @@ describe("provider usage backend", () => { }, }, { - id: "codex", + id: "local-source:codex", displayName: "Codex", logoUrl: "/api/v1/system/providers/codex/logo?h=codex", icon: null, @@ -165,9 +196,12 @@ describe("provider usage backend", () => { [{ hostId: "host-m4", capability: "usage" }], [{ hostId: "host-intel", capability: "usage" }], ]); - expect(host.harness.sdk.callsTo("system.usageLimits")).toEqual([ - [{ hostId: "host-m4" }], - ]); + expect(host.harness.sdk.callsTo("plugins.callRpc")).toHaveLength(1); + expect(host.harness.sdk.callsTo("plugins.callRpc")[0]?.[0]).toMatchObject({ + pluginId: "local-source", + method: usageSourceMethod, + input: { refresh: false }, + }); await host.harness.behavior.callRpc("getUsage", { force: false, @@ -176,7 +210,7 @@ describe("provider usage backend", () => { }); expect(host.harness.sdk.callsTo("hosts.list")).toHaveLength(2); expect(host.harness.sdk.callsTo("providers.list")).toHaveLength(2); - expect(host.harness.sdk.callsTo("system.usageLimits")).toHaveLength(1); + expect(host.harness.sdk.callsTo("plugins.callRpc")).toHaveLength(1); await host.harness.behavior.callRpc("getUsage", { force: true, @@ -185,7 +219,7 @@ describe("provider usage backend", () => { }); expect(host.harness.sdk.callsTo("hosts.list")).toHaveLength(3); expect(host.harness.sdk.callsTo("providers.list")).toHaveLength(4); - expect(host.harness.sdk.callsTo("system.usageLimits")).toHaveLength(2); + expect(host.harness.sdk.callsTo("plugins.callRpc")).toHaveLength(2); await host.harness.behavior.callRpc("getUsage", { force: true, @@ -193,7 +227,7 @@ describe("provider usage backend", () => { maxAgeMs: 0, }); expect(host.harness.sdk.callsTo("providers.list")).toHaveLength(5); - expect(host.harness.sdk.callsTo("system.usageLimits")).toHaveLength(3); + expect(host.harness.sdk.callsTo("plugins.callRpc")).toHaveLength(3); expect(host.harness.sdk.callsTo("providers.list").at(-1)).toEqual([ { hostId: "host-m4", capability: "usage" }, ]); @@ -217,8 +251,9 @@ describe("provider usage backend", () => { providers: { list: async () => [], }, - system: { - usageLimits: async () => ({}), + plugins: { + experimental_discoverRpc: async () => discovery, + callRpc: async () => ({ resources: [] }), }, }, }); @@ -240,10 +275,7 @@ describe("provider usage backend", () => { expect(host.harness.sdk.callsTo("environments.get")).toEqual([ [{ environmentId: "environment-m5" }], ]); - expect(host.harness.sdk.callsTo("system.usageLimits")).toEqual([ - [{ hostId: "host-m4" }], - [{ hostId: "host-m5" }], - ]); + expect(host.harness.sdk.callsTo("plugins.callRpc")).toHaveLength(1); vi.setSystemTime(new Date("2026-09-04T12:02:00.000Z")); await host.harness.behavior.callRpc("getUsage", { @@ -252,11 +284,101 @@ describe("provider usage backend", () => { maxAgeMs: 30 * 60_000, }); - expect(host.harness.sdk.callsTo("system.usageLimits")).toEqual([ - [{ hostId: "host-m4" }], - [{ hostId: "host-m5" }], - [{ hostId: "host-m5" }], + expect(host.harness.sdk.callsTo("plugins.callRpc")).toHaveLength(2); + expect(host.harness.sdk.callsTo("providers.list")).toEqual([ + [{ hostId: "host-m4", capability: "usage" }], + [{ hostId: "host-m5", capability: "usage" }], + [{ hostId: "host-m5", capability: "usage" }], ]); await host.harness.lifecycle.dispose(); }); }); + +describe("usage source composition", () => { + it("keeps shared accounts once, isolates failures, and removes disabled sources", async () => { + let enabled = true; + const host = createFakePluginHost({ + pluginId: "provider-usage", + sdk: { + hosts: { list: async () => [] }, + plugins: { + experimental_discoverRpc: async () => + enabled + ? [ + { pluginId: "pool", method: usageSourceMethod }, + { pluginId: "broken", method: usageSourceMethod }, + ] + : [], + callRpc: async ({ pluginId }) => { + if (pluginId === "broken") throw new Error("Unavailable"); + return usageSnapshotSchema.parse({ + resources: [ + { + id: "account-1", + providerId: "codex", + label: "Team account", + scope: { kind: "shared" }, + observedAt: 123, + usage: { + status: "ok", + accountEmail: "team@example.com", + planLabel: null, + windows: [ + { + id: "budget", + label: "Budget", + usedPercent: 120, + resetsAt: null, + model: null, + cost: { usedUsdCents: 1.2, limitUsdCents: 1 }, + }, + ], + }, + }, + ], + }); + }, + }, + }, + }); + plugin(host.bb); + const request = { force: false, machineIds: null, maxAgeMs: 60_000 }; + const snapshot = await host.harness.behavior.callRpc("getUsage", request); + expect(snapshot).toMatchObject({ + machines: [ + { + id: "source:pool", + providers: [ + { + id: "pool:account-1", + displayName: "Team account", + usage: { + status: "ok", + windows: [{ usedPercent: 120, cost: { usedUsdCents: 1.2 } }], + }, + }, + ], + }, + { + id: "source:broken", + providers: [], + error: "Usage could not be loaded from broken.", + }, + ], + }); + await host.harness.behavior.callRpc("getUsage", { + ...request, + force: true, + machineIds: ["source:pool"], + }); + expect(host.harness.sdk.callsTo("plugins.callRpc")[2]?.[0]).toMatchObject({ + input: { refresh: true }, + }); + expect(host.harness.sdk.callsTo("system.usageLimits")).toEqual([]); + enabled = false; + await expect( + host.harness.behavior.callRpc("getUsage", request), + ).resolves.toEqual({ machines: [] }); + await host.harness.lifecycle.dispose(); + }); +}); diff --git a/plugins/provider-usage/server.ts b/plugins/provider-usage/server.ts index 32c95130a5..4512344c59 100644 --- a/plugins/provider-usage/server.ts +++ b/plugins/provider-usage/server.ts @@ -8,6 +8,19 @@ import { type UsageSnapshot, } from "./usage-schema.js"; +import { + usageSourceMethod, + usageSourceRpcContract, + type UsageSnapshot as SourceSnapshot, +} from "./usage-source-contract.js"; + +type Resource = SourceSnapshot["resources"][number]; +interface SourceResult { + pluginId: string; + resources: Resource[]; + error: string | null; +} + const TINT_COLOR_PATTERN = /^(#[0-9a-f]{3,8}|(rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\([-+.%\w\s,/]*\)|[a-z]{3,20})$/iu; @@ -55,21 +68,19 @@ function normalizedTint( } function normalizedUsage( - usage: Awaited< - ReturnType - >[string], + usage: Resource["usage"] | undefined, ): ProviderUsage | null { if (usage === undefined) return null; switch (usage.status) { case "ok": return { status: "ok", - accountEmail: usage.accountEmail, - planLabel: usage.planLabel, + accountEmail: usage.accountEmail || null, + planLabel: usage.planLabel || null, windows: usage.windows.map((window) => ({ label: window.label, usedPercent: window.usedPercent, - resetsAt: window.resetsAt, + resetsAt: window.resetsAt || null, cost: window.cost ?? null, })), }; @@ -80,7 +91,10 @@ function normalizedUsage( case "expired": return { status: "expired" }; case "error": - return { status: "error", message: usage.message }; + return { + status: "error", + message: usage.message || "Usage could not be collected.", + }; } } @@ -90,8 +104,11 @@ type Provider = Awaited< >[number]; function normalizedProvider( - provider: Provider, - usage: Awaited>, + provider: Pick< + Provider, + "id" | "displayName" | "logoUrl" | "icon" | "strings" + >, + usage: Resource["usage"] | undefined, ): UsageProvider { return { id: provider.id, @@ -107,84 +124,92 @@ function normalizedProvider( "Your " + provider.displayName + " session expired. Sign in again, then reload usage.", - usage: normalizedUsage(usage[provider.id]), + usage: normalizedUsage(usage), + }; +} + +function resourceProvider( + resource: Resource, + pluginId: string, + providers: Provider[], +): UsageProvider { + const metadata = providers.find( + (provider) => provider.id === resource.providerId, + ); + return { + ...normalizedProvider( + metadata ?? { + id: resource.providerId, + displayName: resource.label, + logoUrl: null, + }, + resource.usage, + ), + id: `${pluginId}:${resource.id}`, + displayName: resource.label, }; } async function loadMachineUsage( bb: BbPluginApi, host: Host, + readSources: () => Promise, ): Promise { - const providersPromise = bb.sdk.providers.list({ - hostId: host.id, - capability: "usage", - }); - if (host.status === "disconnected") { - try { - const providers = await providersPromise; - return { - id: host.id, - displayName: host.name, - status: host.status, - providers: providers.map((provider) => - normalizedProvider(provider, {}), - ), - error: null, - }; - } catch { - return { - id: host.id, - displayName: host.name, - status: host.status, - providers: [], - error: "Provider information could not be loaded for this machine.", - }; - } - } - const [providersResult, usageResult] = await Promise.allSettled([ - providersPromise, - bb.sdk.system.usageLimits({ hostId: host.id }), + const [metadata, sources] = await Promise.allSettled([ + bb.sdk.providers.list({ hostId: host.id, capability: "usage" }), + host.status === "disconnected" ? Promise.resolve([]) : readSources(), ]); - if (providersResult.status === "rejected") { - return { - id: host.id, - displayName: host.name, - status: host.status, - providers: [], - error: "Provider information could not be loaded for this machine.", - }; - } - if (usageResult.status === "rejected") { - return { - id: host.id, - displayName: host.name, - status: host.status, - providers: providersResult.value.map((provider) => - normalizedProvider(provider, {}), - ), - error: "Usage could not be loaded for this machine.", - }; - } + const providers = metadata.status === "fulfilled" ? metadata.value : []; + const results = sources.status === "fulfilled" ? sources.value : []; + const providerOrder = new Map( + providers.map((provider, index) => [provider.id, index]), + ); + const resources = results + .flatMap((source) => + source.resources + .filter( + (resource) => + resource.scope.kind === "host" && resource.scope.hostId === host.id, + ) + .map((resource) => ({ pluginId: source.pluginId, resource })), + ) + .sort( + (left, right) => + (providerOrder.get(left.resource.providerId) ?? providers.length) - + (providerOrder.get(right.resource.providerId) ?? providers.length), + ) + .map(({ pluginId, resource }) => + resourceProvider(resource, pluginId, providers), + ); return { id: host.id, displayName: host.name, status: host.status, - providers: providersResult.value.map((provider) => - normalizedProvider(provider, usageResult.value), - ), - error: null, + providers: + host.status === "disconnected" + ? providers.map((provider) => normalizedProvider(provider, undefined)) + : resources, + error: + sources.status === "rejected" + ? "Usage sources could not be discovered." + : null, }; } export default function providerUsagePlugin(bb: BbPluginApi): void { const cache = new Map(); const pendingByMachine = new Map(); + let sourceResults: SourceResult[] = []; + let sourceLoadedAt = 0; + let sourceSignature = ""; + let sharedDirty = false; const environmentHosts = new Map(); const readMachine = async ( host: Host, request: UsageRequest, targeted: boolean, + readSources: () => Promise, ): Promise => { const cached = cache.get(host.id); const effectiveMaxAgeMs = @@ -213,9 +238,9 @@ export default function providerUsagePlugin(bb: BbPluginApi): void { if (pending !== undefined) { if (!request.force || pending.force) return pending.promise; await pending.promise; - return readMachine(host, request, targeted); + return readMachine(host, request, targeted, readSources); } - const next = loadMachineUsage(bb, host) + const next = loadMachineUsage(bb, host, readSources) .then((machine) => { cache.set(host.id, { dirty: false, @@ -232,7 +257,60 @@ export default function providerUsagePlugin(bb: BbPluginApi): void { }; const readUsage = async (request: UsageRequest): Promise => { - const hosts = await bb.sdk.hosts.list(); + const [hosts, sources] = await Promise.all([ + bb.sdk.hosts.list(), + bb.sdk.plugins.experimental_discoverRpc({ method: usageSourceMethod }), + ]); + const signature = JSON.stringify(sources); + if (signature !== sourceSignature) { + cache.clear(); + sourceResults = []; + sourceLoadedAt = 0; + sourceSignature = signature; + } + let pendingSources: Promise | undefined; + const readSources = () => + (pendingSources ??= (async () => { + const results: SourceResult[] = []; + for (let offset = 0; offset < sources.length; offset += 3) { + results.push( + ...(await Promise.all( + sources + .slice(offset, offset + 3) + .map(async (source): Promise => { + try { + const snapshot = await bb.sdk.plugins.callRpc({ + pluginId: source.pluginId, + method: usageSourceMethod, + input: { refresh: request.force }, + outputSchema: + usageSourceRpcContract[usageSourceMethod].output, + signal: AbortSignal.timeout(45_000), + }); + return { + pluginId: source.pluginId, + resources: snapshot.resources, + error: null, + }; + } catch { + return { + pluginId: source.pluginId, + resources: [], + error: + "Usage could not be loaded from " + + source.pluginId + + ".", + }; + } + }), + )), + ); + } + sourceResults = results; + sourceLoadedAt = Date.now(); + sharedDirty = false; + return results; + })()); const hostIds = new Set(hosts.map((host) => host.id)); for (const machineId of cache.keys()) { if (!hostIds.has(machineId)) cache.delete(machineId); @@ -247,9 +325,24 @@ export default function providerUsagePlugin(bb: BbPluginApi): void { targetedIds === null || targetedIds.has(host.id) || !cache.has(host.id), + readSources, ), ), ); + const sharedTargeted = + request.machineIds === null || + request.machineIds.some((id) => id.startsWith("source:")); + if ( + sourceLoadedAt === 0 || + (sharedTargeted && + (request.force || + Date.now() - sourceLoadedAt >= + (sharedDirty + ? Math.min(request.maxAgeMs, DIRTY_CACHE_MAX_AGE_MS) + : request.maxAgeMs))) + ) { + await readSources(); + } const machines: UsageMachine[] = []; for (const host of hosts) { const entry = cache.get(host.id); @@ -258,10 +351,26 @@ export default function providerUsagePlugin(bb: BbPluginApi): void { } machines.push(entry.machine); } + for (const source of sourceResults) { + const shared = source.resources.filter( + (resource) => resource.scope.kind === "shared", + ); + if (shared.length === 0 && source.error === null) continue; + machines.push({ + id: `source:${source.pluginId}`, + displayName: `Shared · ${source.pluginId}`, + status: "connected", + providers: shared.map((resource) => + resourceProvider(resource, source.pluginId, []), + ), + error: source.error, + }); + } return { machines }; }; const markDirty = (machineId: string | null): void => { + sharedDirty = true; if (machineId === null) { for (const entry of cache.values()) entry.dirty = true; } else { diff --git a/plugins/provider-usage/usage-schema.ts b/plugins/provider-usage/usage-schema.ts index a13b4ad237..9dafdcdd37 100644 --- a/plugins/provider-usage/usage-schema.ts +++ b/plugins/provider-usage/usage-schema.ts @@ -2,8 +2,8 @@ import { z } from "zod/mini"; const nonemptyStringSchema = z.string().check(z.minLength(1)); const costSchema = z.strictObject({ - usedUsdCents: z.number().check(z.int(), z.nonnegative()), - limitUsdCents: z.number().check(z.int(), z.positive()), + usedUsdCents: z.number().check(z.nonnegative()), + limitUsdCents: z.number().check(z.positive()), }); export const usageWindowSchema = z.strictObject({ diff --git a/plugins/provider-usage/usage-source-contract.ts b/plugins/provider-usage/usage-source-contract.ts new file mode 100644 index 0000000000..3837ed5de8 --- /dev/null +++ b/plugins/provider-usage/usage-source-contract.ts @@ -0,0 +1,91 @@ +import { defineRpcContract } from "@get-bb/plugin-sdk"; +import { z } from "zod"; + +const accountFields = { + accountEmail: z.string().nullable(), + planLabel: z.string().nullable(), +}; +const usageWindowSchema = z.object({ + id: z.string().min(1), + label: z.string().min(1), + usedPercent: z + .number() + .nonnegative() + .describe("Percentage consumed; may exceed 100 for overage."), + resetsAt: z + .string() + .nullable() + .describe("ISO timestamp, or null when no reset is known."), + model: z + .string() + .nullable() + .describe("Applicable model family, or null for all models."), + cost: z + .object({ + usedUsdCents: z.number().nonnegative(), + limitUsdCents: z.number().positive(), + }) + .nullable(), +}); +const usageSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("ok"), + ...accountFields, + windows: z.array(usageWindowSchema), + }), + z.object({ status: z.literal("not_installed"), ...accountFields }), + z.object({ status: z.literal("unauthenticated"), ...accountFields }), + z.object({ status: z.literal("expired"), ...accountFields }), + z.object({ + status: z.literal("error"), + ...accountFields, + message: z.string(), + }), +]); +export const usageSnapshotSchema = z.object({ + resources: z.array( + z.object({ + id: z + .string() + .min(1) + .describe("Stable resource ID within the reporting plugin."), + providerId: z.string().min(1), + label: z.string().min(1), + scope: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("shared") }), + z.object({ + kind: z.literal("host"), + hostId: z.string().min(1), + hostName: z.string().min(1), + }), + ]), + observedAt: z + .number() + .int() + .nonnegative() + .nullable() + .describe( + "Measurement time in epoch milliseconds; null if never observed.", + ), + usage: usageSchema, + }), + ), +}); +export type UsageSnapshot = z.infer; +export const usageInputSchema = z.object({ + refresh: z + .boolean() + .describe( + "Request fresh collection and wait for the attempt; false permits cached observations.", + ), +}); + +export const usageSourceMethod = "provider-usage.v1.get"; +export const usageSourceRpcContract = defineRpcContract({ + [usageSourceMethod]: { + input: usageInputSchema, + output: usageSnapshotSchema, + experimental_description: + "Returns a complete snapshot of this source's usage resources. Resource IDs are stable within the source plugin. Host resources belong to one machine; shared resources appear once across machines. refresh=true waits for a fresh collection attempt. Return per-resource failures when possible; observedAt records the last successful measurement, never the fetch time. Consumers discover implementations by this method name and validate responses against their local contract copy.", + }, +}); From 2be60a052c12b58f66be3dcea945eaa733c7e736 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 10 Sep 2026 17:53:19 -0700 Subject: [PATCH 04/53] Version discoverable RPC SDK and allow CI time for sidebar integration test --- plugins/account-pool/package.json | 3 ++- plugins/provider-claude-code/package.json | 3 ++- plugins/provider-codex/package.json | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/plugins/account-pool/package.json b/plugins/account-pool/package.json index 7d83d2ef2f..0948040322 100644 --- a/plugins/account-pool/package.json +++ b/plugins/account-pool/package.json @@ -5,7 +5,8 @@ "type": "module", "description": "Routes Claude and Codex API traffic across provider account pools.", "engines": { - "bb": ">=0.0" + "bb": ">=0.0", + "bbPluginSdk": ">=0.4.56" }, "bb": { "name": "Account Pooler [Experimental]", diff --git a/plugins/provider-claude-code/package.json b/plugins/provider-claude-code/package.json index ddb3cd6e7f..6a5ddf0459 100644 --- a/plugins/provider-claude-code/package.json +++ b/plugins/provider-claude-code/package.json @@ -5,7 +5,8 @@ "type": "module", "description": "Run bb threads with Claude Code.", "engines": { - "bb": ">=0.0" + "bb": ">=0.0", + "bbPluginSdk": ">=0.4.56" }, "bb": { "name": "Claude Code provider", diff --git a/plugins/provider-codex/package.json b/plugins/provider-codex/package.json index 14dc70e559..8cc0d6a372 100644 --- a/plugins/provider-codex/package.json +++ b/plugins/provider-codex/package.json @@ -5,7 +5,8 @@ "type": "module", "description": "Run bb threads with Codex.", "engines": { - "bb": ">=0.0" + "bb": ">=0.0", + "bbPluginSdk": ">=0.4.56" }, "bb": { "name": "Codex provider", From 9b6b616ed11bc1c18b1fda2c11e69693a55463c0 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 10 Sep 2026 17:58:12 -0700 Subject: [PATCH 05/53] Include plugin RPC commands in the CLI skill index --- plugins/bb-guide/skills/bb-cli/references/command-index.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/bb-guide/skills/bb-cli/references/command-index.md b/plugins/bb-guide/skills/bb-cli/references/command-index.md index 8078632421..71c1ea8e7a 100644 --- a/plugins/bb-guide/skills/bb-cli/references/command-index.md +++ b/plugins/bb-guide/skills/bb-cli/references/command-index.md @@ -237,6 +237,10 @@ configures the machine with optional configured `preset` and `image` names; - `bb plugin build` - `bb plugin dev` - `bb plugin reload` +- `bb plugin rpc` +- `bb plugin rpc list` +- `bb plugin rpc inspect` +- `bb plugin rpc call` - `bb plugin enable` - `bb plugin disable` - `bb plugin config` From 5f3eb7ea49d0f85cbd036dfc2259e0fb2323e289 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 10 Sep 2026 21:43:25 -0700 Subject: [PATCH 06/53] Group pooled usage accounts under branded provider tabs --- plugins/provider-usage/app.test.tsx | 243 ++++++++++++-------- plugins/provider-usage/app.tsx | 112 +++++---- plugins/provider-usage/server.test.ts | 22 +- plugins/provider-usage/server.ts | 17 +- plugins/provider-usage/usage-schema.test.ts | 2 + plugins/provider-usage/usage-schema.ts | 2 + 6 files changed, 251 insertions(+), 147 deletions(-) diff --git a/plugins/provider-usage/app.test.tsx b/plugins/provider-usage/app.test.tsx index 85b6d95ced..1a12a8d846 100644 --- a/plugins/provider-usage/app.test.tsx +++ b/plugins/provider-usage/app.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { cleanup, fireEvent, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { UsageProvider } from "./usage-schema.js"; import type { PluginSidebarThread } from "@get-bb/plugin-sdk/app"; import { loadPluginApp, @@ -50,13 +51,39 @@ function threadOnMachine( }; } -const app = await loadPluginApp(() => import("./app")); -const item = app.experimentalSidebarFooterItems[0]; -if (item?.kind !== "disclosure") throw new Error("missing disclosure"); - describe("provider usage footer disclosure", () => { - function createFetchMock() { - return vi.fn( + it("aggregates every machine and keeps machine and provider selection local to the card", async () => { + const pooledAccounts: UsageProvider[] = ( + [ + ["codex", "Codex", "team@example.com", 46], + ["codex", "Codex", "personal@example.com", 82], + ["claude-code", "Claude Code", "claude-team@example.com", 97], + ] as const + ).map(([providerId, displayName, email, usedPercent]) => ({ + id: email, + providerId: providerId, + accountLabel: email, + displayName: displayName, + logoUrl: `/api/v1/system/providers/${providerId}/logo`, + iconGlyph: null, + iconTint: null, + signInHint: "Sign in.", + expiredHint: "Sign in again.", + usage: { + status: "ok", + accountEmail: email, + planLabel: "Pro", + windows: [ + { + label: "Weekly limit", + usedPercent: usedPercent, + resetsAt: null, + cost: null, + }, + ], + }, + })); + const fetchMock = vi.fn( async (_input: RequestInfo | URL, _init?: RequestInit) => new Response( JSON.stringify({ @@ -71,13 +98,13 @@ describe("provider usage footer disclosure", () => { providers: [ { id: "claude-code", + providerId: "claude-code", + accountLabel: null, displayName: "Claude Code", logoUrl: "/api/v1/system/providers/claude-code/logo?h=claude", - icon: null, - strings: { - iconTint: { light: "#D97757", dark: "#E38A6E" }, - }, + iconGlyph: null, + iconTint: { light: "#D97757", dark: "#E38A6E" }, signInHint: "Sign in to Claude Code.", expiredHint: "Sign in to Claude Code again.", usage: { @@ -96,10 +123,12 @@ describe("provider usage footer disclosure", () => { }, { id: "codex", + providerId: "codex", + accountLabel: null, displayName: "Codex", logoUrl: "/api/v1/system/providers/codex/logo?h=codex", - icon: null, - strings: { iconTint: null }, + iconGlyph: null, + iconTint: null, signInHint: "Sign in to Codex.", expiredHint: "Sign in to Codex again.", usage: { @@ -126,10 +155,12 @@ describe("provider usage footer disclosure", () => { providers: [ { id: "codex", + providerId: "codex", + accountLabel: null, displayName: "Codex", logoUrl: "/api/v1/system/providers/codex/logo?h=codex", - icon: null, - strings: { iconTint: null }, + iconGlyph: null, + iconTint: null, signInHint: "Sign in to Codex.", expiredHint: "Sign in to Codex again.", usage: { @@ -148,6 +179,13 @@ describe("provider usage footer disclosure", () => { }, ], }, + { + id: "source:account-pool", + displayName: "Account Pooler", + status: "connected", + error: null, + providers: pooledAccounts, + }, { id: "host-intel", displayName: "Intel", @@ -161,63 +199,33 @@ describe("provider usage footer disclosure", () => { { status: 200, headers: { "content-type": "application/json" } }, ), ); - } - - it("registers the footer disclosure", () => { + vi.stubGlobal("fetch", fetchMock); + const app = await loadPluginApp(() => import("./app")); + const mounted = await mountPluginContentScripts(app, { + pluginId: "provider-usage", + }); + const item = app.experimentalSidebarFooterItems[0]; expect(item).toMatchObject({ kind: "disclosure", id: "usage", label: "Provider usage", icon: "ChartColumn", }); - }); - - it("preloads usage and refreshes after an extended focus loss", async () => { - const fetchMock = createFetchMock(); - vi.stubGlobal("fetch", fetchMock); - const mounted = await mountPluginContentScripts(app, { - pluginId: "provider-usage", - }); - try { - await waitFor(() => - expect(fetchMock).toHaveBeenCalledWith( - "/api/v1/plugins/provider-usage/rpc/getUsage", - expect.objectContaining({ - method: "POST", - body: JSON.stringify({ - force: false, - machineIds: null, - maxAgeMs: 30 * 60_000, - }), - }), - ), - ); + if (item?.kind !== "disclosure") throw new Error("missing disclosure"); - const now = vi.spyOn(Date, "now").mockReturnValue(1_000); - window.dispatchEvent(new Event("blur")); - now.mockReturnValue(5 * 60_000 + 1_001); - const callsBeforeFocus = fetchMock.mock.calls.length; - window.dispatchEvent(new Event("focus")); - await waitFor(() => - expect(fetchMock).toHaveBeenCalledTimes(callsBeforeFocus + 1), - ); - expect(fetchMock.mock.calls.at(-1)?.[1]).toEqual( + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + "/api/v1/plugins/provider-usage/rpc/getUsage", expect.objectContaining({ + method: "POST", body: JSON.stringify({ force: false, machineIds: null, - maxAgeMs: 5 * 60_000, + maxAgeMs: 30 * 60_000, }), }), - ); - } finally { - await mounted.lifecycle.dispose(); - } - }); - - it("shows disconnected usage and scopes manual refresh to that machine", async () => { - const fetchMock = createFetchMock(); - vi.stubGlobal("fetch", fetchMock); + ), + ); const dismiss = vi.fn(); const slot = renderSlot( item, @@ -225,12 +233,50 @@ describe("provider usage footer disclosure", () => { { context: { threadId: "thread-active" }, sidebarThreads: { - threads: [threadOnMachine("host-intel", "Intel")], + threads: [threadOnMachine("host-m5", "M5")], }, }, ); + const machinePicker = slot.getByRole("button", { + name: "Usage machine: M5", + }); + expect(slot.getByRole("heading", { name: "Codex" })).toBeTruthy(); + expect(slot.getByText("codex@example.com")).toBeTruthy(); + expect(slot.getByText("97% used")).toBeTruthy(); + + fireEvent.pointerDown(machinePicker, { button: 0 }); + fireEvent.click(slot.getByRole("menuitemradio", { name: "M4" })); + const claudeTab = slot.getByRole("tab", { name: "Claude Code" }); + const codexTab = slot.getByRole("tab", { name: "Codex" }); + expect( + slot + .getByRole("button", { name: "Usage machine: M4" }) + .closest('[data-provider-usage-header=""]'), + ).toBe(claudeTab.closest('[data-provider-usage-header=""]')); + expect( + claudeTab.querySelector("[data-provider-logo*='claude-code']"), + ).not.toBeNull(); + expect( + codexTab.querySelector("[data-provider-logo*='/codex/']"), + ).not.toBeNull(); + expect(slot.getByRole("heading", { name: "Claude Code" })).toBeTruthy(); + expect(slot.getByText("claude@example.com")).toBeTruthy(); + expect(slot.getByText("82% used")).toBeTruthy(); + + fireEvent.click(codexTab); + expect(slot.getByRole("heading", { name: "Codex" })).toBeTruthy(); + expect(slot.getByText("codex@example.com")).toBeTruthy(); + expect(slot.getByText("37% used")).toBeTruthy(); + fireEvent.keyDown(codexTab, { key: "ArrowLeft" }); + expect(claudeTab.getAttribute("aria-selected")).toBe("true"); + + fireEvent.pointerDown( + slot.getByRole("button", { name: "Usage machine: M4" }), + { button: 0 }, + ); + fireEvent.click(slot.getByRole("menuitemradio", { name: "Intel" })); expect( - await slot.findByText( + slot.getByText( "Intel is offline. Usage will refresh when it reconnects.", ), ).toBeTruthy(); @@ -256,54 +302,47 @@ describe("provider usage footer disclosure", () => { }), }), ); - }); - it("aggregates machines and keeps provider selection local to the card", async () => { - const fetchMock = createFetchMock(); - vi.stubGlobal("fetch", fetchMock); - const slot = renderSlot( - item, - { dismiss: vi.fn() }, - { - context: { threadId: "thread-active" }, - sidebarThreads: { - threads: [threadOnMachine("host-m5", "M5")], - }, - }, + const now = vi.spyOn(Date, "now").mockReturnValue(1_000); + window.dispatchEvent(new Event("blur")); + now.mockReturnValue(5 * 60_000 + 1_001); + const callsBeforeFocus = fetchMock.mock.calls.length; + window.dispatchEvent(new Event("focus")); + await waitFor(() => + expect(fetchMock).toHaveBeenCalledTimes(callsBeforeFocus + 1), + ); + expect(fetchMock.mock.calls.at(-1)?.[1]).toEqual( + expect.objectContaining({ + body: JSON.stringify({ + force: false, + machineIds: null, + maxAgeMs: 5 * 60_000, + }), + }), ); - const machinePicker = await slot.findByRole("button", { - name: "Usage machine: M5", - }); - expect(slot.getByRole("heading", { name: "Codex" })).toBeTruthy(); - expect(slot.getByText("codex@example.com")).toBeTruthy(); - expect(slot.getByText("97% used")).toBeTruthy(); - fireEvent.pointerDown(machinePicker, { button: 0 }); - expect(slot.getByRole("menuitemradio", { name: "M5" })).toBeTruthy(); - expect(slot.getByRole("menuitemradio", { name: "Intel" })).toBeTruthy(); - fireEvent.click(slot.getByRole("menuitemradio", { name: "M4" })); - const claudeTab = slot.getByRole("tab", { name: "Claude Code" }); - const codexTab = slot.getByRole("tab", { name: "Codex" }); - expect( - slot - .getByRole("button", { name: "Usage machine: M4" }) - .closest('[data-provider-usage-header=""]'), - ).toBe(claudeTab.closest('[data-provider-usage-header=""]')); + fireEvent.pointerDown( + slot.getByRole("button", { name: "Usage machine: Intel" }), + { button: 0 }, + ); + fireEvent.click( + slot.getByRole("menuitemradio", { name: "Account Pooler" }), + ); + expect(slot.getAllByRole("tab")).toHaveLength(2); + const poolCodexTab = slot.getByRole("tab", { name: "Codex" }); expect( - claudeTab.querySelector("[data-provider-logo*='claude-code']"), + poolCodexTab.querySelector("[data-provider-logo*='/codex/']"), ).not.toBeNull(); expect( - codexTab.querySelector("[data-provider-logo*='/codex/']"), + poolCodexTab.querySelector('[data-provider-usage-tone="warning"]'), ).not.toBeNull(); - expect(slot.getByRole("heading", { name: "Claude Code" })).toBeTruthy(); - expect(slot.getByText("claude@example.com")).toBeTruthy(); + expect(slot.getAllByText("team@example.com")).toHaveLength(1); + expect(slot.getAllByText("personal@example.com")).toHaveLength(1); + expect(slot.getByText("46% used")).toBeTruthy(); expect(slot.getByText("82% used")).toBeTruthy(); - - fireEvent.click(codexTab); - expect(slot.getByRole("heading", { name: "Codex" })).toBeTruthy(); - expect(slot.getByText("codex@example.com")).toBeTruthy(); - expect(slot.getByText("37% used")).toBeTruthy(); - fireEvent.keyDown(codexTab, { key: "ArrowLeft" }); - expect(claudeTab.getAttribute("aria-selected")).toBe("true"); - }); + fireEvent.click(slot.getByRole("tab", { name: "Claude Code" })); + expect(slot.getByText("claude-team@example.com")).toBeTruthy(); + expect(slot.queryByText("personal@example.com")).toBeNull(); + await mounted.lifecycle.dispose(); + }, 15_000); }); diff --git a/plugins/provider-usage/app.tsx b/plugins/provider-usage/app.tsx index 41aad7363a..b92b6178fa 100644 --- a/plugins/provider-usage/app.tsx +++ b/plugins/provider-usage/app.tsx @@ -326,7 +326,23 @@ function ProviderUsageStatus({ machines.find((machine) => machine.status === "connected") ?? machines[0] ?? null; - const providers = activeMachine?.providers ?? []; + const providers = useMemo(() => { + const groups = new Map< + string, + UsageProvider & { accounts: UsageProvider[] } + >(); + for (const account of activeMachine?.providers ?? []) { + const group = groups.get(account.providerId); + if (group) group.accounts.push(account); + else + groups.set(account.providerId, { + ...account, + id: account.providerId, + accounts: [account], + }); + } + return [...groups.values()]; + }, [activeMachine]); const requestedProviderId = activeMachine === null ? null @@ -391,10 +407,10 @@ function ProviderUsageStatus({ }; return ( -
+
{providers.length === 0 ? (
@@ -406,7 +422,12 @@ function ProviderUsageStatus({ > {providers.map((provider, index) => { const isActive = provider.id === activeProvider?.id; - const tone = providerUsageTone(provider); + const tones = provider.accounts.map(providerUsageTone); + const tone = tones.includes("critical") + ? "critical" + : tones.includes("warning") + ? "warning" + : null; return ( + ); + } return (
@@ -172,7 +222,13 @@ function UsageWindow({ window }: { window: UsageWindowValue }) { ); } -function ProviderUsageBody({ provider }: { provider: UsageProvider }) { +function ProviderUsageBody({ + provider, + compact, +}: { + provider: UsageProvider; + compact: boolean; +}) { const usage = provider.usage; if (usage === null) { return

Usage not reported.

; @@ -184,9 +240,9 @@ function ProviderUsageBody({ provider }: { provider: UsageProvider }) { No usage limits reported for this plan.

) : ( -
+
{usage.windows.map((window) => ( - + ))}
); @@ -320,12 +376,12 @@ function ProviderUsageStatus({ const [requestedProviderIds, setRequestedProviderIds] = useState( lastProviderIdByMachine, ); - const activeMachine = - machines.find((machine) => machine.id === requestedMachineId) ?? - machines.find((machine) => machine.id === threadMachineId) ?? - machines.find((machine) => machine.status === "connected") ?? - machines[0] ?? - null; + const activeMachine = selectUsageMachine( + machines, + requestedMachineId, + threadMachineId, + ); + const compactAccounts = activeMachine?.id.startsWith("source:") === true; const providers = useMemo(() => { const groups = new Map< string, @@ -433,7 +489,11 @@ function ProviderUsageStatus({ key={provider.id} type="button" role="tab" - title={provider.displayName} + title={ + tone === null + ? provider.displayName + : `${provider.displayName}: an account usage window is at least ${tone === "critical" ? "95" : "80"}% used.` + } aria-label={provider.displayName} aria-selected={isActive} aria-controls={panelId} @@ -538,11 +598,17 @@ function ProviderUsageStatus({
-

+

{account.accountLabel ?? account.displayName}

{account.usage?.status === "ok" && @@ -563,14 +629,17 @@ function ProviderUsageStatus({ ) : null}
-
+
{activeMachine.status === "disconnected" ? (

{activeMachine.displayName} is offline. Usage will refresh when it reconnects.

) : activeMachine.error === null ? ( - + ) : (

{activeMachine.error} diff --git a/plugins/provider-usage/server.ts b/plugins/provider-usage/server.ts index 78574900a0..59bd3c2295 100644 --- a/plugins/provider-usage/server.ts +++ b/plugins/provider-usage/server.ts @@ -362,7 +362,12 @@ export default function providerUsagePlugin(bb: BbPluginApi): void { const shared = source.resources.filter( (resource) => resource.scope.kind === "shared", ); - if (shared.length === 0 && source.error === null) continue; + if ( + shared.length === 0 && + source.resources.length > 0 && + source.error === null + ) + continue; machines.push({ id: `source:${source.pluginId}`, displayName: diff --git a/plugins/provider-usage/usage-schema.test.ts b/plugins/provider-usage/usage-schema.test.ts index 00cc769522..c543f72e63 100644 --- a/plugins/provider-usage/usage-schema.test.ts +++ b/plugins/provider-usage/usage-schema.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { providerUsageTone, type UsageProvider } from "./usage-schema.js"; +import { + selectUsageMachine, + providerUsageTone, + type UsageProvider, + type UsageMachine, +} from "./usage-schema.js"; function provider( id: string, @@ -43,3 +48,30 @@ describe("usage warning state", () => { expect(providerUsageTone(critical)).toBe("critical"); }); }); + +describe("default usage source", () => { + const machine: UsageMachine = { + id: "host-one", + displayName: "My machine", + status: "connected", + providers: [], + error: null, + }; + const pool: UsageMachine = { + ...machine, + id: "source:account-pool", + displayName: "Account Pooler", + }; + it("prefers even an empty pool to thread-local usage, while preserving explicit selection", () => { + expect(selectUsageMachine([machine, pool], null, machine.id)).toBe(pool); + expect(selectUsageMachine([machine, pool], machine.id, null)).toBe(machine); + expect(selectUsageMachine([machine], pool.id, machine.id)).toBe(machine); + expect( + selectUsageMachine( + [machine, { ...pool, error: "Unavailable" }], + null, + machine.id, + ), + ).toBe(machine); + }); +}); diff --git a/plugins/provider-usage/usage-schema.ts b/plugins/provider-usage/usage-schema.ts index 0ddd2a7639..e796395c84 100644 --- a/plugins/provider-usage/usage-schema.ts +++ b/plugins/provider-usage/usage-schema.ts @@ -81,3 +81,20 @@ export function providerUsageTone( if (usedPercent >= 95) return "critical"; return usedPercent >= 80 ? "warning" : null; } + +export function selectUsageMachine( + machines: UsageMachine[], + requestedId: string | null, + threadMachineId: string | null, +): UsageMachine | null { + return ( + machines.find((machine) => machine.id === requestedId) ?? + machines.find( + (machine) => machine.id.startsWith("source:") && machine.error === null, + ) ?? + machines.find((machine) => machine.id === threadMachineId) ?? + machines.find((machine) => machine.status === "connected") ?? + machines[0] ?? + null + ); +} From dde0b1092d1f680ab2b27405f88245b6268c88ac Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 10 Sep 2026 22:13:11 -0700 Subject: [PATCH 08/53] Use email for pooled usage account headings --- plugins/provider-usage/app.test.tsx | 4 ++-- plugins/provider-usage/server.test.ts | 2 +- plugins/provider-usage/server.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/provider-usage/app.test.tsx b/plugins/provider-usage/app.test.tsx index 194bf3913f..a7139d447a 100644 --- a/plugins/provider-usage/app.test.tsx +++ b/plugins/provider-usage/app.test.tsx @@ -62,7 +62,7 @@ describe("provider usage footer disclosure", () => { ).map(([providerId, displayName, email, usedPercent]) => ({ id: email, providerId: providerId, - accountLabel: email === "personal@example.com" ? "Personal" : email, + accountLabel: email, displayName: displayName, logoUrl: `/api/v1/system/providers/${providerId}/logo`, iconGlyph: null, @@ -240,7 +240,7 @@ describe("provider usage footer disclosure", () => { expect( slot.getByRole("button", { name: "Usage machine: Account Pooler" }), ).toBeTruthy(); - expect(slot.getByRole("heading", { name: "Personal" })).toBeTruthy(); + expect(slot.getByRole("heading", { name: "personal@example.com" })).toBeTruthy(); fireEvent.pointerDown( slot.getByRole("button", { name: "Usage machine: Account Pooler" }), { button: 0 }, diff --git a/plugins/provider-usage/server.test.ts b/plugins/provider-usage/server.test.ts index 06fbc7697b..3c25fe0b0e 100644 --- a/plugins/provider-usage/server.test.ts +++ b/plugins/provider-usage/server.test.ts @@ -368,7 +368,7 @@ describe("usage source composition", () => { providers: [ { id: "pool:account-1", - accountLabel: "Team account", + accountLabel: "team@example.com", providerId: "codex", displayName: "Codex", logoUrl: "/codex.svg", diff --git a/plugins/provider-usage/server.ts b/plugins/provider-usage/server.ts index 59bd3c2295..cb73034a4d 100644 --- a/plugins/provider-usage/server.ts +++ b/plugins/provider-usage/server.ts @@ -148,7 +148,7 @@ function resourceProvider( resource.usage, ), id: `${pluginId}:${resource.id}`, - accountLabel: resource.scope.kind === "shared" ? resource.label : null, + accountLabel: resource.scope.kind === "shared" ? resource.usage.accountEmail : null, }; } From 1d8aa532604da37593f0576f0905fbde857e59e9 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 10 Sep 2026 22:17:26 -0700 Subject: [PATCH 09/53] Show reset countdowns alongside compact usage rows --- plugins/provider-usage/app.test.tsx | 14 ++++++++++++-- plugins/provider-usage/app.tsx | 28 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/plugins/provider-usage/app.test.tsx b/plugins/provider-usage/app.test.tsx index a7139d447a..31d990857e 100644 --- a/plugins/provider-usage/app.test.tsx +++ b/plugins/provider-usage/app.test.tsx @@ -77,7 +77,10 @@ describe("provider usage footer disclosure", () => { { label: "Weekly limit", usedPercent: usedPercent, - resetsAt: null, + resetsAt: + email === "personal@example.com" + ? new Date(Date.now() + 51 * 60 * 60_000).toISOString() + : null, cost: null, }, ], @@ -240,7 +243,9 @@ describe("provider usage footer disclosure", () => { expect( slot.getByRole("button", { name: "Usage machine: Account Pooler" }), ).toBeTruthy(); - expect(slot.getByRole("heading", { name: "personal@example.com" })).toBeTruthy(); + expect( + slot.getByRole("heading", { name: "personal@example.com" }), + ).toBeTruthy(); fireEvent.pointerDown( slot.getByRole("button", { name: "Usage machine: Account Pooler" }), { button: 0 }, @@ -330,6 +335,7 @@ describe("provider usage footer disclosure", () => { }), ); + now.mockRestore(); fireEvent.pointerDown( slot.getByRole("button", { name: "Usage machine: Intel" }), { button: 0 }, @@ -348,6 +354,10 @@ describe("provider usage footer disclosure", () => { expect(slot.getAllByText("team@example.com")).toHaveLength(1); expect(slot.getAllByText("personal@example.com")).toHaveLength(1); expect(slot.getByText("46%")).toBeTruthy(); + expect(slot.getByText("2d 3h")).toBeTruthy(); + expect( + slot.getAllByRole("heading").map((heading) => heading.textContent), + ).toEqual(["team@example.com", "personal@example.com"]); const windowButton = slot.getByRole("button", { name: "Weekly limit: 46% used. Reset time not reported", }); diff --git a/plugins/provider-usage/app.tsx b/plugins/provider-usage/app.tsx index 7b0334cc69..bc4d97977b 100644 --- a/plugins/provider-usage/app.tsx +++ b/plugins/provider-usage/app.tsx @@ -140,6 +140,19 @@ function refreshUsage({ })(); } +function formatResetCountdown(resetsAt: string | null): string | null { + if (resetsAt === null) return null; + const remaining = new Date(resetsAt).getTime() - Date.now(); + if (!Number.isFinite(remaining)) return null; + if (remaining <= 0) return "now"; + const minutes = Math.ceil(remaining / 60_000); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) + return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; + const days = Math.floor(hours / 24); + return hours % 24 === 0 ? `${days}d` : `${days}d ${hours % 24}h`; +} function UsageWindow({ window, compact, @@ -149,6 +162,7 @@ function UsageWindow({ }) { const [showReset, setShowReset] = useState(false); const reset = formatUsageReset(window.resetsAt); + const countdown = formatResetCountdown(window.resetsAt); const value = window.cost === null ? Math.round(window.usedPercent) + "% used" @@ -187,6 +201,12 @@ function UsageWindow({ {Math.round(window.usedPercent)}% + {showReset ? ( @@ -356,6 +376,14 @@ function MachineSelector({ function ProviderUsageStatus({ dismiss, }: ExperimentalSidebarFooterDisclosureProps) { + const [, refreshCountdowns] = useState(0); + useEffect(() => { + const timer = window.setInterval( + () => refreshCountdowns((tick) => tick + 1), + 60_000, + ); + return () => window.clearInterval(timer); + }, []); const snapshot = useSyncExternalStore( subscribeStore, getStoreSnapshot, From 4f16747378f9b4b82063ef360657874fa04664c4 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 10 Sep 2026 22:19:59 -0700 Subject: [PATCH 10/53] Handle usage refresh failures with a graceful retry state --- plugins/provider-usage/app.test.tsx | 38 ++++++++++++++++++++ plugins/provider-usage/app.tsx | 56 ++++++++++++++++++++++------- 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/plugins/provider-usage/app.test.tsx b/plugins/provider-usage/app.test.tsx index 31d990857e..a6ee322a24 100644 --- a/plugins/provider-usage/app.test.tsx +++ b/plugins/provider-usage/app.test.tsx @@ -367,6 +367,44 @@ describe("provider usage footer disclosure", () => { fireEvent.click(slot.getByRole("tab", { name: "Claude Code" })); expect(slot.getByText("claude-team@example.com")).toBeTruthy(); expect(slot.queryByText("personal@example.com")).toBeNull(); + const diagnostics = vi + .spyOn(console, "warn") + .mockImplementation(() => undefined); + for (const failure of [ + () => new Response("bb connect temporarily unavailable", { status: 503 }), + () => new Response("bb connect is not JSON", { status: 200 }), + () => Response.json({ ok: true, result: { machines: "invalid" } }), + ]) { + await waitFor(() => + expect( + slot + .getByRole("button", { name: "Reload provider usage" }) + .hasAttribute("disabled"), + ).toBe(false), + ); + fetchMock.mockResolvedValueOnce(failure()); + fireEvent.click( + slot.getByRole("button", { name: "Reload provider usage" }), + ); + await waitFor(() => + expect( + slot.getByText("Couldn’t refresh. Showing the last update."), + ).toBeTruthy(), + ); + expect(slot.getByText("claude-team@example.com")).toBeTruthy(); + expect( + slot.queryByText(/Unexpected token|bb connect|invalid JSON/i), + ).toBeNull(); + fireEvent.click( + slot.getByRole("button", { name: "Retry usage refresh" }), + ); + await waitFor(() => + expect( + slot.queryByText("Couldn’t refresh. Showing the last update."), + ).toBeNull(), + ); + } + expect(diagnostics).toHaveBeenCalledTimes(3); await mounted.lifecycle.dispose(); }, 15_000); }); diff --git a/plugins/provider-usage/app.tsx b/plugins/provider-usage/app.tsx index bc4d97977b..bf472837d3 100644 --- a/plugins/provider-usage/app.tsx +++ b/plugins/provider-usage/app.tsx @@ -111,9 +111,11 @@ function refreshUsage({ signal, }, ); + if (!response.ok) + throw new Error(`Usage request returned HTTP ${response.status}.`); const body: unknown = await response.json(); const parsed = usageRpcSuccessSchema.safeParse(body); - if (!response.ok || !parsed.success) { + if (!parsed.success) { throw new Error( rpcErrorMessage(body) ?? "Provider usage could not be loaded.", ); @@ -127,9 +129,10 @@ function refreshUsage({ if (signal?.aborted === true) { return; } + console.warn("Provider usage refresh failed", cause); updateStore({ ...storeSnapshot, - error: cause instanceof Error ? cause.message : String(cause), + error: "Couldn’t refresh usage.", }); } finally { activeRefreshCount -= 1; @@ -605,12 +608,13 @@ function ProviderUsageStatus({ className="min-h-0 overflow-y-auto p-2.5" > {activeMachine === null ? ( -

- {snapshot.error ?? - (snapshot.isRefreshing + snapshot.error !== null ? null : ( +

+ {snapshot.isRefreshing ? "Loading provider usage…" - : "No machines are enrolled.")} -

+ : "No machines are enrolled."} +

+ ) ) : activeProvider === null ? (

{activeMachine.status === "disconnected" @@ -677,13 +681,41 @@ function ProviderUsageStatus({

))}
- {snapshot.error === null ? null : ( -

- Showing the last update. {snapshot.error} -

- )} )} + {snapshot.error === null ? null : ( +
+ + {snapshot.data === null + ? "Couldn’t load usage." + : "Couldn’t refresh. Showing the last update."} + + +
+ )}
); From 7ece03f2449c3c00de5fd4d8fd6b1d2405fbb93e Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 10 Sep 2026 22:25:47 -0700 Subject: [PATCH 11/53] Use compact usage rows for all sources with content-sized columns --- plugins/provider-usage/app.test.tsx | 6 +- plugins/provider-usage/app.tsx | 142 ++++++++++------------------ 2 files changed, 52 insertions(+), 96 deletions(-) diff --git a/plugins/provider-usage/app.test.tsx b/plugins/provider-usage/app.test.tsx index a6ee322a24..b66a28d012 100644 --- a/plugins/provider-usage/app.test.tsx +++ b/plugins/provider-usage/app.test.tsx @@ -256,7 +256,7 @@ describe("provider usage footer disclosure", () => { }); expect(slot.getByRole("heading", { name: "Codex" })).toBeTruthy(); expect(slot.getByText("codex@example.com")).toBeTruthy(); - expect(slot.getByText("97% used")).toBeTruthy(); + expect(slot.getByText("97%")).toBeTruthy(); fireEvent.pointerDown(machinePicker, { button: 0 }); fireEvent.click(slot.getByRole("menuitemradio", { name: "M4" })); @@ -275,12 +275,12 @@ describe("provider usage footer disclosure", () => { ).not.toBeNull(); expect(slot.getByRole("heading", { name: "Claude Code" })).toBeTruthy(); expect(slot.getByText("claude@example.com")).toBeTruthy(); - expect(slot.getByText("82% used")).toBeTruthy(); + expect(slot.getByText("82%")).toBeTruthy(); fireEvent.click(codexTab); expect(slot.getByRole("heading", { name: "Codex" })).toBeTruthy(); expect(slot.getByText("codex@example.com")).toBeTruthy(); - expect(slot.getByText("37% used")).toBeTruthy(); + expect(slot.getByText("37%")).toBeTruthy(); fireEvent.keyDown(codexTab, { key: "ArrowLeft" }); expect(claudeTab.getAttribute("aria-selected")).toBe("true"); diff --git a/plugins/provider-usage/app.tsx b/plugins/provider-usage/app.tsx index bf472837d3..f6c0d599ad 100644 --- a/plugins/provider-usage/app.tsx +++ b/plugins/provider-usage/app.tsx @@ -156,13 +156,7 @@ function formatResetCountdown(resetsAt: string | null): string | null { const days = Math.floor(hours / 24); return hours % 24 === 0 ? `${days}d` : `${days}d ${hours % 24}h`; } -function UsageWindow({ - window, - compact, -}: { - window: UsageWindowValue; - compact: boolean; -}) { +function UsageWindow({ window }: { window: UsageWindowValue }) { const [showReset, setShowReset] = useState(false); const reset = formatUsageReset(window.resetsAt); const countdown = formatResetCountdown(window.resetsAt); @@ -172,86 +166,55 @@ function UsageWindow({ : formatUsdCents(window.cost.usedUsdCents, true) + " / " + formatUsdCents(window.cost.limitUsdCents, false); - if (compact) { - const label = window.label - .replace(/^Five-hour limit$|^5 hours$/u, "5h") - .replace(/^Weekly limit$|^Weekly/u, "7d") - .replace(/^Daily limit$/u, "1d"); - return ( - - ); - } - return ( -
-
- {window.label} - - {value} + + {Math.round(window.usedPercent)}% -
-
-
-
- {reset === null ? null : ( -

{reset}

- )} -
+ + + {showReset ? ( + + {reset ?? "Reset time not reported."} + {window.cost === null ? "" : ` · ${value}`} + + ) : null} + ); } -function ProviderUsageBody({ - provider, - compact, -}: { - provider: UsageProvider; - compact: boolean; -}) { +function ProviderUsageBody({ provider }: { provider: UsageProvider }) { const usage = provider.usage; if (usage === null) { return

Usage not reported.

; @@ -263,9 +226,9 @@ function ProviderUsageBody({ No usage limits reported for this plan.

) : ( -
+
{usage.windows.map((window) => ( - + ))}
); @@ -412,7 +375,6 @@ function ProviderUsageStatus({ requestedMachineId, threadMachineId, ); - const compactAccounts = activeMachine?.id.startsWith("source:") === true; const providers = useMemo(() => { const groups = new Map< string, @@ -630,10 +592,7 @@ function ProviderUsageStatus({
@@ -661,17 +620,14 @@ function ProviderUsageStatus({ ) : null}
-
+
{activeMachine.status === "disconnected" ? (

{activeMachine.displayName} is offline. Usage will refresh when it reconnects.

) : activeMachine.error === null ? ( - + ) : (

{activeMachine.error} From 712c59c29b3ab3a54a761d96ebbe7d9f1085760f Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 10 Sep 2026 22:44:48 -0700 Subject: [PATCH 12/53] Slightly widen usage row column gaps --- plugins/provider-usage/app.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/provider-usage/app.tsx b/plugins/provider-usage/app.tsx index f6c0d599ad..55188c0b5f 100644 --- a/plugins/provider-usage/app.tsx +++ b/plugins/provider-usage/app.tsx @@ -226,7 +226,7 @@ function ProviderUsageBody({ provider }: { provider: UsageProvider }) { No usage limits reported for this plan.

) : ( -
+
{usage.windows.map((window) => ( ))} From e240e62be6d7de91bf59bc94ee58977f306dc685 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 10 Sep 2026 22:50:11 -0700 Subject: [PATCH 13/53] Use Account Pooler as the plugin display name --- docs/configuration.md | 2 +- packages/plugin-api-map/src/plugin-icons.ts | 3 +++ plugins/account-pool/package.json | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index dc9f372324..83ec29b9c5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -708,7 +708,7 @@ how many connected clients received the broadcast. `spotlight` focuses the target pane and persistently dims the others; `clear-spotlight` focuses it and persistently restores undimmed splits. -## Account Pooler [Experimental] +## Account Pooler The builtin Account Pooler plugin is disabled on fresh installations. It stores non-secret Claude and Codex account metadata in plugin KV, quota observations diff --git a/packages/plugin-api-map/src/plugin-icons.ts b/packages/plugin-api-map/src/plugin-icons.ts index 112fa91ca4..e2a4ef9fbb 100644 --- a/packages/plugin-api-map/src/plugin-icons.ts +++ b/packages/plugin-api-map/src/plugin-icons.ts @@ -6,6 +6,7 @@ import { BrowserIcon, CheckListIcon, Calendar03Icon, + ChartColumnIcon, Clock01Icon, Coffee01Icon, ComputerIcon, @@ -34,6 +35,7 @@ interface FirstPartyPlugin { } const FIRST_PARTY_PLUGINS: Record = { + "Account Pooler [Experimental]": { id: "account-pool", icon: Layers01Icon }, "Ask User Question": { id: "ask-user-question", icon: MessageQuestionIcon }, Automations: { id: "automations", icon: RepeatIcon }, "Custom instructions": { id: "custom-instructions", icon: Edit04Icon }, @@ -43,6 +45,7 @@ const FIRST_PARTY_PLUGINS: Record = { "Keep Awake": { id: "keep-awake", icon: Coffee01Icon }, Memory: { id: "memory", icon: BrainIcon }, "Provider retry": { id: "provider-retry", icon: ArrowReloadHorizontalIcon }, + "Provider usage": { id: "provider-usage", icon: ChartColumnIcon }, "Push notifications": { id: "push-notifications", icon: BellDotIcon }, "Remote access": { id: "connect", icon: SmartPhone01Icon }, Secrets: { id: "secrets", icon: LockIcon }, diff --git a/plugins/account-pool/package.json b/plugins/account-pool/package.json index 0948040322..33fd740e94 100644 --- a/plugins/account-pool/package.json +++ b/plugins/account-pool/package.json @@ -9,7 +9,7 @@ "bbPluginSdk": ">=0.4.56" }, "bb": { - "name": "Account Pooler [Experimental]", + "name": "Account Pooler", "description": "Routes Claude and Codex API traffic across provider account pools.", "branding": { "icon": "Layers" From 3b7db8f5fe737ddff3717c09f9d8c3f775472de3 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 10 Sep 2026 22:54:54 -0700 Subject: [PATCH 14/53] Let usage sources label shared groups independently of plugin names --- apps/app/src/lib/usage-source-contract.ts | 7 +++++++ docs/configuration.md | 2 +- docs/discoverable-rpc-and-provider-usage-plan.md | 2 +- plugins/account-pool/package.json | 2 +- plugins/account-pool/src/server.test.ts | 1 + plugins/account-pool/src/usage-contract.ts | 7 +++++++ plugins/account-pool/src/usage-source.ts | 2 +- plugins/provider-claude-code/src/usage-contract.ts | 7 +++++++ plugins/provider-codex/src/usage-contract.ts | 7 +++++++ plugins/provider-usage/server.test.ts | 2 ++ plugins/provider-usage/server.ts | 13 +++++++++++-- plugins/provider-usage/usage-source-contract.ts | 7 +++++++ 12 files changed, 53 insertions(+), 6 deletions(-) diff --git a/apps/app/src/lib/usage-source-contract.ts b/apps/app/src/lib/usage-source-contract.ts index 9adf55ce87..7a4a6f51ac 100644 --- a/apps/app/src/lib/usage-source-contract.ts +++ b/apps/app/src/lib/usage-source-contract.ts @@ -42,6 +42,13 @@ const usageSchema = z.discriminatedUnion("status", [ }), ]); export const usageSnapshotSchema = z.object({ + label: z + .string() + .min(1) + .optional() + .describe( + "Optional label for the shared-usage group. Defaults to the source plugin's display name; host groups use machine names.", + ), resources: z.array( z.object({ id: z diff --git a/docs/configuration.md b/docs/configuration.md index 83ec29b9c5..dc9f372324 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -708,7 +708,7 @@ how many connected clients received the broadcast. `spotlight` focuses the target pane and persistently dims the others; `clear-spotlight` focuses it and persistently restores undimmed splits. -## Account Pooler +## Account Pooler [Experimental] The builtin Account Pooler plugin is disabled on fresh installations. It stores non-secret Claude and Codex account metadata in plugin KV, quota observations diff --git a/docs/discoverable-rpc-and-provider-usage-plan.md b/docs/discoverable-rpc-and-provider-usage-plan.md index 10e2200f2a..ba5b368b8c 100644 --- a/docs/discoverable-rpc-and-provider-usage-plan.md +++ b/docs/discoverable-rpc-and-provider-usage-plan.md @@ -136,7 +136,7 @@ Use `provider-usage.v1.get` as the shared method. Provider Usage documents the c Request: `{ refresh: boolean }`. -Response: a complete snapshot of the resources owned by that implementation. Each resource includes a stable source-local ID, provider ID, display label, host or shared scope, observation timestamp, and a discriminated collection result. Successful results contain individually identified windows with utilization, reset time, optional cost information, and model applicability. Authentication failures, collection failures, and unobserved data must be explicit states rather than zero usage. Finalize and copy one concrete schema before implementing adapters. +Response: a complete snapshot of the resources owned by that implementation, plus an optional `label` for the shared-usage group. This label is independent of the plugin manifest name; omission falls back to discovery’s plugin display name. Machine groups keep the host name. Each resource includes a stable source-local ID, provider ID, display label, host or shared scope, observation timestamp, and a discriminated collection result. Successful results contain individually identified windows with utilization, reset time, optional cost information, and model applicability. Authentication failures, collection failures, and unobserved data must be explicit states rather than zero usage. Finalize and copy one concrete schema before implementing adapters. Use the same milliseconds-based timestamp convention throughout. An observation timestamp describes the underlying measurement, not the time the RPC was called. If stale values are retained after a collection failure, preserve their original timestamp and expose the failed refresh separately. diff --git a/plugins/account-pool/package.json b/plugins/account-pool/package.json index 33fd740e94..0948040322 100644 --- a/plugins/account-pool/package.json +++ b/plugins/account-pool/package.json @@ -9,7 +9,7 @@ "bbPluginSdk": ">=0.4.56" }, "bb": { - "name": "Account Pooler", + "name": "Account Pooler [Experimental]", "description": "Routes Claude and Codex API traffic across provider account pools.", "branding": { "icon": "Layers" diff --git a/plugins/account-pool/src/server.test.ts b/plugins/account-pool/src/server.test.ts index 22b70412f9..a968c70585 100644 --- a/plugins/account-pool/src/server.test.ts +++ b/plugins/account-pool/src/server.test.ts @@ -5825,6 +5825,7 @@ it("publishes pooled usage without a display plugin and does not invent unobserv refresh: false, }), ); + expect(result.label).toBe("Account Pooler"); expect(result.resources).toEqual([ expect.objectContaining({ id: fixture.account.id, diff --git a/plugins/account-pool/src/usage-contract.ts b/plugins/account-pool/src/usage-contract.ts index 9adf55ce87..7a4a6f51ac 100644 --- a/plugins/account-pool/src/usage-contract.ts +++ b/plugins/account-pool/src/usage-contract.ts @@ -42,6 +42,13 @@ const usageSchema = z.discriminatedUnion("status", [ }), ]); export const usageSnapshotSchema = z.object({ + label: z + .string() + .min(1) + .optional() + .describe( + "Optional label for the shared-usage group. Defaults to the source plugin's display name; host groups use machine names.", + ), resources: z.array( z.object({ id: z diff --git a/plugins/account-pool/src/usage-source.ts b/plugins/account-pool/src/usage-source.ts index 6f23f84594..a9ac5e0442 100644 --- a/plugins/account-pool/src/usage-source.ts +++ b/plugins/account-pool/src/usage-source.ts @@ -129,7 +129,7 @@ export function registerUsageSource(bb: BbPluginApi, hub: AccountPoolHub) { }; }, ); - return { resources }; + return { label: "Account Pooler", resources }; }, }, { diff --git a/plugins/provider-claude-code/src/usage-contract.ts b/plugins/provider-claude-code/src/usage-contract.ts index 9adf55ce87..7a4a6f51ac 100644 --- a/plugins/provider-claude-code/src/usage-contract.ts +++ b/plugins/provider-claude-code/src/usage-contract.ts @@ -42,6 +42,13 @@ const usageSchema = z.discriminatedUnion("status", [ }), ]); export const usageSnapshotSchema = z.object({ + label: z + .string() + .min(1) + .optional() + .describe( + "Optional label for the shared-usage group. Defaults to the source plugin's display name; host groups use machine names.", + ), resources: z.array( z.object({ id: z diff --git a/plugins/provider-codex/src/usage-contract.ts b/plugins/provider-codex/src/usage-contract.ts index 9adf55ce87..7a4a6f51ac 100644 --- a/plugins/provider-codex/src/usage-contract.ts +++ b/plugins/provider-codex/src/usage-contract.ts @@ -42,6 +42,13 @@ const usageSchema = z.discriminatedUnion("status", [ }), ]); export const usageSnapshotSchema = z.object({ + label: z + .string() + .min(1) + .optional() + .describe( + "Optional label for the shared-usage group. Defaults to the source plugin's display name; host groups use machine names.", + ), resources: z.array( z.object({ id: z diff --git a/plugins/provider-usage/server.test.ts b/plugins/provider-usage/server.test.ts index 3c25fe0b0e..b67ea1fe72 100644 --- a/plugins/provider-usage/server.test.ts +++ b/plugins/provider-usage/server.test.ts @@ -329,6 +329,7 @@ describe("usage source composition", () => { callRpc: async ({ pluginId }) => { if (pluginId === "broken") throw new Error("Unavailable"); return usageSnapshotSchema.parse({ + label: "Pool accounts", resources: [ { id: "account-1", @@ -365,6 +366,7 @@ describe("usage source composition", () => { machines: [ { id: "source:pool", + displayName: "Pool accounts", providers: [ { id: "pool:account-1", diff --git a/plugins/provider-usage/server.ts b/plugins/provider-usage/server.ts index cb73034a4d..bf9624a8b4 100644 --- a/plugins/provider-usage/server.ts +++ b/plugins/provider-usage/server.ts @@ -17,6 +17,7 @@ import { type Resource = SourceSnapshot["resources"][number]; interface SourceResult { pluginId: string; + label: string | null; resources: Resource[]; error: string | null; } @@ -148,7 +149,8 @@ function resourceProvider( resource.usage, ), id: `${pluginId}:${resource.id}`, - accountLabel: resource.scope.kind === "shared" ? resource.usage.accountEmail : null, + accountLabel: + resource.scope.kind === "shared" ? resource.usage.accountEmail : null, }; } @@ -291,12 +293,17 @@ export default function providerUsagePlugin(bb: BbPluginApi): void { }); return { pluginId: source.pluginId, + label: snapshot.label ?? null, resources: snapshot.resources, error: null, }; } catch { return { pluginId: source.pluginId, + label: + sourceResults.find( + (entry) => entry.pluginId === source.pluginId, + )?.label ?? null, resources: [], error: "Usage could not be loaded from " + @@ -371,8 +378,10 @@ export default function providerUsagePlugin(bb: BbPluginApi): void { machines.push({ id: `source:${source.pluginId}`, displayName: + source.label ?? sources.find((entry) => entry.pluginId === source.pluginId) - ?.displayName ?? source.pluginId, + ?.displayName ?? + source.pluginId, status: "connected", providers: shared.map((resource) => resourceProvider(resource, source.pluginId, sharedProviders), diff --git a/plugins/provider-usage/usage-source-contract.ts b/plugins/provider-usage/usage-source-contract.ts index 3837ed5de8..e8fe50e47b 100644 --- a/plugins/provider-usage/usage-source-contract.ts +++ b/plugins/provider-usage/usage-source-contract.ts @@ -43,6 +43,13 @@ const usageSchema = z.discriminatedUnion("status", [ }), ]); export const usageSnapshotSchema = z.object({ + label: z + .string() + .min(1) + .optional() + .describe( + "Optional label for the shared-usage group. Defaults to the source plugin's display name; host groups use machine names.", + ), resources: z.array( z.object({ id: z From 1473cf9d57865f7a797f5b24a9469ebacc7a3658 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 10 Sep 2026 23:08:18 -0700 Subject: [PATCH 15/53] Separate pooled and machine usage in settings source picker --- .../UsageLimitsSettingsSection.stories.tsx | 33 +-- .../UsageLimitsSettingsSection.test.tsx | 34 ++- .../settings/UsageLimitsSettingsSection.tsx | 214 ++++++++++++------ .../UsageSourcesSettingsSection.test.tsx | 19 +- apps/app/src/views/SettingsView.stories.tsx | 11 +- ...iscoverable-rpc-and-provider-usage-plan.md | 6 +- 6 files changed, 206 insertions(+), 111 deletions(-) diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.stories.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.stories.tsx index 0f27f10164..4b1ea16654 100644 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.stories.tsx +++ b/apps/app/src/components/settings/UsageLimitsSettingsSection.stories.tsx @@ -154,23 +154,23 @@ type UsagePreviewProps = Pick & Partial< Pick< UsageLimitsSettingsSectionContentProps, - | "hosts" + | "locations" | "isError" | "isFetching" | "isLoading" - | "onSelectHost" - | "selectedHostId" + | "onSelectLocation" + | "selectedLocationId" > >; function UsagePreview({ usage, - hosts, + locations, isError = false, isFetching = false, isLoading = false, - onSelectHost, - selectedHostId, + onSelectLocation, + selectedLocationId, }: UsagePreviewProps) { return ( @@ -181,23 +181,30 @@ function UsagePreview({ isFetching={isFetching} onRefresh={noop} providers={PROVIDERS} - hosts={hosts} - selectedHostId={selectedHostId} - onSelectHost={onSelectHost} + locations={locations} + selectedLocationId={selectedLocationId} + onSelectLocation={onSelectLocation} /> ); } function MultipleMachinesPreview() { - const [selectedHostId, setSelectedHostId] = useState(HOSTS[0]?.id ?? null); + const [selectedLocationId, setSelectedLocationId] = useState( + HOSTS[0]?.id ?? null, + ); return ( ({ + id: host.id, + name: host.name, + kind: "host", + disabled: host.status !== "connected", + }))} + selectedLocationId={selectedLocationId} + onSelectLocation={setSelectedLocationId} /> ); } diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.test.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.test.tsx index 1d12fddf30..d66b3d84e6 100644 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.test.tsx +++ b/apps/app/src/components/settings/UsageLimitsSettingsSection.test.tsx @@ -266,16 +266,21 @@ describe("UsageLimitsSettingsSectionContent", () => { }); it("selects which connected machine supplies usage", () => { - const onSelectHost = vi.fn(); + const onSelectLocation = vi.fn(); renderContent({ usage: {}, isLoading: false, isError: false, isFetching: false, onRefresh: vi.fn(), - hosts: [primaryHost, remoteHost], - selectedHostId: primaryHost.id, - onSelectHost, + locations: [primaryHost, remoteHost].map((host) => ({ + id: host.id, + name: host.name, + kind: "host", + disabled: false, + })), + selectedLocationId: primaryHost.id, + onSelectLocation, }); const sectionHeader = screen @@ -284,12 +289,12 @@ describe("UsageLimitsSettingsSectionContent", () => { expect(sectionHeader?.classList.contains("flex-col")).toBe(true); fireEvent.pointerDown( - screen.getByRole("button", { name: "Usage limits machine" }), + screen.getByRole("button", { name: "Usage source" }), { button: 0 }, ); fireEvent.click(screen.getByRole("menuitem", { name: /Build machine/u })); - expect(onSelectHost).toHaveBeenCalledWith(remoteHost.id); + expect(onSelectLocation).toHaveBeenCalledWith(remoteHost.id); }); it("does not show a machine selector when there is only one machine", () => { @@ -299,9 +304,16 @@ describe("UsageLimitsSettingsSectionContent", () => { isError: false, isFetching: false, onRefresh: vi.fn(), - hosts: [primaryHost], - selectedHostId: primaryHost.id, - onSelectHost: vi.fn(), + locations: [ + { + id: primaryHost.id, + name: primaryHost.name, + kind: "host", + disabled: false, + }, + ], + selectedLocationId: primaryHost.id, + onSelectLocation: vi.fn(), }); const sectionHeader = screen @@ -309,9 +321,7 @@ describe("UsageLimitsSettingsSectionContent", () => { .closest("section")?.firstElementChild; expect(sectionHeader?.classList.contains("flex-row")).toBe(true); expect(sectionHeader?.classList.contains("flex-col")).toBe(false); - expect( - screen.queryByRole("button", { name: "Usage limits machine" }), - ).toBeNull(); + expect(screen.queryByRole("button", { name: "Usage source" })).toBeNull(); }); }); diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx index cc880e7a14..57e61dc8fa 100644 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx +++ b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx @@ -1,7 +1,7 @@ import { useUsageSources } from "@/hooks/queries/usage-source-queries"; import type { UsageSnapshot } from "@/lib/usage-source-contract"; import { useId, useState } from "react"; -import type { Host, ProviderInfo } from "@bb/domain"; +import type { ProviderInfo } from "@bb/domain"; import type { ProviderUsage, ProviderUsageResponse, @@ -102,6 +102,13 @@ interface ProviderUsageBlockProps { isError: boolean; } +interface UsageLocation { + id: string; + name: string; + kind: "host" | "source"; + disabled: boolean; +} + export interface UsageLimitsSettingsSectionContentProps { resources?: Array<{ key: string; @@ -116,22 +123,23 @@ export interface UsageLimitsSettingsSectionContentProps { onRefresh: () => void; providerStates?: Readonly>; providers?: readonly ProviderInfo[]; - hosts?: readonly Host[]; - selectedHostId?: string | null; - onSelectHost?: (hostId: string) => void; + locations?: readonly UsageLocation[]; + selectedLocationId?: string | null; + onSelectLocation?: (locationId: string) => void; } -function UsageMachinePicker({ - hosts, - selectedHostId, - onSelectHost, +function UsageLocationPicker({ + locations, + selectedLocationId, + onSelectLocation, }: { - hosts: readonly Host[]; - selectedHostId: string | null; - onSelectHost: (hostId: string) => void; + locations: readonly UsageLocation[]; + selectedLocationId: string | null; + onSelectLocation: (locationId: string) => void; }) { - const selectedHost = - hosts.find((host) => host.id === selectedHostId) ?? hosts[0]; + const selectedLocation = + locations.find((location) => location.id === selectedLocationId) ?? + locations[0]; return ( @@ -141,28 +149,35 @@ function UsageMachinePicker({ variant="outline" size="sm" className="max-w-48 gap-1.5" - aria-label="Usage limits machine" + aria-label="Usage source" > - + - {selectedHost?.name ?? "Machine"} + {selectedLocation?.name ?? "Source"} - - {hosts.map((host) => { - const connected = host.status === "connected"; + + {locations.map((location) => { + const connected = !location.disabled; return ( onSelectHost(host.id)} + onSelect={() => onSelectLocation(location.id)} className="flex items-center gap-2" > - - {host.name} - {host.id === selectedHost?.id ? ( + {location.kind === "host" ? ( + + ) : ( + + )} + {location.name} + {location.id === selectedLocation?.id ? ( ) : null} @@ -214,7 +229,7 @@ function ProviderUsageBlock({ > {config.name} - {accountEmail ? ( + {accountEmail && accountEmail !== config.name ? (

{accountEmail}

@@ -316,11 +331,12 @@ export function UsageLimitsSettingsSectionContent({ onRefresh, providerStates = {}, providers = [], - hosts = [], - selectedHostId = null, - onSelectHost, + locations = [], + selectedLocationId = null, + onSelectLocation, }: UsageLimitsSettingsSectionContentProps) { - const showMachinePicker = hosts.length > 1 && onSelectHost !== undefined; + const showLocationPicker = + locations.length > 1 && onSelectLocation !== undefined; const providerById = new Map( providers.map((provider) => [provider.id, provider] as const), ); @@ -346,16 +362,16 @@ export function UsageLimitsSettingsSectionContent({ : "No providers available."; return ( - {showMachinePicker ? ( - ) : null} @@ -382,36 +398,43 @@ export function UsageLimitsSettingsSectionContent({ } > - {resources !== undefined && resources.length > 0 ? ( - resources.map(({ key, resource }) => { - const config = providerConfig( - resource.providerId, - providerById.get(resource.providerId), - ); - return ( - - cost === null ? window : { ...window, cost }, - ), - } - : resource.usage - } - isLoading={false} - isError={false} - /> - ); - }) + {resources !== undefined ? ( + resources.length === 0 ? ( +

{emptyMessage}

+ ) : ( + resources.map(({ key, resource }) => { + const config = providerConfig( + resource.providerId, + providerById.get(resource.providerId), + ); + return ( + + cost === null ? window : { ...window, cost }, + ), + } + : resource.usage + } + isLoading={false} + isError={false} + /> + ); + }) + ) ) : providerConfigs.length === 0 ? (

{emptyMessage}

) : ( @@ -436,13 +459,15 @@ export function UsageLimitsSettingsSection() { const systemConfigQuery = useSystemConfig(); const hostsQuery = useHosts(); const hosts = hostsQuery.data ?? []; - const [selectedHostId, setSelectedHostId] = useState(null); + const [selectedLocationId, setSelectedLocationId] = useState( + null, + ); const primaryHost = selectPrimaryHost( hosts, systemConfigQuery.data?.primaryHostId ?? null, ); const selectedHost = - hosts.find((host) => host.id === selectedHostId) ?? primaryHost; + hosts.find((host) => host.id === selectedLocationId) ?? primaryHost; const usageHostId = selectedHost?.id ?? systemConfigQuery.data?.primaryHostId ?? undefined; const providersQuery = useSystemProviders( @@ -459,14 +484,53 @@ export function UsageLimitsSettingsSection() { ); const providers = providersQuery.data ?? []; const usageQuery = useUsageSources(); - const resources = usageQuery.sources + const sharedSources = usageQuery.sources.filter( + ({ query }) => + query.data?.label !== undefined || + query.data?.resources.length === 0 || + query.data?.resources.some( + (resource) => resource.scope.kind === "shared", + ), + ); + const locations: UsageLocation[] = [ + ...sharedSources.map(({ source, query }) => ({ + id: `source:${source.pluginId}`, + name: query.data?.label ?? source.displayName, + kind: "source" as const, + disabled: false, + })), + ...hosts.map((host) => ({ + id: host.id, + name: host.name, + kind: "host" as const, + disabled: host.status !== "connected", + })), + ]; + const selectedLocation = + locations.find((location) => location.id === selectedLocationId) ?? + locations.find((location) => location.kind === "source") ?? + locations.find((location) => location.id === primaryHost?.id) ?? + locations[0]; + const selectedSources = usageQuery.sources.filter(({ source, query }) => + selectedLocation?.kind === "source" + ? selectedLocation.id === `source:${source.pluginId}` + : !query.data || + query.data.resources.some( + (resource) => + resource.scope.kind === "host" && + resource.scope.hostId === selectedLocation?.id, + ), + ); + const resources = selectedSources .flatMap(({ source, query }) => (query.data?.resources ?? []) .filter( (resource) => resource.usage.status !== "not_installed" && - (resource.scope.kind === "shared" || - resource.scope.hostId === usageHostId), + (selectedLocation?.kind === "source" + ? resource.scope.kind === "shared" + : resource.scope.kind === "host" && + resource.scope.hostId === selectedLocation?.id), ) .map((resource) => ({ key: `${source.pluginId}:${resource.id}`, @@ -487,11 +551,11 @@ export function UsageLimitsSettingsSection() { resources={resources} isLoading={ usageQuery.discovery.isPending || - usageQuery.sources.some(({ query }) => query.isPending) + selectedSources.some(({ query }) => query.isPending) } isError={ usageQuery.discovery.isError || - usageQuery.sources.some(({ query }) => query.isError) + selectedSources.some(({ query }) => query.isError) } isProviderListLoading={providersQuery.isLoading} isProviderListError={providersQuery.isError} @@ -500,9 +564,9 @@ export function UsageLimitsSettingsSection() { void usageQuery.refresh(); }} providers={providers} - hosts={hosts} - selectedHostId={selectedHost?.id ?? null} - onSelectHost={setSelectedHostId} + locations={locations} + selectedLocationId={selectedLocation?.id ?? null} + onSelectLocation={setSelectedLocationId} /> ); } diff --git a/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx b/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx index 44bbaf665f..1c0f8e8f5b 100644 --- a/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx +++ b/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx @@ -35,15 +35,16 @@ afterEach(() => { vi.clearAllMocks(); }); -it("keeps the existing presentation for discovered shared and host usage and refreshes through the copied contract", async () => { +it("selects pooled or machine usage without mixing sources, preserves cards, and refreshes through the copied contract", async () => { calls.discover.mockResolvedValue([ - { pluginId: "pool", displayName: "Account Pooler" }, + { pluginId: "pool", displayName: "Account Pooler [Experimental]" }, { pluginId: "local", displayName: "Codex provider" }, { pluginId: "broken", displayName: "Unavailable provider" }, ]); calls.rpc.mockImplementation(async ({ pluginId }) => { if (pluginId === "broken") throw new Error("Unavailable"); return { + ...(pluginId === "pool" ? { label: "Account Pooler" } : {}), resources: [ { id: "same-local-id", @@ -56,7 +57,7 @@ it("keeps the existing presentation for discovered shared and host usage and ref observedAt: 1_700_000_000_000, usage: { status: "ok", - accountEmail: null, + accountEmail: "person@example.com", planLabel: null, windows: [ { @@ -85,9 +86,17 @@ it("keeps the existing presentation for discovered shared and host usage and ref , ); expect(await screen.findByText("42% used")).toBeTruthy(); - expect(await screen.findByText("81% used")).toBeTruthy(); + expect(screen.queryByText("81% used")).toBeNull(); + expect(screen.getAllByText("person@example.com")).toHaveLength(1); expect(screen.queryByText(/Shared across machines/)).toBeNull(); - expect(screen.queryByText("Account Pooler")).toBeNull(); + expect(screen.getByText("Account Pooler")).toBeTruthy(); + fireEvent.pointerDown( + screen.getByRole("button", { name: "Usage source" }), + { button: 0 }, + ); + fireEvent.click(screen.getByRole("menuitem", { name: "Build machine" })); + expect(await screen.findByText("81% used")).toBeTruthy(); + expect(screen.queryByText("42% used")).toBeNull(); expect(screen.queryByText(/Observed/)).toBeNull(); expect(screen.getByText("Your provider subscription usage.")).toBeTruthy(); await waitFor(() => diff --git a/apps/app/src/views/SettingsView.stories.tsx b/apps/app/src/views/SettingsView.stories.tsx index cdd1ddf921..a68fa826be 100644 --- a/apps/app/src/views/SettingsView.stories.tsx +++ b/apps/app/src/views/SettingsView.stories.tsx @@ -381,9 +381,14 @@ function UsageLimitsStory() { setIsFetching(true); window.setTimeout(() => setIsFetching(false), 500); }} - hosts={usageHosts} - selectedHostId={selectedHostId} - onSelectHost={setSelectedHostId} + locations={usageHosts.map((host) => ({ + id: host.id, + name: host.name, + kind: "host", + disabled: host.status !== "connected", + }))} + selectedLocationId={selectedHostId} + onSelectLocation={setSelectedHostId} /> ); } diff --git a/docs/discoverable-rpc-and-provider-usage-plan.md b/docs/discoverable-rpc-and-provider-usage-plan.md index ba5b368b8c..5166d45ec2 100644 --- a/docs/discoverable-rpc-and-provider-usage-plan.md +++ b/docs/discoverable-rpc-and-provider-usage-plan.md @@ -1,6 +1,6 @@ # Discoverable RPC and replaceable provider usage displays -Status: prototype implemented for discoverable RPC, Account Pooler, Codex, Claude Code, and the core `/settings/usage` page. Provider Usage defines the canonical contract in `plugins/provider-usage/usage-source-contract.ts` and consumes discovered sources through it. The core settings page preserves its existing presentation and machine picker; only its data source changes. +Status: prototype implemented for discoverable RPC, Account Pooler, Codex, Claude Code, and the core `/settings/usage` page. Provider Usage defines the canonical contract in `plugins/provider-usage/usage-source-contract.ts` and consumes discovered sources through it. The core settings page preserves its existing provider cards and extends the machine picker to select shared sources such as Account Pooler. Shared sources are selected by default. ## Prototype verification @@ -150,9 +150,9 @@ Use the same milliseconds-based timestamp convention throughout. An observation ### Display implementation -Provider Usage discovers sources whenever it loads or refreshes data, invokes them with bounded concurrency and bounded wait, and renders successful results even if another source fails. The core settings prototype preserves the existing provider-card presentation, refresh control, and machine picker. Host-local observations follow the selected machine; shared accounts use the same cards. Source-group headings and observation timestamps are not added to this page. Other display plugins can choose their own presentation using the same metadata. +Provider Usage discovers sources whenever it loads or refreshes data, invokes them with bounded concurrency and bounded wait, and renders successful results even if another source fails. The core settings prototype preserves the existing provider-card presentation and refresh control. Its source picker defaults to a shared source when available, or the primary machine otherwise; explicit selections win. Shared-source selections show only that source’s shared accounts, and machine selections show only that machine’s host-local observations. Account headings use email without repeating it as a subtitle. Source-group headings and observation timestamps are not added to this page. Other display plugins can choose their own presentation using the same metadata. -Namespace resource keys by reporting plugin ID. Do not deduplicate by email or sum unrelated quota percentages. A shared pool appears once; a local account and a pool account may both appear even when their labels match. Source removal evicts its current display entries on the next reconciliation. +Namespace resource keys by reporting plugin ID. Do not deduplicate by email or sum unrelated quota percentages. A shared pool appears once in the source picker; local and pooled observations remain separate choices even when their account emails match. Source removal evicts its current display entries on the next reconciliation. An alternative display uses the same discovery query and its own copied response schema. It may render richer UI without changing any producer. Both displays can run at once; producer refresh coalescing limits duplicate work. From 574a2a7d4a8c0ee5a82cb77fc7721829eafd40a6 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 10 Sep 2026 23:29:10 -0700 Subject: [PATCH 16/53] Handle empty and failed usage sources and fix unfiltered RPC discovery --- .../settings/UsageLimitsSettingsSection.tsx | 42 +++++++- .../UsageSourcesSettingsSection.test.tsx | 101 ++++++++++++++++++ apps/app/src/lib/usage-source-contract.ts | 2 +- ...iscoverable-rpc-and-provider-usage-plan.md | 5 +- packages/sdk/src/areas/plugins.ts | 4 +- .../sdk/test/plugin-rpc-discovery.test.ts | 28 +++++ plugins/account-pool/src/server.test.ts | 29 +++++ plugins/account-pool/src/usage-contract.ts | 2 +- plugins/account-pool/src/usage-source.ts | 4 +- .../src/usage-contract.ts | 2 +- plugins/provider-codex/src/usage-contract.ts | 2 +- plugins/provider-usage/README.md | 25 ++++- plugins/provider-usage/app.test.tsx | 80 ++++++++++++++ plugins/provider-usage/app.tsx | 39 ++++--- plugins/provider-usage/server.test.ts | 66 +++++++++++- plugins/provider-usage/server.ts | 32 +++++- plugins/provider-usage/usage-schema.ts | 4 +- .../provider-usage/usage-source-contract.ts | 2 +- 18 files changed, 431 insertions(+), 38 deletions(-) diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx index 57e61dc8fa..94d9509b97 100644 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx +++ b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx @@ -110,6 +110,8 @@ interface UsageLocation { } export interface UsageLimitsSettingsSectionContentProps { + sourceNotice?: string | null; + emptySourceMessage?: string; resources?: Array<{ key: string; resource: UsageSnapshot["resources"][number]; @@ -322,6 +324,8 @@ function ProviderUsageBody({ export function UsageLimitsSettingsSectionContent({ usage, + sourceNotice = null, + emptySourceMessage = "No providers report usage limits on this machine.", resources, isLoading, isError, @@ -359,7 +363,9 @@ export function UsageLimitsSettingsSectionContent({ ? "Loading providers and usage…" : isError || isProviderListError ? "Couldn't load providers or usage right now." - : "No providers available."; + : resources !== undefined + ? emptySourceMessage + : "No providers available."; return ( + {sourceNotice ? ( +

+ {sourceNotice} +

+ ) : null} {resources !== undefined ? ( resources.length === 0 ? ( -

{emptyMessage}

+ sourceNotice ? null : ( +

{emptyMessage}

+ ) ) : ( resources.map(({ key, resource }) => { const config = providerConfig( @@ -415,6 +428,10 @@ export function UsageLimitsSettingsSectionContent({ ? { ...config, name: resource.usage.accountEmail ?? config.name, + signInHint: + "Sign in to this account in the source plugin’s settings, then reload usage.", + expiredHint: + "This account’s session expired. Sign in again in the source plugin’s settings, then reload usage.", } : config } @@ -487,7 +504,6 @@ export function UsageLimitsSettingsSection() { const sharedSources = usageQuery.sources.filter( ({ query }) => query.data?.label !== undefined || - query.data?.resources.length === 0 || query.data?.resources.some( (resource) => resource.scope.kind === "shared", ), @@ -549,6 +565,26 @@ export function UsageLimitsSettingsSection() { query.isError && !query.data, + ) + ? "Some usage sources couldn’t be loaded. Try reloading usage." + : selectedSources.some(({ query }) => query.isError) + ? "Couldn’t refresh usage. Showing the last update. Try reloading usage." + : null + } isLoading={ usageQuery.discovery.isPending || selectedSources.some(({ query }) => query.isPending) diff --git a/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx b/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx index 1c0f8e8f5b..b02d77d189 100644 --- a/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx +++ b/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx @@ -118,3 +118,104 @@ it("selects pooled or machine usage without mixing sources, preserves cards, and client.clear(); } }); + +it("distinguishes an empty shared source, empty host sources, removal, and discovery failure", async () => { + calls.discover.mockResolvedValue([ + { pluginId: "pool", displayName: "Account Pooler [Experimental]" }, + { pluginId: "local", displayName: "Local provider" }, + ]); + calls.rpc.mockImplementation(async ({ pluginId }) => + pluginId === "pool" + ? { label: "Account Pooler", resources: [] } + : { resources: [] }, + ); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + try { + render( + + + + + , + ); + expect(await screen.findByText("Account Pooler")).toBeTruthy(); + expect(screen.getByText(/No accounts report usage yet/)).toBeTruthy(); + expect(screen.queryByText("Local provider")).toBeNull(); + calls.discover.mockResolvedValue([]); + fireEvent.click(screen.getByLabelText("Reload usage data")); + expect( + await screen.findByText(/No usage sources are available/), + ).toBeTruthy(); + expect(screen.queryByText("Account Pooler")).toBeNull(); + calls.discover.mockRejectedValue(new Error("private technical details")); + fireEvent.click(screen.getByLabelText("Reload usage data")); + expect(await screen.findByRole("status")).toHaveProperty( + "textContent", + "Couldn’t discover usage sources. Try reloading usage.", + ); + expect(screen.queryByText(/private technical/)).toBeNull(); + } finally { + client.clear(); + } +}); + +it("keeps successful measurements visible after a failed refresh and recovers", async () => { + calls.discover.mockResolvedValue([ + { pluginId: "pool", displayName: "Account Pooler" }, + ]); + const snapshot = { + label: "Account Pooler", + resources: [ + { + id: "account", + providerId: "codex", + label: "Account", + scope: { kind: "shared" }, + observedAt: 123, + usage: { + status: "ok", + accountEmail: "person@example.com", + planLabel: null, + windows: [ + { + id: "weekly", + label: "Weekly", + usedPercent: 42, + resetsAt: null, + model: null, + cost: null, + }, + ], + }, + }, + ], + }; + calls.rpc.mockResolvedValue(snapshot); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + try { + render( + + + + + , + ); + expect(await screen.findByText("42% used")).toBeTruthy(); + calls.rpc.mockRejectedValue(new Error("Unexpected token b")); + fireEvent.click(screen.getByLabelText("Reload usage data")); + expect(await screen.findByText(/Showing the last update/)).toBeTruthy(); + expect(screen.getByText("42% used")).toBeTruthy(); + expect(screen.queryByText(/Unexpected token/)).toBeNull(); + calls.rpc.mockResolvedValue(snapshot); + fireEvent.click(screen.getByLabelText("Reload usage data")); + await waitFor(() => + expect(screen.queryByText(/Showing the last update/)).toBeNull(), + ); + } finally { + client.clear(); + } +}); diff --git a/apps/app/src/lib/usage-source-contract.ts b/apps/app/src/lib/usage-source-contract.ts index 7a4a6f51ac..edd1d8b1e3 100644 --- a/apps/app/src/lib/usage-source-contract.ts +++ b/apps/app/src/lib/usage-source-contract.ts @@ -47,7 +47,7 @@ export const usageSnapshotSchema = z.object({ .min(1) .optional() .describe( - "Optional label for the shared-usage group. Defaults to the source plugin's display name; host groups use machine names.", + "Declares a shared-usage group, including when resources is empty. Omit for host-only sources. Shared resources without this label use the plugin display name; host groups use machine names.", ), resources: z.array( z.object({ diff --git a/docs/discoverable-rpc-and-provider-usage-plan.md b/docs/discoverable-rpc-and-provider-usage-plan.md index 5166d45ec2..bbfb0ee6ba 100644 --- a/docs/discoverable-rpc-and-provider-usage-plan.md +++ b/docs/discoverable-rpc-and-provider-usage-plan.md @@ -136,7 +136,7 @@ Use `provider-usage.v1.get` as the shared method. Provider Usage documents the c Request: `{ refresh: boolean }`. -Response: a complete snapshot of the resources owned by that implementation, plus an optional `label` for the shared-usage group. This label is independent of the plugin manifest name; omission falls back to discovery’s plugin display name. Machine groups keep the host name. Each resource includes a stable source-local ID, provider ID, display label, host or shared scope, observation timestamp, and a discriminated collection result. Successful results contain individually identified windows with utilization, reset time, optional cost information, and model applicability. Authentication failures, collection failures, and unobserved data must be explicit states rather than zero usage. Finalize and copy one concrete schema before implementing adapters. +Response: a complete snapshot of the resources owned by that implementation, plus an optional `label` for the shared-usage group. Presence of this label declares the shared group even when its resources are empty; host-only sources omit it. An empty unlabeled snapshot declares no shared group. For nonempty shared resources, omission falls back to discovery’s plugin display name. The label is independent of the plugin manifest name. Machine groups keep the host name. Each resource includes a stable source-local ID, provider ID, display label, host or shared scope, observation timestamp, and a discriminated collection result. Successful results contain individually identified windows with utilization, reset time, optional cost information, and model applicability. Authentication failures, collection failures, and unobserved data must be explicit states rather than zero usage. Finalize and copy one concrete schema before implementing adapters. Use the same milliseconds-based timestamp convention throughout. An observation timestamp describes the underlying measurement, not the time the RPC was called. If stale values are retained after a collection failure, preserve their original timestamp and expose the failed refresh separately. @@ -195,3 +195,6 @@ Use Turbo for relevant package typechecks and tests. Extend the plugin test harn - Relevant Provider Usage UI journeys and plugin CLI flows pass the repository's verification workflow. The result is complete when discovery and inspection are generally usable, usage producers implement the public convention, and either display can consume them independently. Provider Retry and thread-specific quota attribution are not prerequisites. + + +Usage-state review: both consumers distinguish loading, empty shared groups, unavailable sources, uninstalled providers, per-account authentication/collection failures, plans without reported limits, and offline machines. Shared-account sign-in guidance refers to the source plugin’s settings. Failed source refreshes preserve successful cached observations with a visible notice; disabled sources disappear on discovery reconciliation. Browser fixtures exercise source selection, removal, and retry recovery without changing configured accounts. Unfiltered CLI discovery omits undefined filters instead of serializing them into literal query values. diff --git a/packages/sdk/src/areas/plugins.ts b/packages/sdk/src/areas/plugins.ts index 0c11a9a7cc..6802d928a2 100644 --- a/packages/sdk/src/areas/plugins.ts +++ b/packages/sdk/src/areas/plugins.ts @@ -386,7 +386,9 @@ export function createPluginsArea(args: CreateSdkAreaArgs): PluginsArea { }, async experimental_discoverRpc(input = {}) { const query = pluginRpcDiscoveryQuerySchema.parse(input); - const params = new URLSearchParams(query); + const params = new URLSearchParams(); + if (query.pluginId !== undefined) params.set("pluginId", query.pluginId); + if (query.method !== undefined) params.set("method", query.method); return requestParsed( `/api/v1/plugins/rpc?${params}`, pluginRpcDiscoveryResponseSchema, diff --git a/packages/sdk/test/plugin-rpc-discovery.test.ts b/packages/sdk/test/plugin-rpc-discovery.test.ts index 47e5e51847..d2c9dab18a 100644 --- a/packages/sdk/test/plugin-rpc-discovery.test.ts +++ b/packages/sdk/test/plugin-rpc-discovery.test.ts @@ -53,3 +53,31 @@ it("discovers published methods with filters and calls using a copied response s }), ).rejects.toThrow(); }); + +it("omits absent and explicitly undefined discovery filters", async () => { + const urls: URL[] = []; + const sdk = createBbSdk({ + transport: createHttpTransport({ + baseUrl: "http://bb.test", + runtime: "node", + fetch: async (input) => { + urls.push(new URL(String(input))); + return Response.json([]); + }, + }), + }); + await sdk.plugins.experimental_discoverRpc(); + await sdk.plugins.experimental_discoverRpc({ + method: undefined, + pluginId: undefined, + }); + await sdk.plugins.experimental_discoverRpc({ + pluginId: "pool", + method: undefined, + }); + expect(urls.map((url) => Object.fromEntries(url.searchParams))).toEqual([ + {}, + {}, + { pluginId: "pool" }, + ]); +}); diff --git a/plugins/account-pool/src/server.test.ts b/plugins/account-pool/src/server.test.ts index a968c70585..bebe6c06f3 100644 --- a/plugins/account-pool/src/server.test.ts +++ b/plugins/account-pool/src/server.test.ts @@ -5844,3 +5844,32 @@ it("publishes pooled usage without a display plugin and does not invent unobserv ), ).toEqual(["provider-usage.v1.get"]); }); + +it("publishes an empty shared usage group before any accounts or settings are configured", async () => { + const dataDir = await mkdtemp(path.join(tmpdir(), "bb-empty-usage-pool-")); + const host = createFakePluginHost({ + pluginId: "account-pool", + dataDir, + sdk: sdkStubs(), + }); + const fetch = vi.fn(async () => { + throw new Error("An empty pool must not contact an upstream"); + }); + try { + await createAccountPoolPlugin({ fetch })(host.bb); + expect( + host.harness.registrations.experimental_publishedRpcMethods.map( + (entry) => entry.method, + ), + ).toContain("provider-usage.v1.get"); + for (const refresh of [false, true]) { + await expect( + host.harness.behavior.callRpc("provider-usage.v1.get", { refresh }), + ).resolves.toEqual({ label: "Account Pooler", resources: [] }); + } + expect(fetch).not.toHaveBeenCalled(); + } finally { + await host.harness.lifecycle.dispose(); + await fs.rm(dataDir, { recursive: true, force: true }); + } +}); diff --git a/plugins/account-pool/src/usage-contract.ts b/plugins/account-pool/src/usage-contract.ts index 7a4a6f51ac..edd1d8b1e3 100644 --- a/plugins/account-pool/src/usage-contract.ts +++ b/plugins/account-pool/src/usage-contract.ts @@ -47,7 +47,7 @@ export const usageSnapshotSchema = z.object({ .min(1) .optional() .describe( - "Optional label for the shared-usage group. Defaults to the source plugin's display name; host groups use machine names.", + "Declares a shared-usage group, including when resources is empty. Omit for host-only sources. Shared resources without this label use the plugin display name; host groups use machine names.", ), resources: z.array( z.object({ diff --git a/plugins/account-pool/src/usage-source.ts b/plugins/account-pool/src/usage-source.ts index a9ac5e0442..e9419c98b7 100644 --- a/plugins/account-pool/src/usage-source.ts +++ b/plugins/account-pool/src/usage-source.ts @@ -111,7 +111,9 @@ export function registerUsageSource(bb: BbPluginApi, hub: AccountPoolHub) { planLabel: usagePlanLabel(account), }; const error = - account.error ?? + (account.error === null + ? null + : "Usage could not be collected for this account. Try refreshing usage.") ?? (account.observedAt === null ? "Usage has not been observed for this account." : null); diff --git a/plugins/provider-claude-code/src/usage-contract.ts b/plugins/provider-claude-code/src/usage-contract.ts index 7a4a6f51ac..edd1d8b1e3 100644 --- a/plugins/provider-claude-code/src/usage-contract.ts +++ b/plugins/provider-claude-code/src/usage-contract.ts @@ -47,7 +47,7 @@ export const usageSnapshotSchema = z.object({ .min(1) .optional() .describe( - "Optional label for the shared-usage group. Defaults to the source plugin's display name; host groups use machine names.", + "Declares a shared-usage group, including when resources is empty. Omit for host-only sources. Shared resources without this label use the plugin display name; host groups use machine names.", ), resources: z.array( z.object({ diff --git a/plugins/provider-codex/src/usage-contract.ts b/plugins/provider-codex/src/usage-contract.ts index 7a4a6f51ac..edd1d8b1e3 100644 --- a/plugins/provider-codex/src/usage-contract.ts +++ b/plugins/provider-codex/src/usage-contract.ts @@ -47,7 +47,7 @@ export const usageSnapshotSchema = z.object({ .min(1) .optional() .describe( - "Optional label for the shared-usage group. Defaults to the source plugin's display name; host groups use machine names.", + "Declares a shared-usage group, including when resources is empty. Omit for host-only sources. Shared resources without this label use the plugin display name; host groups use machine names.", ), resources: z.array( z.object({ diff --git a/plugins/provider-usage/README.md b/plugins/provider-usage/README.md index b0074f090b..ef8968c733 100644 --- a/plugins/provider-usage/README.md +++ b/plugins/provider-usage/README.md @@ -1,8 +1,23 @@ # Provider usage -Shows live usage limits from every provider that supports BB's usage -maintenance capability. The card follows the provider picker's ordering, -names, and icons. +Shows usage from enabled usage-source plugins in the sidebar. Provider tabs +use provider names and icons, with pooled accounts stacked under each provider. +Shared sources such as Account Pooler are selected by default; an explicit +machine selection shows that machine’s local usage instead. -The same underlying data is available to people and agents with -`bb settings usage --json` and `bb.sdk.system.usageLimits()`. +An unconfigured shared source remains selectable and shows setup guidance. +Failed refreshes retain the last available measurements with a retry notice. +Account authentication failures and plans without reported limits have separate +states; unavailable usage is never represented as zero consumption. + +Settings → Usage limits consumes the same sources independently, using its +existing full-size provider cards. Neither display is required for source +plugins to publish their usage. + +Use `bb plugin rpc list --method provider-usage.v1.get --json` to find sources +and `bb plugin rpc inspect --method provider-usage.v1.get --json` +to inspect their published contracts. RPC calls accept JSON through +`--input-file`. See the Plugin Guide for the contract API. + +`bb settings usage --json` and `bb.sdk.system.usageLimits()` remain the +host-local provider-maintenance view; they do not aggregate shared pool accounts. diff --git a/plugins/provider-usage/app.test.tsx b/plugins/provider-usage/app.test.tsx index b66a28d012..ad8b5e348e 100644 --- a/plugins/provider-usage/app.test.tsx +++ b/plugins/provider-usage/app.test.tsx @@ -408,3 +408,83 @@ describe("provider usage footer disclosure", () => { await mounted.lifecycle.dispose(); }, 15_000); }); + +it.each([ + ["empty", "No accounts report usage yet."], + ["expired", "Sign in again in the source plugin’s settings."], + [ + "unauthenticated", + "Sign in to this account in the source plugin’s settings.", + ], + ["no-limits", "No usage limits reported for this plan."], + ["source-error", "Couldn’t refresh. Showing the last update."], +] as const)("renders the %s shared-source state", async (state, expected) => { + const usage: UsageProvider["usage"] = + state === "expired" || state === "unauthenticated" + ? { status: state } + : { + status: "ok", + accountEmail: "review@example.com", + planLabel: null, + windows: + state === "no-limits" + ? [] + : [ + { + label: "Weekly limit", + usedPercent: 42, + resetsAt: null, + cost: null, + }, + ], + }; + const account: UsageProvider = { + id: "account", + providerId: "codex", + accountLabel: "review@example.com", + displayName: "Codex", + logoUrl: null, + iconGlyph: null, + iconTint: null, + signInHint: "Sign in to this account in the source plugin’s settings.", + expiredHint: "Sign in again in the source plugin’s settings.", + usage, + }; + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ + ok: true, + result: { + machines: [ + { + id: "source:pool", + displayName: "Review pool", + status: "connected", + providers: state === "empty" ? [] : [account], + error: state === "source-error" ? "private backend error" : null, + }, + ], + }, + }), + ), + ); + const app = await loadPluginApp(() => import("./app")); + const mounted = await mountPluginContentScripts(app, { + pluginId: "provider-usage", + }); + const item = app.experimentalSidebarFooterItems[0]; + if (item?.kind !== "disclosure") throw new Error("missing disclosure"); + const slot = renderSlot(item, { dismiss: vi.fn() }); + await waitFor(() => + expect(slot.getByText(expected, { exact: false })).toBeTruthy(), + ); + if (state === "source-error") { + expect(slot.getByText("42%")).toBeTruthy(); + expect(slot.queryByText("private backend error")).toBeNull(); + expect( + slot.getByRole("button", { name: "Retry usage refresh" }), + ).toBeTruthy(); + } + await mounted.lifecycle.dispose(); +}); diff --git a/plugins/provider-usage/app.tsx b/plugins/provider-usage/app.tsx index 55188c0b5f..993fe0f572 100644 --- a/plugins/provider-usage/app.tsx +++ b/plugins/provider-usage/app.tsx @@ -108,7 +108,10 @@ function refreshUsage({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ force, machineIds, maxAgeMs }), - signal, + signal: + signal === undefined + ? AbortSignal.timeout(60_000) + : AbortSignal.any([signal, AbortSignal.timeout(60_000)]), }, ); if (!response.ok) @@ -282,7 +285,7 @@ function MachineSelector({ > - {activeMachine?.displayName ?? "No machines"} + {activeMachine?.displayName ?? "Usage"} @@ -311,13 +314,19 @@ function MachineSelector({ aria-hidden="true" className={cn( "size-1.5 shrink-0 rounded-full", - machine.status === "connected" - ? "bg-success" - : "border border-muted-foreground", + machine.error !== null + ? "bg-warning" + : machine.status === "connected" + ? "bg-success" + : "border border-muted-foreground", )} /> {machine.displayName} - {machine.status === "disconnected" ? ( + {machine.error !== null ? ( + + Unavailable + + ) : machine.status === "disconnected" ? ( Offline @@ -381,6 +390,7 @@ function ProviderUsageStatus({ UsageProvider & { accounts: UsageProvider[] } >(); for (const account of activeMachine?.providers ?? []) { + if (account.usage?.status === "not_installed") continue; const group = groups.get(account.providerId); if (group) group.accounts.push(account); else @@ -582,8 +592,11 @@ function ProviderUsageStatus({ {activeMachine.status === "disconnected" ? activeMachine.displayName + " is offline. Usage will refresh when it reconnects." - : (activeMachine.error ?? - "No providers report usage limits on this machine.")} + : activeMachine.error !== null + ? null + : activeMachine.id.startsWith("source:") + ? "No accounts report usage yet. Configure accounts in the source plugin’s settings." + : "No providers report usage limits on this machine."}

) : ( <> @@ -626,12 +639,8 @@ function ProviderUsageStatus({ {activeMachine.displayName} is offline. Usage will refresh when it reconnects.

- ) : activeMachine.error === null ? ( - ) : ( -

- {activeMachine.error} -

+ )}
@@ -639,7 +648,7 @@ function ProviderUsageStatus({
)} - {snapshot.error === null ? null : ( + {snapshot.error === null && activeMachine?.error == null ? null : (
- {snapshot.data === null + {snapshot.data === null || activeMachine?.providers.length === 0 ? "Couldn’t load usage." : "Couldn’t refresh. Showing the last update."} diff --git a/plugins/provider-usage/server.test.ts b/plugins/provider-usage/server.test.ts index b67ea1fe72..46bbe6f9c8 100644 --- a/plugins/provider-usage/server.test.ts +++ b/plugins/provider-usage/server.test.ts @@ -309,6 +309,7 @@ describe("provider usage backend", () => { describe("usage source composition", () => { it("keeps shared accounts once, isolates failures, and removes disabled sources", async () => { let enabled = true; + let failPool = false; const host = createFakePluginHost({ pluginId: "provider-usage", sdk: { @@ -327,7 +328,8 @@ describe("usage source composition", () => { ] : [], callRpc: async ({ pluginId }) => { - if (pluginId === "broken") throw new Error("Unavailable"); + if (pluginId === "broken" || failPool) + throw new Error("Unavailable"); return usageSnapshotSchema.parse({ label: "Pool accounts", resources: [ @@ -397,10 +399,72 @@ describe("usage source composition", () => { input: { refresh: true }, }); expect(host.harness.sdk.callsTo("system.usageLimits")).toEqual([]); + failPool = true; + const stale = await host.harness.behavior.callRpc("getUsage", { + ...request, + force: true, + }); + expect(stale).toMatchObject({ + machines: [ + { + id: "source:pool", + error: "Usage could not be loaded from pool.", + providers: [ + { usage: { status: "ok", windows: [{ usedPercent: 120 }] } }, + ], + }, + { id: "source:broken" }, + ], + }); enabled = false; await expect( host.harness.behavior.callRpc("getUsage", request), ).resolves.toEqual({ machines: [] }); await host.harness.lifecycle.dispose(); }); + it("keeps an unconfigured shared source selectable without inventing groups for empty host sources", async () => { + const host = createFakePluginHost({ + pluginId: "provider-usage", + sdk: { + hosts: { list: async () => [] }, + plugins: { + experimental_discoverRpc: async () => [ + { + pluginId: "pool", + displayName: "Account Pooler [Experimental]", + method: usageSourceMethod, + }, + { + pluginId: "local", + displayName: "Local provider", + method: usageSourceMethod, + }, + ], + callRpc: async ({ pluginId }) => + pluginId === "pool" + ? { label: "Account Pooler", resources: [] } + : { resources: [] }, + }, + }, + }); + plugin(host.bb); + await expect( + host.harness.behavior.callRpc("getUsage", { + force: false, + machineIds: null, + maxAgeMs: 0, + }), + ).resolves.toEqual({ + machines: [ + { + id: "source:pool", + displayName: "Account Pooler", + status: "connected", + providers: [], + error: null, + }, + ], + }); + await host.harness.lifecycle.dispose(); + }); }); diff --git a/plugins/provider-usage/server.ts b/plugins/provider-usage/server.ts index bf9624a8b4..88f1ed0329 100644 --- a/plugins/provider-usage/server.ts +++ b/plugins/provider-usage/server.ts @@ -148,6 +148,14 @@ function resourceProvider( }, resource.usage, ), + ...(resource.scope.kind === "shared" + ? { + signInHint: + "Sign in to this account in the source plugin’s settings, then reload usage.", + expiredHint: + "This account’s session expired. Sign in again in the source plugin’s settings, then reload usage.", + } + : {}), id: `${pluginId}:${resource.id}`, accountLabel: resource.scope.kind === "shared" ? resource.usage.accountEmail : null, @@ -196,7 +204,18 @@ async function loadMachineUsage( error: sources.status === "rejected" ? "Usage sources could not be discovered." - : null, + : results.some( + (source) => + source.error !== null && + (source.resources.length === 0 || + source.resources.some( + (resource) => + resource.scope.kind === "host" && + resource.scope.hostId === host.id, + )), + ) + ? "Some usage could not be refreshed." + : null, }; } @@ -304,10 +323,13 @@ export default function providerUsagePlugin(bb: BbPluginApi): void { sourceResults.find( (entry) => entry.pluginId === source.pluginId, )?.label ?? null, - resources: [], + resources: + sourceResults.find( + (entry) => entry.pluginId === source.pluginId, + )?.resources ?? [], error: "Usage could not be loaded from " + - source.pluginId + + (source.displayName ?? source.pluginId) + ".", }; } @@ -371,8 +393,8 @@ export default function providerUsagePlugin(bb: BbPluginApi): void { ); if ( shared.length === 0 && - source.resources.length > 0 && - source.error === null + source.label === null && + (source.resources.length > 0 || source.error === null) ) continue; machines.push({ diff --git a/plugins/provider-usage/usage-schema.ts b/plugins/provider-usage/usage-schema.ts index e796395c84..7cc24d3112 100644 --- a/plugins/provider-usage/usage-schema.ts +++ b/plugins/provider-usage/usage-schema.ts @@ -90,7 +90,9 @@ export function selectUsageMachine( return ( machines.find((machine) => machine.id === requestedId) ?? machines.find( - (machine) => machine.id.startsWith("source:") && machine.error === null, + (machine) => + machine.id.startsWith("source:") && + (machine.error === null || machine.providers.length > 0), ) ?? machines.find((machine) => machine.id === threadMachineId) ?? machines.find((machine) => machine.status === "connected") ?? diff --git a/plugins/provider-usage/usage-source-contract.ts b/plugins/provider-usage/usage-source-contract.ts index e8fe50e47b..c3cffe3164 100644 --- a/plugins/provider-usage/usage-source-contract.ts +++ b/plugins/provider-usage/usage-source-contract.ts @@ -48,7 +48,7 @@ export const usageSnapshotSchema = z.object({ .min(1) .optional() .describe( - "Optional label for the shared-usage group. Defaults to the source plugin's display name; host groups use machine names.", + "Declares a shared-usage group, including when resources is empty. Omit for host-only sources. Shared resources without this label use the plugin display name; host groups use machine names.", ), resources: z.array( z.object({ From 8c1b86c1bfedb8ab9ae541160adce5f03713f2a2 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 11 Sep 2026 09:26:28 -0700 Subject: [PATCH 17/53] Split usage inventory from targeted resource measurement --- .../settings/UsageLimitsSettingsSection.tsx | 82 ++- .../UsageSourcesSettingsSection.test.tsx | 96 ++- .../src/hooks/queries/usage-source-queries.ts | 67 +- apps/app/src/lib/usage-source-contract.ts | 72 +- ...iscoverable-rpc-and-provider-usage-plan.md | 60 +- .../src/templates/bb-guide-plugins.md | 2 +- .../references/accounts-and-routing.md | 2 +- plugins/account-pool/src/server.test.ts | 53 +- plugins/account-pool/src/usage-contract.ts | 87 ++- plugins/account-pool/src/usage-source.ts | 207 +++--- .../skills/bb-cli/references/plugins.md | 2 +- .../skills/claude-code-provider/SKILL.md | 2 +- .../src/usage-contract.ts | 87 ++- .../src/usage-source.test.ts | 48 +- .../provider-claude-code/src/usage-source.ts | 220 +++--- .../skills/codex-provider/SKILL.md | 2 +- plugins/provider-codex/src/usage-contract.ts | 87 ++- .../provider-codex/src/usage-source.test.ts | 48 +- plugins/provider-codex/src/usage-source.ts | 220 +++--- plugins/provider-usage/README.md | 8 +- plugins/provider-usage/app.test.tsx | 3 + plugins/provider-usage/app.tsx | 45 +- plugins/provider-usage/server.test.ts | 625 ++++++------------ plugins/provider-usage/server.ts | 471 +++++-------- .../provider-usage/usage-source-contract.ts | 88 +-- 25 files changed, 1339 insertions(+), 1345 deletions(-) diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx index 94d9509b97..d328111c98 100644 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx +++ b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx @@ -1,5 +1,11 @@ -import { useUsageSources } from "@/hooks/queries/usage-source-queries"; -import type { UsageSnapshot } from "@/lib/usage-source-contract"; +import { + useUsageSources, + useUsageMeasurements, +} from "@/hooks/queries/usage-source-queries"; +import type { + UsageResourceList, + UsageMeasurement, +} from "@/lib/usage-source-contract"; import { useId, useState } from "react"; import type { ProviderInfo } from "@bb/domain"; import type { @@ -114,7 +120,9 @@ export interface UsageLimitsSettingsSectionContentProps { emptySourceMessage?: string; resources?: Array<{ key: string; - resource: UsageSnapshot["resources"][number]; + isError?: boolean; + resource: UsageResourceList["resources"][number] & + Partial; }>; usage: ProviderUsageResponse; isLoading: boolean; @@ -273,8 +281,7 @@ function ProviderUsageBody({ if (isError) { return (

- Couldn't load usage right now. Make sure the selected machine is - connected, then reload usage. + Couldn't load usage right now. Try reloading usage.

); } @@ -415,7 +422,7 @@ export function UsageLimitsSettingsSectionContent({

{emptyMessage}

) ) : ( - resources.map(({ key, resource }) => { + resources.map(({ key, resource, isError: resourceIsError }) => { const config = providerConfig( resource.providerId, providerById.get(resource.providerId), @@ -427,7 +434,7 @@ export function UsageLimitsSettingsSectionContent({ resource.scope.kind === "shared" ? { ...config, - name: resource.usage.accountEmail ?? config.name, + name: resource.usage?.accountEmail ?? resource.label, signInHint: "Sign in to this account in the source plugin’s settings, then reload usage.", expiredHint: @@ -436,7 +443,7 @@ export function UsageLimitsSettingsSectionContent({ : config } usage={ - resource.usage.status === "ok" + resource.usage?.status === "ok" ? { ...resource.usage, windows: resource.usage.windows.map( @@ -446,8 +453,10 @@ export function UsageLimitsSettingsSectionContent({ } : resource.usage } - isLoading={false} - isError={false} + isLoading={isLoading || isFetching} + isError={ + resourceIsError === true && resource.usage === undefined + } /> ); }) @@ -537,19 +546,18 @@ export function UsageLimitsSettingsSection() { resource.scope.hostId === selectedLocation?.id, ), ); - const resources = selectedSources + const listedResources = selectedSources .flatMap(({ source, query }) => (query.data?.resources ?? []) - .filter( - (resource) => - resource.usage.status !== "not_installed" && - (selectedLocation?.kind === "source" - ? resource.scope.kind === "shared" - : resource.scope.kind === "host" && - resource.scope.hostId === selectedLocation?.id), + .filter((resource) => + selectedLocation?.kind === "source" + ? resource.scope.kind === "shared" + : resource.scope.kind === "host" && + resource.scope.hostId === selectedLocation?.id, ) .map((resource) => ({ key: `${source.pluginId}:${resource.id}`, + pluginId: source.pluginId, resource, })), ) @@ -561,6 +569,25 @@ export function UsageLimitsSettingsSection() { return rank(a.resource.providerId) - rank(b.resource.providerId); }); + const measurements = useUsageMeasurements( + listedResources + .filter( + ({ resource }) => + (selectedLocationId !== null || + selectedLocation?.kind === "source" || + usageQuery.sources.every(({ query }) => !query.isPending)) && + (resource.scope.kind === "shared" || !selectedLocation?.disabled), + ) + .map(({ pluginId, resource }) => ({ pluginId, resourceId: resource.id })), + ); + const resources = listedResources + .map((entry, index) => ({ + ...entry, + isError: measurements.queries[index]?.isError ?? false, + resource: { ...entry.resource, ...measurements.queries[index]?.data }, + })) + .filter(({ resource }) => resource.usage?.status !== "not_installed"); + return ( query.isError && !query.data, ) ? "Some usage sources couldn’t be loaded. Try reloading usage." - : selectedSources.some(({ query }) => query.isError) - ? "Couldn’t refresh usage. Showing the last update. Try reloading usage." + : selectedSources.some(({ query }) => query.isError) || + measurements.queries.some((query) => query.isError) + ? measurements.queries.some( + (query) => query.isError && !query.data, + ) + ? "Some usage couldn’t be loaded. Try reloading usage." + : "Couldn’t refresh usage. Showing the last update. Try reloading usage." : null } isLoading={ usageQuery.discovery.isPending || - selectedSources.some(({ query }) => query.isPending) + selectedSources.some(({ query }) => query.isPending) || + measurements.queries.some((query) => query.isPending) } isError={ usageQuery.discovery.isError || - selectedSources.some(({ query }) => query.isError) + selectedSources.some(({ query }) => query.isError) || + measurements.queries.some((query) => query.isError) } isProviderListLoading={providersQuery.isLoading} isProviderListError={providersQuery.isError} - isFetching={usageQuery.isFetching} + isFetching={usageQuery.isFetching || measurements.isFetching} onRefresh={() => { - void usageQuery.refresh(); + void usageQuery.refresh().then(() => measurements.refresh()); }} providers={providers} locations={locations} diff --git a/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx b/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx index b02d77d189..4b0090b79f 100644 --- a/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx +++ b/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx @@ -41,9 +41,9 @@ it("selects pooled or machine usage without mixing sources, preserves cards, and { pluginId: "local", displayName: "Codex provider" }, { pluginId: "broken", displayName: "Unavailable provider" }, ]); - calls.rpc.mockImplementation(async ({ pluginId }) => { + calls.rpc.mockImplementation(async ({ pluginId, method }) => { if (pluginId === "broken") throw new Error("Unavailable"); - return { + const snapshot = { ...(pluginId === "pool" ? { label: "Account Pooler" } : {}), resources: [ { @@ -73,6 +73,12 @@ it("selects pooled or machine usage without mixing sources, preserves cards, and }, ], }; + return method.endsWith("listResources") + ? snapshot + : { + observedAt: snapshot.resources[0]!.observedAt, + usage: snapshot.resources[0]!.usage, + }; }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -108,9 +114,9 @@ it("selects pooled or machine usage without mixing sources, preserves cards, and await waitFor(() => expect(calls.rpc).toHaveBeenCalledWith( expect.objectContaining({ - pluginId: "pool", - method: "provider-usage.v1.get", - input: { refresh: true }, + pluginId: "local", + method: "provider-usage.v1.getResource", + input: { resourceId: "same-local-id", refresh: true }, }), ), ); @@ -192,7 +198,14 @@ it("keeps successful measurements visible after a failed refresh and recovers", }, ], }; - calls.rpc.mockResolvedValue(snapshot); + calls.rpc.mockImplementation(async ({ method }) => + method.endsWith("listResources") + ? snapshot + : { + observedAt: snapshot.resources[0]!.observedAt, + usage: snapshot.resources[0]!.usage, + }, + ); const client = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); @@ -210,7 +223,14 @@ it("keeps successful measurements visible after a failed refresh and recovers", expect(await screen.findByText(/Showing the last update/)).toBeTruthy(); expect(screen.getByText("42% used")).toBeTruthy(); expect(screen.queryByText(/Unexpected token/)).toBeNull(); - calls.rpc.mockResolvedValue(snapshot); + calls.rpc.mockImplementation(async ({ method }) => + method.endsWith("listResources") + ? snapshot + : { + observedAt: snapshot.resources[0]!.observedAt, + usage: snapshot.resources[0]!.usage, + }, + ); fireEvent.click(screen.getByLabelText("Reload usage data")); await waitFor(() => expect(screen.queryByText(/Showing the last update/)).toBeNull(), @@ -219,3 +239,65 @@ it("keeps successful measurements visible after a failed refresh and recovers", client.clear(); } }); + +it("waits for the default shared inventory before fetching a fallback machine", async () => { + let release!: (value: { label: string; resources: never[] }) => void; + const pool = new Promise<{ label: string; resources: never[] }>((resolve) => { + release = resolve; + }); + calls.discover.mockResolvedValue([ + { pluginId: "pool", displayName: "Pool" }, + { pluginId: "local", displayName: "Local" }, + ]); + calls.rpc.mockImplementation(async ({ pluginId, method }) => { + if (!method.endsWith("listResources")) + throw new Error("Should not collect any quota for an empty pool"); + return pluginId === "pool" + ? pool + : { + resources: [ + { + id: "host-a", + providerId: "codex", + label: "Codex", + scope: { + kind: "host", + hostId: "host-a", + hostName: "Build machine", + }, + }, + ], + }; + }); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + try { + render( + + + + + , + ); + await waitFor(() => + expect(calls.rpc).toHaveBeenCalledWith( + expect.objectContaining({ + pluginId: "local", + method: "provider-usage.v1.listResources", + }), + ), + ); + release({ label: "Pool", resources: [] }); + expect( + await screen.findByText(/No accounts report usage yet/), + ).toBeTruthy(); + expect( + calls.rpc.mock.calls.every(([args]) => + args.method.endsWith("listResources"), + ), + ).toBe(true); + } finally { + client.clear(); + } +}); diff --git a/apps/app/src/hooks/queries/usage-source-queries.ts b/apps/app/src/hooks/queries/usage-source-queries.ts index caa879c752..8c39bb54fa 100644 --- a/apps/app/src/hooks/queries/usage-source-queries.ts +++ b/apps/app/src/hooks/queries/usage-source-queries.ts @@ -1,16 +1,23 @@ import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { sdk } from "@/lib/sdk"; -import { usageSnapshotSchema } from "@/lib/usage-source-contract"; +import { + usageListMethod, + usageFetchMethod, + usageResourceListSchema, + usageMeasurementSchema, +} from "@/lib/usage-source-contract"; -const method = "provider-usage.v1.get"; -const discoveryKey = ["pluginRpcDiscovery", method] as const; +const discoveryKey = ["pluginRpcDiscovery", usageListMethod] as const; const sourceKey = (pluginId: string) => - ["pluginUsageSource", pluginId, method] as const; + ["pluginUsageInventory", pluginId] as const; +const resourceKey = (pluginId: string, resourceId: string) => + ["pluginUsageMeasurement", pluginId, resourceId] as const; let active = 0; const waiting: Array<() => void> = []; -async function loadSource( +async function loadResource( pluginId: string, + resourceId: string, refresh: boolean, signal: AbortSignal, ) { @@ -20,9 +27,9 @@ async function loadSource( signal.throwIfAborted(); return await sdk.plugins.callRpc({ pluginId, - method, - input: { refresh }, - outputSchema: usageSnapshotSchema, + method: usageFetchMethod, + input: { resourceId, refresh }, + outputSchema: usageMeasurementSchema, signal: AbortSignal.any([signal, AbortSignal.timeout(45_000)]), }); } finally { @@ -33,10 +40,10 @@ async function loadSource( } export function useUsageSources() { - const client = useQueryClient(); const discovery = useQuery({ queryKey: discoveryKey, - queryFn: () => sdk.plugins.experimental_discoverRpc({ method }), + queryFn: () => + sdk.plugins.experimental_discoverRpc({ method: usageListMethod }), staleTime: 10_000, refetchInterval: 30_000, }); @@ -45,8 +52,15 @@ export function useUsageSources() { queries: sources.map((source) => ({ queryKey: sourceKey(source.pluginId), queryFn: ({ signal }: { signal: AbortSignal }) => - loadSource(source.pluginId, false, signal), + sdk.plugins.callRpc({ + pluginId: source.pluginId, + method: usageListMethod, + input: {}, + outputSchema: usageResourceListSchema, + signal: AbortSignal.any([signal, AbortSignal.timeout(45_000)]), + }), staleTime: 30_000, + refetchInterval: 30_000, retry: false, })), }); @@ -59,12 +73,35 @@ export function useUsageSources() { isFetching: discovery.isFetching || queries.some((query) => query.isFetching), async refresh() { - const result = await discovery.refetch(); + await discovery.refetch(); + await Promise.allSettled(queries.map((query) => query.refetch())); + }, + }; +} + +export function useUsageMeasurements( + resources: Array<{ pluginId: string; resourceId: string }>, +) { + const client = useQueryClient(); + const queries = useQueries({ + queries: resources.map(({ pluginId, resourceId }) => ({ + queryKey: resourceKey(pluginId, resourceId), + queryFn: ({ signal }: { signal: AbortSignal }) => + loadResource(pluginId, resourceId, false, signal), + staleTime: 30_000, + retry: false, + })), + }); + return { + queries, + isFetching: queries.some((query) => query.isFetching), + async refresh() { await Promise.allSettled( - (result.data ?? []).map((source) => + resources.map(({ pluginId, resourceId }) => client.fetchQuery({ - queryKey: sourceKey(source.pluginId), - queryFn: ({ signal }) => loadSource(source.pluginId, true, signal), + queryKey: resourceKey(pluginId, resourceId), + queryFn: ({ signal }) => + loadResource(pluginId, resourceId, true, signal), staleTime: 0, }), ), diff --git a/apps/app/src/lib/usage-source-contract.ts b/apps/app/src/lib/usage-source-contract.ts index edd1d8b1e3..154cc5956a 100644 --- a/apps/app/src/lib/usage-source-contract.ts +++ b/apps/app/src/lib/usage-source-contract.ts @@ -41,47 +41,55 @@ const usageSchema = z.discriminatedUnion("status", [ message: z.string(), }), ]); -export const usageSnapshotSchema = z.object({ +export const usageResourceSchema = z.object({ + id: z + .string() + .min(1) + .describe("Stable resource ID within this source plugin."), + providerId: z.string().min(1), + label: z.string().min(1), + scope: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("shared") }), + z.object({ + kind: z.literal("host"), + hostId: z.string().min(1), + hostName: z.string().min(1), + }), + ]), +}); +export const usageResourceListSchema = z.object({ label: z .string() .min(1) .optional() .describe( - "Declares a shared-usage group, including when resources is empty. Omit for host-only sources. Shared resources without this label use the plugin display name; host groups use machine names.", + "Declares a shared group even when empty. Host-only sources omit it; machine groups use host names.", ), - resources: z.array( - z.object({ - id: z - .string() - .min(1) - .describe("Stable resource ID within the reporting plugin."), - providerId: z.string().min(1), - label: z.string().min(1), - scope: z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("shared") }), - z.object({ - kind: z.literal("host"), - hostId: z.string().min(1), - hostName: z.string().min(1), - }), - ]), - observedAt: z - .number() - .int() - .nonnegative() - .nullable() - .describe( - "Measurement time in epoch milliseconds; null if never observed.", - ), - usage: usageSchema, - }), - ), + resources: z.array(usageResourceSchema), +}); +export const usageMeasurementSchema = z.object({ + observedAt: z + .number() + .int() + .nonnegative() + .nullable() + .describe( + "Last successful measurement time in epoch milliseconds; null if never observed.", + ), + usage: usageSchema, }); -export type UsageSnapshot = z.infer; -export const usageInputSchema = z.object({ +export const usageListInputSchema = z.object({}); +export const usageFetchInputSchema = z.object({ + resourceId: z.string().min(1), refresh: z .boolean() .describe( - "Request fresh collection and wait for the attempt; false permits cached observations.", + "False permits a cached measurement but still returns actual usage. True requests a fresh collection attempt for this resource only.", ), }); +export type UsageResourceList = z.infer; +export type UsageMeasurement = z.infer; +export type UsageResource = z.infer & + UsageMeasurement; +export const usageListMethod = "provider-usage.v1.listResources"; +export const usageFetchMethod = "provider-usage.v1.getResource"; diff --git a/docs/discoverable-rpc-and-provider-usage-plan.md b/docs/discoverable-rpc-and-provider-usage-plan.md index bbfb0ee6ba..3c16a13250 100644 --- a/docs/discoverable-rpc-and-provider-usage-plan.md +++ b/docs/discoverable-rpc-and-provider-usage-plan.md @@ -4,13 +4,12 @@ Status: prototype implemented for discoverable RPC, Account Pooler, Codex, Claud ## Prototype verification -- Relevant typechecks passed across the server, app, CLI, SDK, Plugin SDK, and three source plugins (13 Turbo tasks). -- Focused tests passed: 3 RPC publication tests, 10 server SDK tests, 1 SDK discovery/call test, 2 provider-source tests, 141 Account Pooler server tests, and 13 settings usage tests. -- The isolated dev server advertises all three implementations with registration/method descriptions and JSON Schemas. CLI inspection and invocation passed. -- Desktop and 390-pixel mobile browser checks passed, including refresh and no horizontal overflow. The fresh dev store has no pooled accounts; live host usage and authentication-error states were exercised. -- The prototype trusts the Standard JSON Schema exporter for semantic fidelity; exhaustive refinement/transform fidelity auditing remains a stabilization task. `bb settings usage` remains on its previous collection path. Provider Usage uses discovery through its existing `getUsage` display RPC; call it with `bb plugin rpc call provider-usage getUsage`. Shared sources appear once in the existing sidebar picker and are selected by default; explicit machine choices win. The sidebar groups accounts under provider tabs with provider branding and compact usage rows. Source adapters preserve account labels and normalize window and plan names. -- The repository verification inventory reports an existing unmapped `browser` CLI family; this prototype does not rewrite that unrelated baseline. - +- Relevant typechecks pass for the app and all four plugins. Current focused/full package suites pass: 16 settings tests, 10 Provider Usage tests, 276 Account Pooler tests, 269 Codex tests, and 349 Claude Code tests. +- Source tests prove that listing does not collect quota and fetching addresses one resource, including cached reads, forced reads, offline hosts, and removed resource IDs. +- Display tests prove inventory-only discovery, selected provider/account fetching, cached failure preservation, empty groups, resource removal, and tab/source changes. Settings waits for default shared-source discovery before fetching a fallback host. +- Live CLI discovery advertises both methods from all three sources. Browser request traces show only pool Codex on first open, Claude on tab selection, and four selected pool resources on settings. Existing configured accounts were retained. +- `pnpm start:worktree` serves the review instance. Provider Usage background reconciliation lists metadata only; its open card refreshes only the active provider’s resources. +- JSON Schema exporter fidelity remains an experimental stabilization audit. The unrelated verification inventory still reports an unmapped `browser` CLI family. ## Outcome @@ -26,16 +25,22 @@ Keep the existing `defineRpcContract` shape, method addressing, and calls. Add o ```ts const usageContract = defineRpcContract({ - "provider-usage.v1.get": { + "provider-usage.v1.listResources": { experimental_description: - "Returns a complete usage snapshot. refresh=true requests fresh collection and waits for the attempt; individual resource failures are included in the result.", - input: usageInputSchema, - output: usageSnapshotSchema, + "Cheap ordered resource inventory; reads local metadata only and never collects quota.", + input: usageListInputSchema, + output: usageResourceListSchema, + }, + "provider-usage.v1.getResource": { + input: usageFetchInputSchema, + output: usageMeasurementSchema, + experimental_description: "Fetch one resource’s actual usage; refresh=false permits cache, refresh=true requests a fresh attempt for this resource only.", }, }); bb.rpc.register(usageContract, { - "provider-usage.v1.get": getUsage, + "provider-usage.v1.listResources": listResources, + "provider-usage.v1.getResource": getResource, }, { experimental_discoverable: true, experimental_description: @@ -51,7 +56,7 @@ Add a proposed SDK query: ```ts const sources = await bb.sdk.plugins.experimental_discoverRpc({ - method: "provider-usage.v1.get", + method: "provider-usage.v1.listResources", }); ``` @@ -61,10 +66,10 @@ Support optional `pluginId` and exact `method` filters; omitting both lists publ { pluginId: "account-pool", displayName: "Account Pooler", - method: "provider-usage.v1.get", + method: "provider-usage.v1.listResources", registrationDescription: "Usage windows for accounts managed by Account Pooler.", methodDescription: - "Returns a complete usage snapshot. refresh=true requests fresh collection and waits for the attempt; individual resource failures are included in the result.", + "Cheap ordered resource inventory; reads local metadata only and never collects quota.", inputSchema: publishedInputJsonSchema, outputSchema: publishedOutputJsonSchema, } @@ -80,9 +85,9 @@ Consumers retain their own expected schemas and use the existing call API: const results = await Promise.allSettled( sources.map((source) => bb.sdk.plugins.callRpc({ pluginId: source.pluginId, - method: "provider-usage.v1.get", - input: { refresh: false }, - outputSchema: usageSnapshotSchema, + method: "provider-usage.v1.listResources", + input: {}, + outputSchema: usageResourceListSchema, })), ); ``` @@ -111,7 +116,7 @@ Runtime descriptors have size limits and are generated at registration, not on e - Keep duplicate method rejection within a plugin. Different plugins may publish the same method name. - Preserve existing authentication for inspection and calls. Never include credentials, settings values, or handler results in descriptors. - Discovery is a snapshot. A source may unload before invocation; callers handle individual failures. Do not retry arbitrary RPC calls automatically. -- Preserve plugin-and-method addressing. Optional versioning uses names such as `provider-usage.v1.get` and `provider-usage.v2.get`. Both can coexist. +- Preserve plugin-and-method addressing. Optional versioning uses names such as `provider-usage.v1.listResources` and `provider-usage.v2.listResources`. Both can coexist. - A method-name match does not prove compatibility. Breaking schema or behavioral changes require a new method name by convention; compatible changes retain the name. - No runtime dependency on the plugin that originally authored the convention is introduced. @@ -121,9 +126,9 @@ Add discoverable CLI surfaces backed by the same SDK query: ```sh bb plugin rpc list --json -bb plugin rpc list --method provider-usage.v1.get --json +bb plugin rpc list --method provider-usage.v1.listResources --json bb plugin rpc inspect account-pool --json -bb plugin rpc inspect account-pool --method provider-usage.v1.get --json +bb plugin rpc inspect account-pool --method provider-usage.v1.listResources --json ``` Listing presents identities and method names; inspection includes registration descriptions, method descriptions, and published schemas with field descriptions preserved. JSON output is sufficient for copying or generating local schema definitions. TypeScript generation is outside the initial scope because JSON Schema cannot reconstruct arbitrary validator source. @@ -132,15 +137,14 @@ A consumer author inspects a producer's source or CLI output, copies the relevan ## Usage pilot -Use `provider-usage.v1.get` as the shared method. Provider Usage documents the canonical convention in its source; each producer and alternative consumer owns a local definition. Producers publish its calling semantics in the method description and describe their own resource scope in the registration description. - -Request: `{ refresh: boolean }`. +Use two methods defined canonically by Provider Usage and copied locally by each producer and independent consumer: -Response: a complete snapshot of the resources owned by that implementation, plus an optional `label` for the shared-usage group. Presence of this label declares the shared group even when its resources are empty; host-only sources omit it. An empty unlabeled snapshot declares no shared group. For nonempty shared resources, omission falls back to discovery’s plugin display name. The label is independent of the plugin manifest name. Machine groups keep the host name. Each resource includes a stable source-local ID, provider ID, display label, host or shared scope, observation timestamp, and a discriminated collection result. Successful results contain individually identified windows with utilization, reset time, optional cost information, and model applicability. Authentication failures, collection failures, and unobserved data must be explicit states rather than zero usage. Finalize and copy one concrete schema before implementing adapters. +- `provider-usage.v1.listResources({})` returns `{ label?, resources: [{ id, providerId, label, scope }] }`. This is cheap local inventory; it never refreshes usage or contacts providers. The optional label declares an empty shared group. Host-only sources omit it. List order is display order. +- `provider-usage.v1.getResource({ resourceId, refresh })` returns `{ observedAt, usage }` for exactly one listed resource. False permits cached measurements but still returns actual usage. True requests a fresh collection attempt for that resource only. A removed resource fails explicitly, and consumers relist. -Use the same milliseconds-based timestamp convention throughout. An observation timestamp describes the underlying measurement, not the time the RPC was called. If stale values are retained after a collection failure, preserve their original timestamp and expose the failed refresh separately. +The sidebar lists all sources to construct its source picker and provider tabs, then fetches only accounts belonging to the selected provider and source/machine. Background reconciliation lists metadata only. Unopened tabs have unknown usage rather than a fabricated healthy badge; retained measurements may still supply badges. Settings fetches the resources in its selected pool or machine. Both keep independent per-resource caches, bounded collection concurrency, stale-data notices, and graceful failures. -`refresh: false` permits the source's cached measurements. `refresh: true` requests fresh collection and waits for the attempt; collection failures must remain visible. Coalesce overlapping refreshes in sources. The display owns its own fetch cache, but does not relabel cached measurements as freshly observed. +Account Pooler lists account metadata without refreshing, then calls its existing account-specific collection for fetch. Local provider sources list hosts without collecting quota and fetch only the requested host/provider pair. No display plugin is required for source registration or collection. ### Source implementations @@ -160,7 +164,7 @@ An alternative display uses the same discovery query and its own copied response Preserve `bb.sdk.system.usageLimits()` and `bb settings usage --json` as existing host-provider maintenance views during the first rollout. Do not silently change their response shape or use them as the unified view. -Expose Provider Usage's unified display snapshot through its RPC and a plugin-owned CLI command, proposed as `bb provider-usage status [--refresh] [--json]`. Document the distinction. Consumers needing replacement-independent data can discover and call the source methods directly. Provider Usage must stop collecting the same host usage independently once provider plugins supply it through discovery. +Provider Usage exposes its display snapshot through `bb plugin rpc call provider-usage getUsage --input-file --json`. The private display request includes `force`, nullable `machineIds`, nullable `providerId`, and `maxAgeMs`; null providerId lists metadata without collecting usage. Source fetch requests use a JSON file containing `resourceId` and `refresh`. Consumers needing replacement-independent data can discover and call the source methods directly. Provider Usage must stop collecting the same host usage independently once provider plugins supply it through discovery. ## Delivery sequence diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index f7e03fa856..c839e4ad30 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -924,6 +924,6 @@ Modal image debugging: `bb modal image build [--json]` prepares the saved image; ## Inspect plugin RPC -`bb plugin rpc list [--method ] [--json]` lists discoverable methods from running plugins. `bb plugin rpc inspect [--method ] [--json]` dumps registration and method descriptions plus input/output JSON Schemas. Copy the relevant schema into your consumer and call the existing plugin RPC endpoint. Discovery is opt-in advertising, not access control; method names may carry versions such as `provider-usage.v1.get`. +`bb plugin rpc list [--method ] [--json]` lists discoverable methods from running plugins. `bb plugin rpc inspect [--method ] [--json]` dumps registration and method descriptions plus input/output JSON Schemas. Copy the relevant schema into your consumer and call the existing plugin RPC endpoint. Discovery is opt-in advertising, not access control; method names may carry versions such as `provider-usage.v1.listResources`. `bb plugin rpc call [--input-file ] [--json]` invokes a method using server-side schema validation. Omitting the input file sends JSON null. Input files avoid putting sensitive values in command arguments. diff --git a/plugins/account-pool/skills/account-pool/references/accounts-and-routing.md b/plugins/account-pool/skills/account-pool/references/accounts-and-routing.md index bfeb7cef59..0c5c6028be 100644 --- a/plugins/account-pool/skills/account-pool/references/accounts-and-routing.md +++ b/plugins/account-pool/skills/account-pool/references/accounts-and-routing.md @@ -81,4 +81,4 @@ sets an individual priority; the same operations are available through the ## Discoverable usage -This plugin exposes `provider-usage.v1.get` for independent usage displays. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect account-pool --method provider-usage.v1.get --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. +This plugin exposes cheap `provider-usage.v1.listResources` inventory and `provider-usage.v1.getResource` measurements for independent usage displays. Fetch takes `{ resourceId, refresh }` and returns actual usage for that resource even when refresh is false; listing never collects quota. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect account-pool --method provider-usage.v1.listResources --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. diff --git a/plugins/account-pool/src/server.test.ts b/plugins/account-pool/src/server.test.ts index bebe6c06f3..20c6741c01 100644 --- a/plugins/account-pool/src/server.test.ts +++ b/plugins/account-pool/src/server.test.ts @@ -1,4 +1,9 @@ -import { usageSnapshotSchema } from "./usage-contract.js"; +import { + usageMeasurementSchema, + usageResourceListSchema, + usageListMethod, + usageFetchMethod, +} from "./usage-contract.js"; import fs from "node:fs/promises"; import http, { type IncomingMessage, type ServerResponse } from "node:http"; import path from "node:path"; @@ -5820,29 +5825,35 @@ it("publishes pooled usage without a display plugin and does not invent unobserv }); cleanups.push(upstream.close); const fixture = await createFixture({ upstreamUrl: upstream.url }); - const result = usageSnapshotSchema.parse( - await fixture.host.harness.behavior.callRpc("provider-usage.v1.get", { - refresh: false, - }), + const inventory = usageResourceListSchema.parse( + await fixture.host.harness.behavior.callRpc(usageListMethod, {}), ); - expect(result.label).toBe("Account Pooler"); - expect(result.resources).toEqual([ + expect(inventory.label).toBe("Account Pooler"); + expect(inventory.resources).toEqual([ expect.objectContaining({ id: fixture.account.id, providerId: "claude-code", scope: { kind: "shared" }, - observedAt: null, - usage: expect.objectContaining({ - status: "error", - message: "Usage has not been observed for this account.", - }), }), ]); + const result = usageMeasurementSchema.parse( + await fixture.host.harness.behavior.callRpc(usageFetchMethod, { + resourceId: fixture.account.id, + refresh: false, + }), + ); + expect(result).toMatchObject({ + observedAt: null, + usage: { + status: "error", + message: "Usage has not been observed for this account.", + }, + }); expect( fixture.host.harness.registrations.experimental_publishedRpcMethods.map( (entry) => entry.method, ), - ).toEqual(["provider-usage.v1.get"]); + ).toEqual([usageListMethod, usageFetchMethod]); }); it("publishes an empty shared usage group before any accounts or settings are configured", async () => { @@ -5861,12 +5872,16 @@ it("publishes an empty shared usage group before any accounts or settings are co host.harness.registrations.experimental_publishedRpcMethods.map( (entry) => entry.method, ), - ).toContain("provider-usage.v1.get"); - for (const refresh of [false, true]) { - await expect( - host.harness.behavior.callRpc("provider-usage.v1.get", { refresh }), - ).resolves.toEqual({ label: "Account Pooler", resources: [] }); - } + ).toContain(usageListMethod); + await expect( + host.harness.behavior.callRpc(usageListMethod, {}), + ).resolves.toEqual({ label: "Account Pooler", resources: [] }); + await expect( + host.harness.behavior.callRpc(usageFetchMethod, { + resourceId: "removed", + refresh: false, + }), + ).rejects.toThrow("no longer exists"); expect(fetch).not.toHaveBeenCalled(); } finally { await host.harness.lifecycle.dispose(); diff --git a/plugins/account-pool/src/usage-contract.ts b/plugins/account-pool/src/usage-contract.ts index edd1d8b1e3..c5255e5c42 100644 --- a/plugins/account-pool/src/usage-contract.ts +++ b/plugins/account-pool/src/usage-contract.ts @@ -1,3 +1,4 @@ +import { defineRpcContract } from "@get-bb/plugin-sdk"; import { z } from "zod"; const accountFields = { @@ -41,47 +42,69 @@ const usageSchema = z.discriminatedUnion("status", [ message: z.string(), }), ]); -export const usageSnapshotSchema = z.object({ +export const usageResourceSchema = z.object({ + id: z + .string() + .min(1) + .describe("Stable resource ID within this source plugin."), + providerId: z.string().min(1), + label: z.string().min(1), + scope: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("shared") }), + z.object({ + kind: z.literal("host"), + hostId: z.string().min(1), + hostName: z.string().min(1), + }), + ]), +}); +export const usageResourceListSchema = z.object({ label: z .string() .min(1) .optional() .describe( - "Declares a shared-usage group, including when resources is empty. Omit for host-only sources. Shared resources without this label use the plugin display name; host groups use machine names.", + "Declares a shared group even when empty. Host-only sources omit it; machine groups use host names.", ), - resources: z.array( - z.object({ - id: z - .string() - .min(1) - .describe("Stable resource ID within the reporting plugin."), - providerId: z.string().min(1), - label: z.string().min(1), - scope: z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("shared") }), - z.object({ - kind: z.literal("host"), - hostId: z.string().min(1), - hostName: z.string().min(1), - }), - ]), - observedAt: z - .number() - .int() - .nonnegative() - .nullable() - .describe( - "Measurement time in epoch milliseconds; null if never observed.", - ), - usage: usageSchema, - }), - ), + resources: z.array(usageResourceSchema), }); -export type UsageSnapshot = z.infer; -export const usageInputSchema = z.object({ +export const usageMeasurementSchema = z.object({ + observedAt: z + .number() + .int() + .nonnegative() + .nullable() + .describe( + "Last successful measurement time in epoch milliseconds; null if never observed.", + ), + usage: usageSchema, +}); +export const usageListInputSchema = z.object({}); +export const usageFetchInputSchema = z.object({ + resourceId: z.string().min(1), refresh: z .boolean() .describe( - "Request fresh collection and wait for the attempt; false permits cached observations.", + "False permits a cached measurement but still returns actual usage. True requests a fresh collection attempt for this resource only.", ), }); +export type UsageResourceList = z.infer; +export type UsageMeasurement = z.infer; +export type UsageResource = z.infer & + UsageMeasurement; +export const usageListMethod = "provider-usage.v1.listResources"; +export const usageFetchMethod = "provider-usage.v1.getResource"; +export const usageSourceRpcContract = defineRpcContract({ + [usageListMethod]: { + input: usageListInputSchema, + output: usageResourceListSchema, + experimental_description: + "Cheap complete inventory of resources owned by this source. Reads local metadata only; never refreshes quota or contacts providers. IDs are stable and source-local. Resource order is display order. Shared label preserves empty groups. Resources may disappear between list and fetch.", + }, + [usageFetchMethod]: { + input: usageFetchInputSchema, + output: usageMeasurementSchema, + experimental_description: + "Returns actual usage for exactly one listed resource, even when refresh is false. False permits cached observations; true requests a fresh attempt. Never collects other resources as a side effect. A removed resource fails the RPC; consumers relist. Per-account authentication and collection failures are usage states. observedAt is the last successful measurement time.", + }, +}); diff --git a/plugins/account-pool/src/usage-source.ts b/plugins/account-pool/src/usage-source.ts index e9419c98b7..5632552785 100644 --- a/plugins/account-pool/src/usage-source.ts +++ b/plugins/account-pool/src/usage-source.ts @@ -1,10 +1,11 @@ -import { defineRpcContract, type BbPluginApi } from "@get-bb/plugin-sdk"; +import { type BbPluginApi } from "@get-bb/plugin-sdk"; import type { AccountSummary } from "./contracts.js"; import type { AccountPoolHub } from "./hub.js"; import { - usageInputSchema, - usageSnapshotSchema, - type UsageSnapshot, + usageSourceRpcContract, + usageListMethod, + usageFetchMethod, + type UsageResource, } from "./usage-contract.js"; export function usageWindowLabel( @@ -33,105 +34,119 @@ export function usagePlanLabel( export function registerUsageSource(bb: BbPluginApi, hub: AccountPoolHub) { bb.rpc.register( - defineRpcContract({ - "provider-usage.v1.get": { - experimental_description: - "Returns a complete snapshot of pooled account usage across hosts. refresh=true asks the pool to refresh eligible OAuth accounts; busy accounts retain their previous observation time. API key accounts may not expose usage. Unknown measurements are not reported as zero.", - input: usageInputSchema, - output: usageSnapshotSchema, - }, - }), + usageSourceRpcContract, { - async "provider-usage.v1.get"({ refresh }) { - await hub.refreshUsage(undefined, refresh); + async [usageListMethod]() { const { accounts } = await hub.status(); - const resources: UsageSnapshot["resources"] = accounts.map( - (account) => { - const windows: Extract< - UsageSnapshot["resources"][number]["usage"], - { status: "ok" } - >["windows"] = []; - const add = ( - id: string, - label: string, - utilization: number | null, - reset: number | null, - model: string | null, - ) => { - if (utilization === null) return; - windows.push({ - id, - label, - usedPercent: Math.round(utilization * 100), - resetsAt: reset === null ? null : new Date(reset).toISOString(), - model, - cost: null, - }); - }; - if (account.limitWindows.length > 0) { - for (const window of account.limitWindows) { - add( - window.slot, - usageWindowLabel(window.windowMinutes, window.slot), - window.utilization, - window.resetAt, - null, - ); - } - } else { + return { + label: "Account Pooler", + resources: accounts.map((account) => ({ + id: account.id, + providerId: account.provider === "claude" ? "claude-code" : "codex", + label: + account.email ?? + (account.provider === "claude" ? "Claude Code" : "Codex"), + scope: { kind: "shared" as const }, + })), + }; + }, + async [usageFetchMethod]({ resourceId, refresh }) { + if ( + !(await hub.status()).accounts.some( + (account) => account.id === resourceId, + ) + ) + throw new Error("Usage resource no longer exists."); + await hub.refreshUsage(resourceId, refresh); + const { accounts: allAccounts } = await hub.status(); + const accounts = allAccounts.filter( + (account) => account.id === resourceId, + ); + if (accounts.length === 0) + throw new Error("Usage resource no longer exists."); + const resources: UsageResource[] = accounts.map((account) => { + const windows: Extract< + UsageResource["usage"], + { status: "ok" } + >["windows"] = []; + const add = ( + id: string, + label: string, + utilization: number | null, + reset: number | null, + model: string | null, + ) => { + if (utilization === null) return; + windows.push({ + id, + label, + usedPercent: Math.round(utilization * 100), + resetsAt: reset === null ? null : new Date(reset).toISOString(), + model, + cost: null, + }); + }; + if (account.limitWindows.length > 0) { + for (const window of account.limitWindows) { add( - "five-hour", - "Five-hour limit", - account.fiveHourUtilization, - account.fiveHourResetAt, + window.slot, + usageWindowLabel(window.windowMinutes, window.slot), + window.utilization, + window.resetAt, null, ); + } + } else { + add( + "five-hour", + "Five-hour limit", + account.fiveHourUtilization, + account.fiveHourResetAt, + null, + ); + add( + "weekly", + "Weekly limit", + account.sevenDayUtilization, + account.sevenDayResetAt, + null, + ); + } + for (const [family, window] of Object.entries(account.familyWeekly)) { + if (window !== null) add( - "weekly", - "Weekly limit", - account.sevenDayUtilization, - account.sevenDayResetAt, - null, + `weekly:${family}`, + `Weekly · ${family}`, + window.utilization, + window.resetAt, + family, ); - } - for (const [family, window] of Object.entries( - account.familyWeekly, - )) { - if (window !== null) - add( - `weekly:${family}`, - `Weekly · ${family}`, - window.utilization, - window.resetAt, - family, - ); - } - const accountFields = { - accountEmail: account.email, - planLabel: usagePlanLabel(account), - }; - const error = - (account.error === null - ? null - : "Usage could not be collected for this account. Try refreshing usage.") ?? - (account.observedAt === null - ? "Usage has not been observed for this account." - : null); - return { - id: account.id, - providerId: - account.provider === "claude" ? "claude-code" : "codex", - label: account.label, - scope: { kind: "shared" }, - observedAt: account.observedAt, - usage: - error === null - ? { status: "ok", ...accountFields, windows } - : { status: "error", ...accountFields, message: error }, - }; - }, - ); - return { label: "Account Pooler", resources }; + } + const accountFields = { + accountEmail: account.email, + planLabel: usagePlanLabel(account), + }; + const error = + (account.error === null + ? null + : "Usage could not be collected for this account. Try refreshing usage.") ?? + (account.observedAt === null + ? "Usage has not been observed for this account." + : null); + return { + id: account.id, + providerId: account.provider === "claude" ? "claude-code" : "codex", + label: account.label, + scope: { kind: "shared" }, + observedAt: account.observedAt, + usage: + error === null + ? { status: "ok", ...accountFields, windows } + : { status: "error", ...accountFields, message: error }, + }; + }); + const resource = resources[0]!; + return { observedAt: resource.observedAt, usage: resource.usage }; }, }, { diff --git a/plugins/bb-guide/skills/bb-cli/references/plugins.md b/plugins/bb-guide/skills/bb-cli/references/plugins.md index 9109247de5..619a069b84 100644 --- a/plugins/bb-guide/skills/bb-cli/references/plugins.md +++ b/plugins/bb-guide/skills/bb-cli/references/plugins.md @@ -219,6 +219,6 @@ ## Inspect plugin RPC -`bb plugin rpc list [--method ] [--json]` lists discoverable methods from running plugins. `bb plugin rpc inspect [--method ] [--json]` dumps registration and method descriptions plus input/output JSON Schemas. Copy the relevant schema into your consumer and call the existing plugin RPC endpoint. Discovery is opt-in advertising, not access control; method names may carry versions such as `provider-usage.v1.get`. +`bb plugin rpc list [--method ] [--json]` lists discoverable methods from running plugins. `bb plugin rpc inspect [--method ] [--json]` dumps registration and method descriptions plus input/output JSON Schemas. Copy the relevant schema into your consumer and call the existing plugin RPC endpoint. Discovery is opt-in advertising, not access control; method names may carry versions such as `provider-usage.v1.listResources`. `bb plugin rpc call [--input-file ] [--json]` invokes a method using server-side schema validation. Omitting the input file sends JSON null. Input files avoid putting sensitive values in command arguments. diff --git a/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md b/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md index c1a572cbd3..9a10314170 100644 --- a/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md +++ b/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md @@ -21,4 +21,4 @@ threads or change settings merely to answer a question. ## Discoverable usage -This plugin exposes `provider-usage.v1.get` for independent usage displays. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect provider-claude-code --method provider-usage.v1.get --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. +This plugin exposes cheap `provider-usage.v1.listResources` inventory and `provider-usage.v1.getResource` measurements for independent usage displays. Fetch takes `{ resourceId, refresh }` and returns actual usage for that resource even when refresh is false; listing never collects quota. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect provider-claude-code --method provider-usage.v1.listResources --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. diff --git a/plugins/provider-claude-code/src/usage-contract.ts b/plugins/provider-claude-code/src/usage-contract.ts index edd1d8b1e3..c5255e5c42 100644 --- a/plugins/provider-claude-code/src/usage-contract.ts +++ b/plugins/provider-claude-code/src/usage-contract.ts @@ -1,3 +1,4 @@ +import { defineRpcContract } from "@get-bb/plugin-sdk"; import { z } from "zod"; const accountFields = { @@ -41,47 +42,69 @@ const usageSchema = z.discriminatedUnion("status", [ message: z.string(), }), ]); -export const usageSnapshotSchema = z.object({ +export const usageResourceSchema = z.object({ + id: z + .string() + .min(1) + .describe("Stable resource ID within this source plugin."), + providerId: z.string().min(1), + label: z.string().min(1), + scope: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("shared") }), + z.object({ + kind: z.literal("host"), + hostId: z.string().min(1), + hostName: z.string().min(1), + }), + ]), +}); +export const usageResourceListSchema = z.object({ label: z .string() .min(1) .optional() .describe( - "Declares a shared-usage group, including when resources is empty. Omit for host-only sources. Shared resources without this label use the plugin display name; host groups use machine names.", + "Declares a shared group even when empty. Host-only sources omit it; machine groups use host names.", ), - resources: z.array( - z.object({ - id: z - .string() - .min(1) - .describe("Stable resource ID within the reporting plugin."), - providerId: z.string().min(1), - label: z.string().min(1), - scope: z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("shared") }), - z.object({ - kind: z.literal("host"), - hostId: z.string().min(1), - hostName: z.string().min(1), - }), - ]), - observedAt: z - .number() - .int() - .nonnegative() - .nullable() - .describe( - "Measurement time in epoch milliseconds; null if never observed.", - ), - usage: usageSchema, - }), - ), + resources: z.array(usageResourceSchema), }); -export type UsageSnapshot = z.infer; -export const usageInputSchema = z.object({ +export const usageMeasurementSchema = z.object({ + observedAt: z + .number() + .int() + .nonnegative() + .nullable() + .describe( + "Last successful measurement time in epoch milliseconds; null if never observed.", + ), + usage: usageSchema, +}); +export const usageListInputSchema = z.object({}); +export const usageFetchInputSchema = z.object({ + resourceId: z.string().min(1), refresh: z .boolean() .describe( - "Request fresh collection and wait for the attempt; false permits cached observations.", + "False permits a cached measurement but still returns actual usage. True requests a fresh collection attempt for this resource only.", ), }); +export type UsageResourceList = z.infer; +export type UsageMeasurement = z.infer; +export type UsageResource = z.infer & + UsageMeasurement; +export const usageListMethod = "provider-usage.v1.listResources"; +export const usageFetchMethod = "provider-usage.v1.getResource"; +export const usageSourceRpcContract = defineRpcContract({ + [usageListMethod]: { + input: usageListInputSchema, + output: usageResourceListSchema, + experimental_description: + "Cheap complete inventory of resources owned by this source. Reads local metadata only; never refreshes quota or contacts providers. IDs are stable and source-local. Resource order is display order. Shared label preserves empty groups. Resources may disappear between list and fetch.", + }, + [usageFetchMethod]: { + input: usageFetchInputSchema, + output: usageMeasurementSchema, + experimental_description: + "Returns actual usage for exactly one listed resource, even when refresh is false. False permits cached observations; true requests a fresh attempt. Never collects other resources as a side effect. A removed resource fails the RPC; consumers relist. Per-account authentication and collection failures are usage states. observedAt is the last successful measurement time.", + }, +}); diff --git a/plugins/provider-claude-code/src/usage-source.test.ts b/plugins/provider-claude-code/src/usage-source.test.ts index 4c4cf57f25..27202ddba9 100644 --- a/plugins/provider-claude-code/src/usage-source.test.ts +++ b/plugins/provider-claude-code/src/usage-source.test.ts @@ -4,7 +4,12 @@ import { makeHostResponse, } from "@get-bb/plugin-sdk/testing"; import { registerUsageSource } from "./usage-source.js"; -import { usageSnapshotSchema } from "./usage-contract.js"; +import { + usageMeasurementSchema, + usageResourceListSchema, + usageListMethod, + usageFetchMethod, +} from "./usage-contract.js"; it("publishes usage independently of displays and isolates disconnected hosts", async () => { const collect = vi.fn(async () => ({ @@ -28,29 +33,38 @@ it("publishes usage independently of displays and isolates disconnected hosts", }); try { registerUsageSource(bb); - expect( - harness.registrations.experimental_publishedRpcMethods.map( - (item) => item.method, - ), - ).toEqual(["provider-usage.v1.get"]); - const read = async (refresh: boolean) => - usageSnapshotSchema.parse( - await harness.behavior.callRpc("provider-usage.v1.get", { refresh }), + const inventory = usageResourceListSchema.parse( + await harness.behavior.callRpc(usageListMethod, {}), + ); + expect(inventory.resources.map((resource) => resource.id)).toEqual([ + "online", + "offline", + ]); + expect(collect).not.toHaveBeenCalled(); + const read = async (resourceId: string, refresh: boolean) => + usageMeasurementSchema.parse( + await harness.behavior.callRpc(usageFetchMethod, { + resourceId, + refresh, + }), ); - const result = await read(false); - expect(result.resources[0]).toMatchObject({ - providerId: "claude-code", - scope: { kind: "host", hostId: "online" }, + expect(await read("online", false)).toMatchObject({ usage: { status: "ok", windows: [{ usedPercent: 42 }] }, }); - expect(result.resources[1]).toMatchObject({ + expect(collect).toHaveBeenCalledWith({ + hostId: "online", + providerId: "claude-code", + }); + await read("online", false); + expect(collect).toHaveBeenCalledTimes(1); + await read("online", true); + expect(collect).toHaveBeenCalledTimes(2); + expect(await read("offline", false)).toMatchObject({ observedAt: null, usage: { status: "error" }, }); - await read(false); - expect(collect).toHaveBeenCalledTimes(1); - await read(true); expect(collect).toHaveBeenCalledTimes(2); + await expect(read("removed", false)).rejects.toThrow("no longer exists"); } finally { await harness.lifecycle.dispose(); } diff --git a/plugins/provider-claude-code/src/usage-source.ts b/plugins/provider-claude-code/src/usage-source.ts index e1003e2ca2..96906f131b 100644 --- a/plugins/provider-claude-code/src/usage-source.ts +++ b/plugins/provider-claude-code/src/usage-source.ts @@ -1,134 +1,124 @@ -import { defineRpcContract, type BbPluginApi } from "@get-bb/plugin-sdk"; +import type { BbPluginApi } from "@get-bb/plugin-sdk"; import { - usageInputSchema, - usageSnapshotSchema, - type UsageSnapshot, + usageSourceRpcContract, + usageListMethod, + usageFetchMethod, + type UsageResource, } from "./usage-contract.js"; - -const contract = defineRpcContract({ - "provider-usage.v1.get": { - experimental_description: - "Returns local Claude Code account usage for each host. refresh=true requests fresh collection. Host failures are returned individually; observedAt remains the time of the last successful measurement.", - input: usageInputSchema, - output: usageSnapshotSchema, - }, -}); - export function registerUsageSource(bb: BbPluginApi) { - const cache = new Map(); - const pending = new Map< - string, - Promise - >(); + const cache = new Map(); + const pending = new Map>(); bb.rpc.register( - contract, + usageSourceRpcContract, { - async "provider-usage.v1.get"({ refresh }) { + async [usageListMethod]() { const hosts = await bb.sdk.hosts.list(); for (const id of cache.keys()) if (!hosts.some((host) => host.id === id)) cache.delete(id); - const resources: UsageSnapshot["resources"] = []; - for (let offset = 0; offset < hosts.length; offset += 3) { - resources.push( - ...(await Promise.all( - hosts.slice(offset, offset + 3).map(async (host) => { - const previous = cache.get(host.id); - const base = { - id: host.id, - providerId: "claude-code", - label: "Claude Code", - scope: { - kind: "host" as const, - hostId: host.id, - hostName: host.name, - }, - observedAt: previous?.observedAt ?? null, - }; - if (host.status === "disconnected") { - return { - ...base, - usage: { - status: "error" as const, - accountEmail: null, - planLabel: null, - message: "Machine is disconnected.", - }, - }; - } - if ( - !refresh && - previous?.usage.status === "ok" && - previous.observedAt !== null && - Date.now() - previous.observedAt < 60_000 - ) { - return { ...previous, scope: base.scope }; - } - const running = pending.get(host.id); - if (running !== undefined) return running; - const load = (async (): Promise< - UsageSnapshot["resources"][number] - > => { - try { - const result = await bb.sdk.system.usageLimits({ - hostId: host.id, - providerId: "claude-code", - }); - const usage = result["claude-code"]; - if (usage === undefined) - throw new Error( - "Provider returned no usage information.", - ); - const resource = { - ...base, - observedAt: - usage.status === "ok" ? Date.now() : base.observedAt, - usage: - usage.status === "ok" - ? { - ...usage, - windows: usage.windows.map((window, index) => ({ - ...window, - id: `${index}:${window.label}`, - model: null, - cost: window.cost ?? null, - })), - } - : usage.status === "error" - ? usage - : { - status: usage.status, - accountEmail: null, - planLabel: null, - }, - }; - cache.set(host.id, resource); - return resource; - } catch { - return { - ...base, - usage: { - status: "error", + return { + resources: hosts.map((host) => ({ + id: host.id, + providerId: "claude-code", + label: "Claude Code", + scope: { + kind: "host" as const, + hostId: host.id, + hostName: host.name, + }, + })), + }; + }, + async [usageFetchMethod]({ resourceId, refresh }) { + const host = (await bb.sdk.hosts.list()).find( + (host) => host.id === resourceId, + ); + if (!host) throw new Error("Usage resource no longer exists."); + const previous = cache.get(host.id); + const base = { + id: host.id, + providerId: "claude-code", + label: "Claude Code", + scope: { + kind: "host" as const, + hostId: host.id, + hostName: host.name, + }, + observedAt: previous?.observedAt ?? null, + }; + if (host.status === "disconnected") { + return { + ...base, + usage: { + status: "error" as const, + accountEmail: null, + planLabel: null, + message: "Machine is disconnected.", + }, + }; + } + if ( + !refresh && + previous?.usage.status === "ok" && + previous.observedAt !== null && + Date.now() - previous.observedAt < 60_000 + ) { + return { ...previous, scope: base.scope }; + } + const running = pending.get(host.id); + if (running !== undefined) return running; + const load = (async (): Promise => { + try { + const result = await bb.sdk.system.usageLimits({ + hostId: host.id, + providerId: "claude-code", + }); + const usage = result["claude-code"]; + if (usage === undefined) + throw new Error("Provider returned no usage information."); + const resource = { + ...base, + observedAt: usage.status === "ok" ? Date.now() : base.observedAt, + usage: + usage.status === "ok" + ? { + ...usage, + windows: usage.windows.map((window, index) => ({ + ...window, + id: `${index}:${window.label}`, + model: null, + cost: window.cost ?? null, + })), + } + : usage.status === "error" + ? usage + : { + status: usage.status, accountEmail: null, planLabel: null, - message: - "Usage could not be collected from this machine.", }, - }; - } - })().finally(() => pending.delete(host.id)); - pending.set(host.id, load); - return load; - }), - )), - ); - } - return { resources }; + }; + cache.set(host.id, resource); + return resource; + } catch { + return { + ...base, + usage: { + status: "error", + accountEmail: null, + planLabel: null, + message: "Usage could not be collected from this machine.", + }, + }; + } + })().finally(() => pending.delete(host.id)); + pending.set(host.id, load); + return load; }, }, { experimental_discoverable: true, experimental_description: - "Claude Code usage from host-local credentials. Independent of pooled accounts and display plugins.", + "Claude Code usage from host-local credentials. Inventory never collects usage.", }, ); } diff --git a/plugins/provider-codex/skills/codex-provider/SKILL.md b/plugins/provider-codex/skills/codex-provider/SKILL.md index 8c30907c9d..ee27d20d6a 100644 --- a/plugins/provider-codex/skills/codex-provider/SKILL.md +++ b/plugins/provider-codex/skills/codex-provider/SKILL.md @@ -19,4 +19,4 @@ upstream product behavior. ## Discoverable usage -This plugin exposes `provider-usage.v1.get` for independent usage displays. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect provider-codex --method provider-usage.v1.get --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. +This plugin exposes cheap `provider-usage.v1.listResources` inventory and `provider-usage.v1.getResource` measurements for independent usage displays. Fetch takes `{ resourceId, refresh }` and returns actual usage for that resource even when refresh is false; listing never collects quota. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect provider-codex --method provider-usage.v1.listResources --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. diff --git a/plugins/provider-codex/src/usage-contract.ts b/plugins/provider-codex/src/usage-contract.ts index edd1d8b1e3..c5255e5c42 100644 --- a/plugins/provider-codex/src/usage-contract.ts +++ b/plugins/provider-codex/src/usage-contract.ts @@ -1,3 +1,4 @@ +import { defineRpcContract } from "@get-bb/plugin-sdk"; import { z } from "zod"; const accountFields = { @@ -41,47 +42,69 @@ const usageSchema = z.discriminatedUnion("status", [ message: z.string(), }), ]); -export const usageSnapshotSchema = z.object({ +export const usageResourceSchema = z.object({ + id: z + .string() + .min(1) + .describe("Stable resource ID within this source plugin."), + providerId: z.string().min(1), + label: z.string().min(1), + scope: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("shared") }), + z.object({ + kind: z.literal("host"), + hostId: z.string().min(1), + hostName: z.string().min(1), + }), + ]), +}); +export const usageResourceListSchema = z.object({ label: z .string() .min(1) .optional() .describe( - "Declares a shared-usage group, including when resources is empty. Omit for host-only sources. Shared resources without this label use the plugin display name; host groups use machine names.", + "Declares a shared group even when empty. Host-only sources omit it; machine groups use host names.", ), - resources: z.array( - z.object({ - id: z - .string() - .min(1) - .describe("Stable resource ID within the reporting plugin."), - providerId: z.string().min(1), - label: z.string().min(1), - scope: z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("shared") }), - z.object({ - kind: z.literal("host"), - hostId: z.string().min(1), - hostName: z.string().min(1), - }), - ]), - observedAt: z - .number() - .int() - .nonnegative() - .nullable() - .describe( - "Measurement time in epoch milliseconds; null if never observed.", - ), - usage: usageSchema, - }), - ), + resources: z.array(usageResourceSchema), }); -export type UsageSnapshot = z.infer; -export const usageInputSchema = z.object({ +export const usageMeasurementSchema = z.object({ + observedAt: z + .number() + .int() + .nonnegative() + .nullable() + .describe( + "Last successful measurement time in epoch milliseconds; null if never observed.", + ), + usage: usageSchema, +}); +export const usageListInputSchema = z.object({}); +export const usageFetchInputSchema = z.object({ + resourceId: z.string().min(1), refresh: z .boolean() .describe( - "Request fresh collection and wait for the attempt; false permits cached observations.", + "False permits a cached measurement but still returns actual usage. True requests a fresh collection attempt for this resource only.", ), }); +export type UsageResourceList = z.infer; +export type UsageMeasurement = z.infer; +export type UsageResource = z.infer & + UsageMeasurement; +export const usageListMethod = "provider-usage.v1.listResources"; +export const usageFetchMethod = "provider-usage.v1.getResource"; +export const usageSourceRpcContract = defineRpcContract({ + [usageListMethod]: { + input: usageListInputSchema, + output: usageResourceListSchema, + experimental_description: + "Cheap complete inventory of resources owned by this source. Reads local metadata only; never refreshes quota or contacts providers. IDs are stable and source-local. Resource order is display order. Shared label preserves empty groups. Resources may disappear between list and fetch.", + }, + [usageFetchMethod]: { + input: usageFetchInputSchema, + output: usageMeasurementSchema, + experimental_description: + "Returns actual usage for exactly one listed resource, even when refresh is false. False permits cached observations; true requests a fresh attempt. Never collects other resources as a side effect. A removed resource fails the RPC; consumers relist. Per-account authentication and collection failures are usage states. observedAt is the last successful measurement time.", + }, +}); diff --git a/plugins/provider-codex/src/usage-source.test.ts b/plugins/provider-codex/src/usage-source.test.ts index 3b50cad6a3..11c7d9a901 100644 --- a/plugins/provider-codex/src/usage-source.test.ts +++ b/plugins/provider-codex/src/usage-source.test.ts @@ -4,7 +4,12 @@ import { makeHostResponse, } from "@get-bb/plugin-sdk/testing"; import { registerUsageSource } from "./usage-source.js"; -import { usageSnapshotSchema } from "./usage-contract.js"; +import { + usageMeasurementSchema, + usageResourceListSchema, + usageListMethod, + usageFetchMethod, +} from "./usage-contract.js"; it("publishes usage independently of displays and isolates disconnected hosts", async () => { const collect = vi.fn(async () => ({ @@ -28,29 +33,38 @@ it("publishes usage independently of displays and isolates disconnected hosts", }); try { registerUsageSource(bb); - expect( - harness.registrations.experimental_publishedRpcMethods.map( - (item) => item.method, - ), - ).toEqual(["provider-usage.v1.get"]); - const read = async (refresh: boolean) => - usageSnapshotSchema.parse( - await harness.behavior.callRpc("provider-usage.v1.get", { refresh }), + const inventory = usageResourceListSchema.parse( + await harness.behavior.callRpc(usageListMethod, {}), + ); + expect(inventory.resources.map((resource) => resource.id)).toEqual([ + "online", + "offline", + ]); + expect(collect).not.toHaveBeenCalled(); + const read = async (resourceId: string, refresh: boolean) => + usageMeasurementSchema.parse( + await harness.behavior.callRpc(usageFetchMethod, { + resourceId, + refresh, + }), ); - const result = await read(false); - expect(result.resources[0]).toMatchObject({ - providerId: "codex", - scope: { kind: "host", hostId: "online" }, + expect(await read("online", false)).toMatchObject({ usage: { status: "ok", windows: [{ usedPercent: 42 }] }, }); - expect(result.resources[1]).toMatchObject({ + expect(collect).toHaveBeenCalledWith({ + hostId: "online", + providerId: "codex", + }); + await read("online", false); + expect(collect).toHaveBeenCalledTimes(1); + await read("online", true); + expect(collect).toHaveBeenCalledTimes(2); + expect(await read("offline", false)).toMatchObject({ observedAt: null, usage: { status: "error" }, }); - await read(false); - expect(collect).toHaveBeenCalledTimes(1); - await read(true); expect(collect).toHaveBeenCalledTimes(2); + await expect(read("removed", false)).rejects.toThrow("no longer exists"); } finally { await harness.lifecycle.dispose(); } diff --git a/plugins/provider-codex/src/usage-source.ts b/plugins/provider-codex/src/usage-source.ts index 09e1c6aba9..b655e0e17d 100644 --- a/plugins/provider-codex/src/usage-source.ts +++ b/plugins/provider-codex/src/usage-source.ts @@ -1,134 +1,124 @@ -import { defineRpcContract, type BbPluginApi } from "@get-bb/plugin-sdk"; +import type { BbPluginApi } from "@get-bb/plugin-sdk"; import { - usageInputSchema, - usageSnapshotSchema, - type UsageSnapshot, + usageSourceRpcContract, + usageListMethod, + usageFetchMethod, + type UsageResource, } from "./usage-contract.js"; - -const contract = defineRpcContract({ - "provider-usage.v1.get": { - experimental_description: - "Returns local Codex account usage for each host. refresh=true requests fresh collection. Host failures are returned individually; observedAt remains the time of the last successful measurement.", - input: usageInputSchema, - output: usageSnapshotSchema, - }, -}); - export function registerUsageSource(bb: BbPluginApi) { - const cache = new Map(); - const pending = new Map< - string, - Promise - >(); + const cache = new Map(); + const pending = new Map>(); bb.rpc.register( - contract, + usageSourceRpcContract, { - async "provider-usage.v1.get"({ refresh }) { + async [usageListMethod]() { const hosts = await bb.sdk.hosts.list(); for (const id of cache.keys()) if (!hosts.some((host) => host.id === id)) cache.delete(id); - const resources: UsageSnapshot["resources"] = []; - for (let offset = 0; offset < hosts.length; offset += 3) { - resources.push( - ...(await Promise.all( - hosts.slice(offset, offset + 3).map(async (host) => { - const previous = cache.get(host.id); - const base = { - id: host.id, - providerId: "codex", - label: "Codex", - scope: { - kind: "host" as const, - hostId: host.id, - hostName: host.name, - }, - observedAt: previous?.observedAt ?? null, - }; - if (host.status === "disconnected") { - return { - ...base, - usage: { - status: "error" as const, - accountEmail: null, - planLabel: null, - message: "Machine is disconnected.", - }, - }; - } - if ( - !refresh && - previous?.usage.status === "ok" && - previous.observedAt !== null && - Date.now() - previous.observedAt < 60_000 - ) { - return { ...previous, scope: base.scope }; - } - const running = pending.get(host.id); - if (running !== undefined) return running; - const load = (async (): Promise< - UsageSnapshot["resources"][number] - > => { - try { - const result = await bb.sdk.system.usageLimits({ - hostId: host.id, - providerId: "codex", - }); - const usage = result["codex"]; - if (usage === undefined) - throw new Error( - "Provider returned no usage information.", - ); - const resource = { - ...base, - observedAt: - usage.status === "ok" ? Date.now() : base.observedAt, - usage: - usage.status === "ok" - ? { - ...usage, - windows: usage.windows.map((window, index) => ({ - ...window, - id: `${index}:${window.label}`, - model: null, - cost: window.cost ?? null, - })), - } - : usage.status === "error" - ? usage - : { - status: usage.status, - accountEmail: null, - planLabel: null, - }, - }; - cache.set(host.id, resource); - return resource; - } catch { - return { - ...base, - usage: { - status: "error", + return { + resources: hosts.map((host) => ({ + id: host.id, + providerId: "codex", + label: "Codex", + scope: { + kind: "host" as const, + hostId: host.id, + hostName: host.name, + }, + })), + }; + }, + async [usageFetchMethod]({ resourceId, refresh }) { + const host = (await bb.sdk.hosts.list()).find( + (host) => host.id === resourceId, + ); + if (!host) throw new Error("Usage resource no longer exists."); + const previous = cache.get(host.id); + const base = { + id: host.id, + providerId: "codex", + label: "Codex", + scope: { + kind: "host" as const, + hostId: host.id, + hostName: host.name, + }, + observedAt: previous?.observedAt ?? null, + }; + if (host.status === "disconnected") { + return { + ...base, + usage: { + status: "error" as const, + accountEmail: null, + planLabel: null, + message: "Machine is disconnected.", + }, + }; + } + if ( + !refresh && + previous?.usage.status === "ok" && + previous.observedAt !== null && + Date.now() - previous.observedAt < 60_000 + ) { + return { ...previous, scope: base.scope }; + } + const running = pending.get(host.id); + if (running !== undefined) return running; + const load = (async (): Promise => { + try { + const result = await bb.sdk.system.usageLimits({ + hostId: host.id, + providerId: "codex", + }); + const usage = result["codex"]; + if (usage === undefined) + throw new Error("Provider returned no usage information."); + const resource = { + ...base, + observedAt: usage.status === "ok" ? Date.now() : base.observedAt, + usage: + usage.status === "ok" + ? { + ...usage, + windows: usage.windows.map((window, index) => ({ + ...window, + id: `${index}:${window.label}`, + model: null, + cost: window.cost ?? null, + })), + } + : usage.status === "error" + ? usage + : { + status: usage.status, accountEmail: null, planLabel: null, - message: - "Usage could not be collected from this machine.", }, - }; - } - })().finally(() => pending.delete(host.id)); - pending.set(host.id, load); - return load; - }), - )), - ); - } - return { resources }; + }; + cache.set(host.id, resource); + return resource; + } catch { + return { + ...base, + usage: { + status: "error", + accountEmail: null, + planLabel: null, + message: "Usage could not be collected from this machine.", + }, + }; + } + })().finally(() => pending.delete(host.id)); + pending.set(host.id, load); + return load; }, }, { experimental_discoverable: true, experimental_description: - "Codex usage from host-local credentials. Independent of pooled accounts and display plugins.", + "Codex usage from host-local credentials. Inventory never collects usage.", }, ); } diff --git a/plugins/provider-usage/README.md b/plugins/provider-usage/README.md index ef8968c733..ee77b4c97c 100644 --- a/plugins/provider-usage/README.md +++ b/plugins/provider-usage/README.md @@ -2,7 +2,7 @@ Shows usage from enabled usage-source plugins in the sidebar. Provider tabs use provider names and icons, with pooled accounts stacked under each provider. -Shared sources such as Account Pooler are selected by default; an explicit +The card lists account metadata cheaply, then fetches only the selected provider’s accounts. Unopened tabs have no quota badge until measured. Shared sources such as Account Pooler are selected by default; an explicit machine selection shows that machine’s local usage instead. An unconfigured shared source remains selectable and shows setup guidance. @@ -11,11 +11,11 @@ Account authentication failures and plans without reported limits have separate states; unavailable usage is never represented as zero consumption. Settings → Usage limits consumes the same sources independently, using its -existing full-size provider cards. Neither display is required for source +existing full-size provider cards and fetching only resources in the selected pool or machine. Neither display is required for source plugins to publish their usage. -Use `bb plugin rpc list --method provider-usage.v1.get --json` to find sources -and `bb plugin rpc inspect --method provider-usage.v1.get --json` +Use `bb plugin rpc list --method provider-usage.v1.listResources --json` to find sources +and `bb plugin rpc inspect --method provider-usage.v1.listResources --json` to inspect their published contracts. RPC calls accept JSON through `--input-file`. See the Plugin Guide for the contract API. diff --git a/plugins/provider-usage/app.test.tsx b/plugins/provider-usage/app.test.tsx index ad8b5e348e..8d0371c515 100644 --- a/plugins/provider-usage/app.test.tsx +++ b/plugins/provider-usage/app.test.tsx @@ -225,6 +225,7 @@ describe("provider usage footer disclosure", () => { force: false, machineIds: null, maxAgeMs: 30 * 60_000, + providerId: null, }), }), ), @@ -313,6 +314,7 @@ describe("provider usage footer disclosure", () => { force: true, machineIds: ["host-intel"], maxAgeMs: 0, + providerId: null, }), }), ); @@ -331,6 +333,7 @@ describe("provider usage footer disclosure", () => { force: false, machineIds: null, maxAgeMs: 5 * 60_000, + providerId: null, }), }), ); diff --git a/plugins/provider-usage/app.tsx b/plugins/provider-usage/app.tsx index 993fe0f572..2288544209 100644 --- a/plugins/provider-usage/app.tsx +++ b/plugins/provider-usage/app.tsx @@ -91,11 +91,13 @@ function refreshUsage({ force, machineIds, maxAgeMs, + providerId = null, signal, }: { force: boolean; machineIds: string[] | null; maxAgeMs: number; + providerId?: string | null; signal?: AbortSignal; }): Promise { activeRefreshCount += 1; @@ -107,7 +109,7 @@ function refreshUsage({ { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ force, machineIds, maxAgeMs }), + body: JSON.stringify({ force, machineIds, maxAgeMs, providerId }), signal: signal === undefined ? AbortSignal.timeout(60_000) @@ -412,14 +414,27 @@ function ProviderUsageStatus({ null; const panelId = useId(); const activeMachineId = activeMachine?.id ?? null; + const activeProviderId = activeProvider?.id ?? null; useEffect(() => { - void refreshUsage({ - force: false, - machineIds: activeMachineId === null ? null : [activeMachineId], - maxAgeMs: CARD_MAX_AGE_MS, - }); - }, [activeMachineId]); + if (activeMachineId === null || activeProviderId === null) return; + const refresh = () => { + if (document.visibilityState === "hidden") return; + void refreshUsage({ + force: false, + machineIds: [activeMachineId], + providerId: activeProviderId, + maxAgeMs: CARD_MAX_AGE_MS, + }); + }; + refresh(); + const timer = window.setInterval(refresh, CARD_MAX_AGE_MS); + window.addEventListener("focus", refresh); + return () => { + window.clearInterval(timer); + window.removeEventListener("focus", refresh); + }; + }, [activeMachineId, activeProviderId]); const selectMachine = useCallback((machineId: string) => { lastMachineId = machineId; @@ -546,6 +561,7 @@ function ProviderUsageStatus({ force: true, machineIds: activeMachineId === null ? null : [activeMachineId], maxAgeMs: 0, + providerId: activeProvider?.id ?? null, }) } > @@ -639,6 +655,15 @@ function ProviderUsageStatus({ {activeMachine.displayName} is offline. Usage will refresh when it reconnects.

+ ) : account.usage === null && snapshot.isRefreshing ? ( +

+ Loading usage… +

+ ) : account.usage === null && + activeMachine.error !== null ? ( +

+ Couldn’t load this account’s usage. +

) : ( )} @@ -659,7 +684,10 @@ function ProviderUsageStatus({ )} > - {snapshot.data === null || activeMachine?.providers.length === 0 + {snapshot.data === null || + !activeProvider?.accounts.some( + (account) => account.usage !== null, + ) ? "Couldn’t load usage." : "Couldn’t refresh. Showing the last update."} @@ -674,6 +702,7 @@ function ProviderUsageStatus({ machineIds: activeMachineId === null ? null : [activeMachineId], maxAgeMs: 0, + providerId: activeProvider?.id ?? null, }) } > diff --git a/plugins/provider-usage/server.test.ts b/plugins/provider-usage/server.test.ts index 46bbe6f9c8..2887aa2c3b 100644 --- a/plugins/provider-usage/server.test.ts +++ b/plugins/provider-usage/server.test.ts @@ -1,457 +1,250 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { expect, it, vi } from "vitest"; import { createFakePluginHost, - makeThreadResponse, + makeHostResponse, } from "@get-bb/plugin-sdk/testing"; import plugin from "./server.js"; -import { - usageSnapshotSchema, - usageSourceMethod, -} from "./usage-source-contract.js"; - -const discovery = [{ pluginId: "local-source", method: usageSourceMethod }]; - -afterEach(() => { - vi.useRealTimers(); -}); +import { usageListMethod, usageFetchMethod } from "./usage-source-contract.js"; -describe("provider usage backend", () => { - it("loads ordered provider usage independently for every machine", async () => { - const host = createFakePluginHost({ - pluginId: "provider-usage", - sdk: { - hosts: { - list: async () => [ - { - id: "host-m4", - name: "M4", - type: "persistent", - status: "connected", - maxPermissionMode: "full", - lastSeenAt: 1, - lastRejectedProtocolVersion: null, - createdAt: 1, - updatedAt: 1, - }, - { - id: "host-intel", - name: "Intel", - type: "persistent", - status: "disconnected", - maxPermissionMode: "full", - lastSeenAt: 1, - lastRejectedProtocolVersion: null, - createdAt: 1, - updatedAt: 1, - }, - ], - }, - providers: { - list: async () => [ - { - id: "claude-code", - providerId: "claude-code", - accountLabel: null, - displayName: "Claude Code", - logoUrl: "/api/v1/system/providers/claude-code/logo?h=claude", - strings: { - signInHint: "Sign in to Claude Code.", - expiredHint: "Sign in to Claude Code again.", - iconTint: { light: "#D97757", dark: "#E38A6E" }, +it("lists cheaply, fetches only the selected source/provider, preserves failed measurements, and evicts removed resources", async () => { + let enabled = true; + let failure = false; + let hasWork = true; + const rpc = vi.fn(async ({ pluginId, method, input }) => { + if (method === usageListMethod) + return pluginId === "pool" + ? { + label: "Account Pooler", + resources: [ + { + id: "personal", + providerId: "codex", + label: "personal@example.com", + scope: { kind: "shared" }, }, - }, - { - id: "codex", - providerId: "codex", - accountLabel: null, - displayName: "Codex", - logoUrl: "/api/v1/system/providers/codex/logo?h=codex", - }, - ], - }, - plugins: { - experimental_discoverRpc: async () => discovery, - callRpc: async () => - usageSnapshotSchema.parse({ - resources: [ + ...(hasWork + ? [ + { + id: "work", + providerId: "codex", + label: "work@example.com", + scope: { kind: "shared" }, + }, + ] + : []), + { + id: "claude", + providerId: "claude-code", + label: "claude@example.com", + scope: { kind: "shared" }, + }, + ], + } + : { + resources: [ + { + id: "host", + providerId: "codex", + label: "Codex", + scope: { kind: "host", hostId: "host", hostName: "Machine" }, + }, + ], + }; + if (failure) throw new Error("Upstream failed"); + return { + observedAt: 123, + usage: { + status: "ok", + accountEmail: `${input.resourceId}@example.com`, + planLabel: null, + windows: [ + { + id: "week", + label: "Weekly", + usedPercent: 42, + resetsAt: null, + model: null, + cost: null, + }, + ], + }, + }; + }); + const host = createFakePluginHost({ + pluginId: "provider-usage", + sdk: { + hosts: { + list: async () => [ + makeHostResponse({ + id: "host", + name: "Machine", + status: "connected", + }), + ], + }, + providers: { + list: async () => [ + { id: "codex", displayName: "Codex", logoUrl: "/codex.svg" }, + { + id: "claude-code", + displayName: "Claude Code", + logoUrl: "/claude.svg", + }, + ], + }, + plugins: { + experimental_discoverRpc: async () => + enabled + ? [ { - id: "claude-code", - providerId: "claude-code", - label: "Claude Code", - scope: { kind: "host", hostId: "host-m4", hostName: "M4" }, - observedAt: 1, - usage: { - status: "ok", - accountEmail: "dev@example.com", - planLabel: "Max", - windows: [ - { - id: "five-hour", - label: "Five-hour limit", - usedPercent: 82, - resetsAt: "2026-09-02T18:42:00.000Z", - model: null, - cost: null, - }, - ], - }, + pluginId: "pool", + displayName: "Account Pooler", + method: usageListMethod, }, { - id: "codex", - providerId: "codex", - label: "Codex", - scope: { kind: "host", hostId: "host-m4", hostName: "M4" }, - observedAt: null, - usage: { - status: "unauthenticated", - accountEmail: null, - planLabel: null, - }, + pluginId: "local", + displayName: "Codex", + method: usageListMethod, }, - ], - }), - }, + ] + : [], + callRpc: rpc, }, - }); - plugin(host.bb); - - await expect( - host.harness.behavior.callRpc("getUsage", { - force: false, - machineIds: null, - maxAgeMs: 30 * 60_000, - }), - ).resolves.toEqual({ + }, + }); + plugin(host.bb); + const request = { + force: false, + machineIds: null, + providerId: null, + maxAgeMs: 60_000, + }; + try { + const listed = await host.harness.behavior.callRpc("getUsage", request); + expect(listed).toMatchObject({ machines: [ + { id: "host" }, { - id: "host-m4", - displayName: "M4", - status: "connected", - error: null, - providers: [ - { - id: "local-source:claude-code", - providerId: "claude-code", - accountLabel: null, - displayName: "Claude Code", - logoUrl: "/api/v1/system/providers/claude-code/logo?h=claude", - icon: null, - strings: { iconTint: { light: "#D97757", dark: "#E38A6E" } }, - signInHint: "Sign in to Claude Code.", - expiredHint: "Sign in to Claude Code again.", - usage: { - status: "ok", - accountEmail: "dev@example.com", - planLabel: "Max", - windows: [ - { - label: "Five-hour limit", - usedPercent: 82, - resetsAt: "2026-09-02T18:42:00.000Z", - cost: null, - }, - ], - }, - }, - { - id: "local-source:codex", - providerId: "codex", - accountLabel: null, - displayName: "Codex", - logoUrl: "/api/v1/system/providers/codex/logo?h=codex", - icon: null, - strings: { iconTint: null }, - signInHint: "Sign in to Codex, then reload usage.", - expiredHint: - "Your Codex session expired. Sign in again, then reload usage.", - usage: { status: "unauthenticated" }, - }, - ], - }, - { - id: "host-intel", - displayName: "Intel", - status: "disconnected", - error: null, + id: "source:pool", providers: [ - { - id: "claude-code", - providerId: "claude-code", - accountLabel: null, - displayName: "Claude Code", - logoUrl: "/api/v1/system/providers/claude-code/logo?h=claude", - icon: null, - strings: { iconTint: { light: "#D97757", dark: "#E38A6E" } }, - signInHint: "Sign in to Claude Code.", - expiredHint: "Sign in to Claude Code again.", - usage: null, - }, - { - id: "codex", - providerId: "codex", - accountLabel: null, - displayName: "Codex", - logoUrl: "/api/v1/system/providers/codex/logo?h=codex", - icon: null, - strings: { iconTint: null }, - signInHint: "Sign in to Codex, then reload usage.", - expiredHint: - "Your Codex session expired. Sign in again, then reload usage.", - usage: null, - }, + { providerId: "codex", usage: null }, + { providerId: "codex", usage: null }, + { providerId: "claude-code", usage: null }, ], }, ], }); - expect(host.harness.sdk.callsTo("hosts.list")).toEqual([[]]); - expect(host.harness.sdk.callsTo("providers.list")).toEqual([ - [{ hostId: "host-m4", capability: "usage" }], - [{ hostId: "host-intel", capability: "usage" }], - ]); - expect(host.harness.sdk.callsTo("plugins.callRpc")).toHaveLength(1); - expect(host.harness.sdk.callsTo("plugins.callRpc")[0]?.[0]).toMatchObject({ - pluginId: "local-source", - method: usageSourceMethod, - input: { refresh: false }, - }); - - await host.harness.behavior.callRpc("getUsage", { - force: false, - machineIds: null, - maxAgeMs: 30 * 60_000, - }); - expect(host.harness.sdk.callsTo("hosts.list")).toHaveLength(2); - expect(host.harness.sdk.callsTo("providers.list")).toHaveLength(2); - expect(host.harness.sdk.callsTo("plugins.callRpc")).toHaveLength(1); - - await host.harness.behavior.callRpc("getUsage", { - force: true, - machineIds: null, - maxAgeMs: 0, - }); - expect(host.harness.sdk.callsTo("hosts.list")).toHaveLength(3); - expect(host.harness.sdk.callsTo("providers.list")).toHaveLength(4); - expect(host.harness.sdk.callsTo("plugins.callRpc")).toHaveLength(2); - - await host.harness.behavior.callRpc("getUsage", { - force: true, - machineIds: ["host-m4"], - maxAgeMs: 0, - }); - expect(host.harness.sdk.callsTo("providers.list")).toHaveLength(5); - expect(host.harness.sdk.callsTo("plugins.callRpc")).toHaveLength(3); - expect(host.harness.sdk.callsTo("providers.list").at(-1)).toEqual([ - { hostId: "host-m4", capability: "usage" }, - ]); - }); - - it("marks only the affected machine dirty after thread completion", async () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-09-04T12:00:00.000Z")); - const host = createFakePluginHost({ - pluginId: "provider-usage", - sdk: { - hosts: { - list: async () => [ - { id: "host-m4", name: "M4", status: "connected" }, - { id: "host-m5", name: "M5", status: "connected" }, - ], - }, - environments: { - get: async () => ({ hostId: "host-m5" }), - }, - providers: { - list: async () => [], - }, - plugins: { - experimental_discoverRpc: async () => discovery, - callRpc: async () => ({ resources: [] }), - }, - }, - }); - plugin(host.bb); - await host.harness.behavior.callRpc("getUsage", { - force: false, - machineIds: null, - maxAgeMs: 30 * 60_000, - }); - - await host.harness.behavior.emitThreadEvent("thread.idle", { - thread: makeThreadResponse({ environmentId: "environment-m5" }), - lastAssistantText: "done", - }); - await host.harness.behavior.emitThreadEvent("thread.failed", { - thread: makeThreadResponse({ environmentId: "environment-m5" }), - error: "failed", - }); - expect(host.harness.sdk.callsTo("environments.get")).toEqual([ - [{ environmentId: "environment-m5" }], - ]); - expect(host.harness.sdk.callsTo("plugins.callRpc")).toHaveLength(1); - - vi.setSystemTime(new Date("2026-09-04T12:02:00.000Z")); - await host.harness.behavior.callRpc("getUsage", { - force: false, - machineIds: null, - maxAgeMs: 30 * 60_000, - }); - - expect(host.harness.sdk.callsTo("plugins.callRpc")).toHaveLength(2); - expect(host.harness.sdk.callsTo("providers.list")).toEqual([ - [{ hostId: "host-m4", capability: "usage" }], - [{ hostId: "host-m5", capability: "usage" }], - [{ hostId: "host-m5", capability: "usage" }], + expect( + rpc.mock.calls.every(([args]) => args.method === usageListMethod), + ).toBe(true); + const target = { + ...request, + machineIds: ["source:pool"], + providerId: "codex", + }; + await host.harness.behavior.callRpc("getUsage", target); + expect( + rpc.mock.calls + .filter(([args]) => args.method === usageFetchMethod) + .map(([args]) => [ + args.pluginId, + args.input.resourceId, + args.input.refresh, + ]), + ).toEqual([ + ["pool", "personal", false], + ["pool", "work", false], ]); - await host.harness.lifecycle.dispose(); - }); -}); - -describe("usage source composition", () => { - it("keeps shared accounts once, isolates failures, and removes disabled sources", async () => { - let enabled = true; - let failPool = false; - const host = createFakePluginHost({ - pluginId: "provider-usage", - sdk: { - hosts: { list: async () => [] }, - providers: { - list: async () => [ - { id: "codex", displayName: "Codex", logoUrl: "/codex.svg" }, - ], - }, - plugins: { - experimental_discoverRpc: async () => - enabled - ? [ - { pluginId: "pool", method: usageSourceMethod }, - { pluginId: "broken", method: usageSourceMethod }, - ] - : [], - callRpc: async ({ pluginId }) => { - if (pluginId === "broken" || failPool) - throw new Error("Unavailable"); - return usageSnapshotSchema.parse({ - label: "Pool accounts", - resources: [ - { - id: "account-1", - providerId: "codex", - label: "Team account", - scope: { kind: "shared" }, - observedAt: 123, - usage: { - status: "ok", - accountEmail: "team@example.com", - planLabel: null, - windows: [ - { - id: "budget", - label: "Budget", - usedPercent: 120, - resetsAt: null, - model: null, - cost: { usedUsdCents: 1.2, limitUsdCents: 1 }, - }, - ], - }, - }, - ], - }); - }, - }, - }, - }); - plugin(host.bb); - const request = { force: false, machineIds: null, maxAgeMs: 60_000 }; - const snapshot = await host.harness.behavior.callRpc("getUsage", request); - expect(snapshot).toMatchObject({ + await host.harness.behavior.callRpc("getUsage", target); + expect( + rpc.mock.calls.filter(([args]) => args.method === usageFetchMethod), + ).toHaveLength(2); + failure = true; + expect( + await host.harness.behavior.callRpc("getUsage", { + ...target, + force: true, + }), + ).toMatchObject({ machines: [ + { id: "host" }, { id: "source:pool", - displayName: "Pool accounts", + error: "Some usage could not be refreshed.", providers: [ - { - id: "pool:account-1", - accountLabel: "team@example.com", - providerId: "codex", - displayName: "Codex", - logoUrl: "/codex.svg", - usage: { - status: "ok", - windows: [{ usedPercent: 120, cost: { usedUsdCents: 1.2 } }], - }, - }, + { usage: { status: "ok" } }, + { usage: { status: "ok" } }, + { usage: null }, ], }, - { - id: "source:broken", - providers: [], - error: "Usage could not be loaded from broken.", - }, ], }); - await host.harness.behavior.callRpc("getUsage", { - ...request, - force: true, - machineIds: ["source:pool"], - }); - expect(host.harness.sdk.callsTo("plugins.callRpc")[2]?.[0]).toMatchObject({ - input: { refresh: true }, - }); - expect(host.harness.sdk.callsTo("system.usageLimits")).toEqual([]); - failPool = true; - const stale = await host.harness.behavior.callRpc("getUsage", { - ...request, - force: true, - }); - expect(stale).toMatchObject({ + failure = false; + hasWork = false; + expect( + await host.harness.behavior.callRpc("getUsage", { + ...target, + force: true, + }), + ).toMatchObject({ machines: [ + { id: "host" }, { id: "source:pool", - error: "Usage could not be loaded from pool.", - providers: [ - { usage: { status: "ok", windows: [{ usedPercent: 120 }] } }, - ], + error: null, + providers: [{ id: "pool:personal" }, { id: "pool:claude" }], }, - { id: "source:broken" }, ], }); + await host.harness.behavior.callRpc("getUsage", { + ...target, + providerId: "claude-code", + }); + expect( + rpc.mock.calls + .filter(([args]) => args.method === usageFetchMethod) + .at(-1)?.[0].input.resourceId, + ).toBe("claude"); + await host.harness.behavior.callRpc("getUsage", { + ...target, + machineIds: null, + }); + expect( + rpc.mock.calls + .filter(([args]) => args.method === usageFetchMethod) + .at(-1)?.[0].pluginId, + ).toBe("local"); enabled = false; - await expect( - host.harness.behavior.callRpc("getUsage", request), - ).resolves.toEqual({ machines: [] }); + expect( + await host.harness.behavior.callRpc("getUsage", request), + ).toMatchObject({ machines: [{ id: "host", providers: [] }] }); + } finally { await host.harness.lifecycle.dispose(); - }); - it("keeps an unconfigured shared source selectable without inventing groups for empty host sources", async () => { - const host = createFakePluginHost({ - pluginId: "provider-usage", - sdk: { - hosts: { list: async () => [] }, - plugins: { - experimental_discoverRpc: async () => [ - { - pluginId: "pool", - displayName: "Account Pooler [Experimental]", - method: usageSourceMethod, - }, - { - pluginId: "local", - displayName: "Local provider", - method: usageSourceMethod, - }, - ], - callRpc: async ({ pluginId }) => - pluginId === "pool" - ? { label: "Account Pooler", resources: [] } - : { resources: [] }, - }, + } +}); + +it("keeps an unconfigured shared group without hosts or measurement requests", async () => { + const rpc = vi.fn(async () => ({ label: "Account Pooler", resources: [] })); + const host = createFakePluginHost({ + pluginId: "provider-usage", + sdk: { + hosts: { list: async () => [] }, + providers: { list: async () => [] }, + plugins: { + experimental_discoverRpc: async () => [ + { pluginId: "pool", displayName: "Pool", method: usageListMethod }, + ], + callRpc: rpc, }, - }); - plugin(host.bb); + }, + }); + plugin(host.bb); + try { await expect( host.harness.behavior.callRpc("getUsage", { force: false, machineIds: null, + providerId: null, maxAgeMs: 0, }), ).resolves.toEqual({ @@ -465,6 +258,8 @@ describe("usage source composition", () => { }, ], }); + expect(rpc).toHaveBeenCalledTimes(1); + } finally { await host.harness.lifecycle.dispose(); - }); + } }); diff --git a/plugins/provider-usage/server.ts b/plugins/provider-usage/server.ts index 88f1ed0329..5099a10275 100644 --- a/plugins/provider-usage/server.ts +++ b/plugins/provider-usage/server.ts @@ -9,16 +9,18 @@ import { } from "./usage-schema.js"; import { - usageSourceMethod, + usageListMethod, + usageFetchMethod, + type UsageResourceList, + type UsageMeasurement, usageSourceRpcContract, - type UsageSnapshot as SourceSnapshot, + type UsageResource as Resource, } from "./usage-source-contract.js"; -type Resource = SourceSnapshot["resources"][number]; interface SourceResult { pluginId: string; label: string | null; - resources: Resource[]; + resources: UsageResourceList["resources"]; error: string | null; } @@ -30,31 +32,19 @@ export const providerUsageRpcContract = defineRpcContract({ input: z.strictObject({ force: z.boolean(), machineIds: z.nullable(z.array(z.string().check(z.minLength(1)))), + providerId: z.nullable(z.string()), maxAgeMs: z.number().check(z.int(), z.nonnegative()), }), output: usageSnapshotSchema, }, }); -const DIRTY_CACHE_MAX_AGE_MS = 2 * 60_000; - interface UsageRequest { force: boolean; machineIds: string[] | null; maxAgeMs: number; + providerId: string | null; } - -interface MachineCacheEntry { - dirty: boolean; - loadedAt: number; - machine: UsageMachine; -} - -interface PendingMachineUsage { - force: boolean; - promise: Promise; -} - function normalizedTint( tint: { light: string; dark: string } | undefined, ): { light: string; dark: string } | null { @@ -132,7 +122,8 @@ function normalizedProvider( } function resourceProvider( - resource: Resource, + resource: UsageResourceList["resources"][number], + measurement: UsageMeasurement | undefined, pluginId: string, providers: Provider[], ): UsageProvider { @@ -146,7 +137,7 @@ function resourceProvider( displayName: resource.providerId, logoUrl: null, }, - resource.usage, + measurement?.usage, ), ...(resource.scope.kind === "shared" ? { @@ -158,297 +149,189 @@ function resourceProvider( : {}), id: `${pluginId}:${resource.id}`, accountLabel: - resource.scope.kind === "shared" ? resource.usage.accountEmail : null, - }; -} - -async function loadMachineUsage( - bb: BbPluginApi, - host: Host, - readSources: () => Promise, -): Promise { - const [metadata, sources] = await Promise.allSettled([ - bb.sdk.providers.list({ hostId: host.id, capability: "usage" }), - host.status === "disconnected" ? Promise.resolve([]) : readSources(), - ]); - const providers = metadata.status === "fulfilled" ? metadata.value : []; - const results = sources.status === "fulfilled" ? sources.value : []; - const providerOrder = new Map( - providers.map((provider, index) => [provider.id, index]), - ); - const resources = results - .flatMap((source) => - source.resources - .filter( - (resource) => - resource.scope.kind === "host" && resource.scope.hostId === host.id, - ) - .map((resource) => ({ pluginId: source.pluginId, resource })), - ) - .sort( - (left, right) => - (providerOrder.get(left.resource.providerId) ?? providers.length) - - (providerOrder.get(right.resource.providerId) ?? providers.length), - ) - .map(({ pluginId, resource }) => - resourceProvider(resource, pluginId, providers), - ); - return { - id: host.id, - displayName: host.name, - status: host.status, - providers: - host.status === "disconnected" - ? providers.map((provider) => normalizedProvider(provider, undefined)) - : resources, - error: - sources.status === "rejected" - ? "Usage sources could not be discovered." - : results.some( - (source) => - source.error !== null && - (source.resources.length === 0 || - source.resources.some( - (resource) => - resource.scope.kind === "host" && - resource.scope.hostId === host.id, - )), - ) - ? "Some usage could not be refreshed." - : null, + resource.scope.kind === "shared" + ? (measurement?.usage.accountEmail ?? resource.label) + : null, }; } export default function providerUsagePlugin(bb: BbPluginApi): void { - const cache = new Map(); - const pendingByMachine = new Map(); - let sourceResults: SourceResult[] = []; - let sourceLoadedAt = 0; - let sourceSignature = ""; - let sharedDirty = false; - const environmentHosts = new Map(); - - const readMachine = async ( - host: Host, - request: UsageRequest, - targeted: boolean, - readSources: () => Promise, - ): Promise => { - const cached = cache.get(host.id); - const effectiveMaxAgeMs = - cached?.dirty === true - ? Math.min(request.maxAgeMs, DIRTY_CACHE_MAX_AGE_MS) - : request.maxAgeMs; - const hostChanged = - cached !== undefined && - (cached.machine.status !== host.status || - cached.machine.displayName !== host.name); - if ( - cached !== undefined && - (!targeted || - (!request.force && - !hostChanged && - Date.now() - cached.loadedAt < effectiveMaxAgeMs)) - ) { - cached.machine = { - ...cached.machine, - displayName: host.name, - status: host.status, - }; - return cached.machine; + const inventories = new Map(); + const measurements = new Map< + string, + { value: UsageMeasurement; loadedAt: number } + >(); + const failures = new Set(); + const pending = new Map< + string, + { force: boolean; promise: Promise } + >(); + const keyOf = (pluginId: string, resourceId: string) => + JSON.stringify([pluginId, resourceId]); + const fetchResource = async ( + pluginId: string, + resourceId: string, + force: boolean, + ): Promise => { + const key = keyOf(pluginId, resourceId); + const running = pending.get(key); + if (running) { + if (!force || running.force) return running.promise; + await running.promise.catch(() => undefined); + return fetchResource(pluginId, resourceId, force); } - const pending = pendingByMachine.get(host.id); - if (pending !== undefined) { - if (!request.force || pending.force) return pending.promise; - await pending.promise; - return readMachine(host, request, targeted, readSources); - } - const next = loadMachineUsage(bb, host, readSources) - .then((machine) => { - cache.set(host.id, { - dirty: false, - loadedAt: Date.now(), - machine, - }); - return machine; + const promise = bb.sdk.plugins + .callRpc({ + pluginId, + method: usageFetchMethod, + input: { resourceId, refresh: force }, + outputSchema: usageSourceRpcContract[usageFetchMethod].output, + signal: AbortSignal.timeout(45_000), + }) + .then((value) => { + measurements.set(key, { value, loadedAt: Date.now() }); + failures.delete(key); + return value; }) - .finally(() => { - pendingByMachine.delete(host.id); - }); - pendingByMachine.set(host.id, { force: request.force, promise: next }); - return next; + .finally(() => pending.delete(key)); + pending.set(key, { force, promise }); + return promise; }; - const readUsage = async (request: UsageRequest): Promise => { - const [hosts, sources] = await Promise.all([ + const [hosts, sources, providers] = await Promise.all([ bb.sdk.hosts.list(), - bb.sdk.plugins.experimental_discoverRpc({ method: usageSourceMethod }), + bb.sdk.plugins.experimental_discoverRpc({ method: usageListMethod }), + bb.sdk.providers.list({ capability: "usage" }).catch(() => []), ]); - const signature = JSON.stringify(sources); - if (signature !== sourceSignature) { - cache.clear(); - sourceResults = []; - sourceLoadedAt = 0; - sourceSignature = signature; - } - let pendingSources: Promise | undefined; - const readSources = () => - (pendingSources ??= (async () => { - const results: SourceResult[] = []; - for (let offset = 0; offset < sources.length; offset += 3) { - results.push( - ...(await Promise.all( - sources - .slice(offset, offset + 3) - .map(async (source): Promise => { - try { - const snapshot = await bb.sdk.plugins.callRpc({ - pluginId: source.pluginId, - method: usageSourceMethod, - input: { refresh: request.force }, - outputSchema: - usageSourceRpcContract[usageSourceMethod].output, - signal: AbortSignal.timeout(45_000), - }); - return { - pluginId: source.pluginId, - label: snapshot.label ?? null, - resources: snapshot.resources, - error: null, - }; - } catch { - return { - pluginId: source.pluginId, - label: - sourceResults.find( - (entry) => entry.pluginId === source.pluginId, - )?.label ?? null, - resources: - sourceResults.find( - (entry) => entry.pluginId === source.pluginId, - )?.resources ?? [], - error: - "Usage could not be loaded from " + - (source.displayName ?? source.pluginId) + - ".", - }; - } - }), - )), - ); - } - sourceResults = results; - sourceLoadedAt = Date.now(); - sharedDirty = false; - return results; - })()); - const hostIds = new Set(hosts.map((host) => host.id)); - for (const machineId of cache.keys()) { - if (!hostIds.has(machineId)) cache.delete(machineId); + for (const id of inventories.keys()) + if (!sources.some((source) => source.pluginId === id)) + inventories.delete(id); + for (let offset = 0; offset < sources.length; offset += 3) { + await Promise.all( + sources.slice(offset, offset + 3).map(async (source) => { + try { + const inventory = await bb.sdk.plugins.callRpc({ + pluginId: source.pluginId, + method: usageListMethod, + input: {}, + outputSchema: usageSourceRpcContract[usageListMethod].output, + signal: AbortSignal.timeout(45_000), + }); + inventories.set(source.pluginId, { + pluginId: source.pluginId, + label: inventory.label ?? null, + resources: inventory.resources, + error: null, + }); + } catch { + const previous = inventories.get(source.pluginId); + inventories.set(source.pluginId, { + pluginId: source.pluginId, + label: previous?.label ?? null, + resources: previous?.resources ?? [], + error: "Usage resources could not be listed.", + }); + } + }), + ); } - const targetedIds = - request.machineIds === null ? null : new Set(request.machineIds); - await Promise.all( - hosts.map((host) => - readMachine( - host, - request, - targetedIds === null || - targetedIds.has(host.id) || - !cache.has(host.id), - readSources, - ), + const keys = new Set( + [...inventories.values()].flatMap((source) => + source.resources.map((resource) => keyOf(source.pluginId, resource.id)), ), ); - const sharedTargeted = - request.machineIds === null || - request.machineIds.some((id) => id.startsWith("source:")); - if ( - sourceLoadedAt === 0 || - (sharedTargeted && - (request.force || - Date.now() - sourceLoadedAt >= - (sharedDirty - ? Math.min(request.maxAgeMs, DIRTY_CACHE_MAX_AGE_MS) - : request.maxAgeMs))) - ) { - await readSources(); - } - const machines: UsageMachine[] = []; - for (const host of hosts) { - const entry = cache.get(host.id); - if (entry === undefined) { - throw new Error("Provider usage cache is missing " + host.name + "."); + for (const key of measurements.keys()) + if (!keys.has(key)) { + measurements.delete(key); + failures.delete(key); } - machines.push(entry.machine); - } - const sharedProviders = sourceResults.some((source) => - source.resources.some((resource) => resource.scope.kind === "shared"), - ) - ? await bb.sdk.providers.list({ capability: "usage" }).catch(() => []) - : []; - for (const source of sourceResults) { - const shared = source.resources.filter( - (resource) => resource.scope.kind === "shared", + const selected = [...inventories.values()].flatMap((source) => + source.resources + .filter((resource) => { + const machineId = + resource.scope.kind === "shared" + ? `source:${source.pluginId}` + : resource.scope.hostId; + return ( + request.providerId !== null && + resource.providerId === request.providerId && + (request.machineIds === null || + request.machineIds.includes(machineId)) && + (resource.scope.kind === "shared" || + hosts.some( + (host) => + resource.scope.kind === "host" && + host.id === resource.scope.hostId && + host.status === "connected", + )) + ); + }) + .map((resource) => ({ source, resource })), + ); + for (let offset = 0; offset < selected.length; offset += 3) { + await Promise.all( + selected.slice(offset, offset + 3).map(async ({ source, resource }) => { + const key = keyOf(source.pluginId, resource.id); + const cached = measurements.get(key); + if ( + !request.force && + cached && + Date.now() - cached.loadedAt < request.maxAgeMs + ) + return; + try { + await fetchResource(source.pluginId, resource.id, request.force); + } catch { + failures.add(key); + } + }), ); - if ( - shared.length === 0 && - source.label === null && - (source.resources.length > 0 || source.error === null) - ) - continue; - machines.push({ - id: `source:${source.pluginId}`, - displayName: - source.label ?? - sources.find((entry) => entry.pluginId === source.pluginId) - ?.displayName ?? - source.pluginId, - status: "connected", - providers: shared.map((resource) => - resourceProvider(resource, source.pluginId, sharedProviders), - ), - error: source.error, - }); } - return { machines }; - }; - - const markDirty = (machineId: string | null): void => { - sharedDirty = true; - if (machineId === null) { - for (const entry of cache.values()) entry.dirty = true; - } else { - const entry = cache.get(machineId); - if (entry !== undefined) entry.dirty = true; - } - }; - - const markDirtyForThread = async (environmentId: string | null) => { - if (environmentId === null) { - markDirty(null); - return; - } - let hostId = environmentHosts.get(environmentId); - if (hostId === undefined) { - try { - const environment = await bb.sdk.environments.get({ environmentId }); - hostId = environment.hostId; - } catch { - hostId = null; + const machines: UsageMachine[] = hosts.map((host) => ({ + id: host.id, + displayName: host.name, + status: host.status, + providers: [], + error: null, + })); + for (const source of inventories.values()) { + const hasShared = + source.label !== null || + source.resources.some((resource) => resource.scope.kind === "shared"); + if (hasShared || (source.error !== null && source.resources.length === 0)) + machines.push({ + id: `source:${source.pluginId}`, + displayName: + source.label ?? + sources.find((item) => item.pluginId === source.pluginId) + ?.displayName ?? + source.pluginId, + status: "connected", + providers: [], + error: source.error, + }); + for (const resource of source.resources) { + const machineId = + resource.scope.kind === "shared" + ? `source:${source.pluginId}` + : resource.scope.hostId; + const machine = machines.find((machine) => machine.id === machineId); + if (!machine) continue; + const key = keyOf(source.pluginId, resource.id); + const cached = measurements.get(key); + machine.providers.push( + resourceProvider(resource, cached?.value, source.pluginId, providers), + ); + if (source.error !== null || failures.has(key)) + machine.error = "Some usage could not be refreshed."; } - environmentHosts.set(environmentId, hostId); } - markDirty(hostId); + return { machines }; }; - - bb.rpc.register(providerUsageRpcContract, { - getUsage: readUsage, - }); - bb.events.on("thread.idle", ({ thread }) => - markDirtyForThread(thread.environmentId), - ); - bb.events.on("thread.failed", ({ thread }) => - markDirtyForThread(thread.environmentId), - ); + bb.rpc.register(providerUsageRpcContract, { getUsage: readUsage }); + const markDirty = () => { + for (const value of measurements.values()) value.loadedAt = 0; + }; + bb.events.on("thread.idle", markDirty); + bb.events.on("thread.failed", markDirty); } diff --git a/plugins/provider-usage/usage-source-contract.ts b/plugins/provider-usage/usage-source-contract.ts index c3cffe3164..c5255e5c42 100644 --- a/plugins/provider-usage/usage-source-contract.ts +++ b/plugins/provider-usage/usage-source-contract.ts @@ -42,57 +42,69 @@ const usageSchema = z.discriminatedUnion("status", [ message: z.string(), }), ]); -export const usageSnapshotSchema = z.object({ +export const usageResourceSchema = z.object({ + id: z + .string() + .min(1) + .describe("Stable resource ID within this source plugin."), + providerId: z.string().min(1), + label: z.string().min(1), + scope: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("shared") }), + z.object({ + kind: z.literal("host"), + hostId: z.string().min(1), + hostName: z.string().min(1), + }), + ]), +}); +export const usageResourceListSchema = z.object({ label: z .string() .min(1) .optional() .describe( - "Declares a shared-usage group, including when resources is empty. Omit for host-only sources. Shared resources without this label use the plugin display name; host groups use machine names.", + "Declares a shared group even when empty. Host-only sources omit it; machine groups use host names.", ), - resources: z.array( - z.object({ - id: z - .string() - .min(1) - .describe("Stable resource ID within the reporting plugin."), - providerId: z.string().min(1), - label: z.string().min(1), - scope: z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("shared") }), - z.object({ - kind: z.literal("host"), - hostId: z.string().min(1), - hostName: z.string().min(1), - }), - ]), - observedAt: z - .number() - .int() - .nonnegative() - .nullable() - .describe( - "Measurement time in epoch milliseconds; null if never observed.", - ), - usage: usageSchema, - }), - ), + resources: z.array(usageResourceSchema), }); -export type UsageSnapshot = z.infer; -export const usageInputSchema = z.object({ +export const usageMeasurementSchema = z.object({ + observedAt: z + .number() + .int() + .nonnegative() + .nullable() + .describe( + "Last successful measurement time in epoch milliseconds; null if never observed.", + ), + usage: usageSchema, +}); +export const usageListInputSchema = z.object({}); +export const usageFetchInputSchema = z.object({ + resourceId: z.string().min(1), refresh: z .boolean() .describe( - "Request fresh collection and wait for the attempt; false permits cached observations.", + "False permits a cached measurement but still returns actual usage. True requests a fresh collection attempt for this resource only.", ), }); - -export const usageSourceMethod = "provider-usage.v1.get"; +export type UsageResourceList = z.infer; +export type UsageMeasurement = z.infer; +export type UsageResource = z.infer & + UsageMeasurement; +export const usageListMethod = "provider-usage.v1.listResources"; +export const usageFetchMethod = "provider-usage.v1.getResource"; export const usageSourceRpcContract = defineRpcContract({ - [usageSourceMethod]: { - input: usageInputSchema, - output: usageSnapshotSchema, + [usageListMethod]: { + input: usageListInputSchema, + output: usageResourceListSchema, + experimental_description: + "Cheap complete inventory of resources owned by this source. Reads local metadata only; never refreshes quota or contacts providers. IDs are stable and source-local. Resource order is display order. Shared label preserves empty groups. Resources may disappear between list and fetch.", + }, + [usageFetchMethod]: { + input: usageFetchInputSchema, + output: usageMeasurementSchema, experimental_description: - "Returns a complete snapshot of this source's usage resources. Resource IDs are stable within the source plugin. Host resources belong to one machine; shared resources appear once across machines. refresh=true waits for a fresh collection attempt. Return per-resource failures when possible; observedAt records the last successful measurement, never the fetch time. Consumers discover implementations by this method name and validate responses against their local contract copy.", + "Returns actual usage for exactly one listed resource, even when refresh is false. False permits cached observations; true requests a fresh attempt. Never collects other resources as a side effect. A removed resource fails the RPC; consumers relist. Per-account authentication and collection failures are usage states. observedAt is the last successful measurement time.", }, }); From 8d2bd143bf8c8a7b0899e9945845ae4a14ef5266 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 11 Sep 2026 09:50:57 -0700 Subject: [PATCH 18/53] Keep usage adaptation plugin-owned and normalize account observations --- .../features/plugin-provider-usage.md | 21 +- .../settings/UsageLimitsSettingsSection.tsx | 65 +++--- .../UsageSourcesSettingsSection.test.tsx | 68 ++++++ .../src/hooks/queries/usage-source-queries.ts | 20 +- apps/app/src/lib/usage-normalization.ts | 78 +++++++ apps/app/src/lib/usage-source-contract.ts | 22 ++ .../src/services/plugins/builtin-registry.ts | 5 + .../services/plugins/builtin-plugins.test.ts | 1 + ...iscoverable-rpc-and-provider-usage-plan.md | 46 ++++- plugins/account-pool/src/usage-contract.ts | 22 ++ plugins/account-pool/src/usage-source.ts | 34 ++- plugins/bb-official.json | 4 + plugins/provider-claude-code/server.ts | 2 - .../skills/claude-code-provider/SKILL.md | 2 +- .../src/bridge/provider-maintenance.ts | 43 +++- .../src/usage-source.test.ts | 71 ------- .../provider-claude-code/src/usage-source.ts | 124 ----------- plugins/provider-codex/server.ts | 2 - .../skills/codex-provider/SKILL.md | 2 +- .../src/bridge/provider-maintenance.test.ts | 12 +- .../src/bridge/provider-maintenance.ts | 18 +- plugins/provider-codex/src/usage-contract.ts | 110 ---------- .../provider-codex/src/usage-source.test.ts | 71 ------- plugins/provider-codex/src/usage-source.ts | 124 ----------- plugins/provider-usage-sources/.gitignore | 2 + plugins/provider-usage-sources/README.md | 22 ++ plugins/provider-usage-sources/package.json | 39 ++++ plugins/provider-usage-sources/server.ts | 156 ++++++++++++++ .../skills/provider-usage-sources/SKILL.md | 26 +++ .../src/usage-contract.ts | 22 ++ .../src/usage-source.test.ts | 195 ++++++++++++++++++ plugins/provider-usage-sources/tsconfig.json | 21 ++ .../provider-usage-sources/vitest.config.ts | 15 ++ plugins/provider-usage/README.md | 6 + plugins/provider-usage/server.test.ts | 89 ++++++++ plugins/provider-usage/server.ts | 31 ++- .../usage-normalization.test.ts | 96 +++++++++ plugins/provider-usage/usage-normalization.ts | 78 +++++++ .../provider-usage/usage-source-contract.ts | 22 ++ pnpm-lock.yaml | 22 ++ turbo.json | 3 + 41 files changed, 1245 insertions(+), 567 deletions(-) create mode 100644 apps/app/src/lib/usage-normalization.ts delete mode 100644 plugins/provider-claude-code/src/usage-source.test.ts delete mode 100644 plugins/provider-claude-code/src/usage-source.ts delete mode 100644 plugins/provider-codex/src/usage-contract.ts delete mode 100644 plugins/provider-codex/src/usage-source.test.ts delete mode 100644 plugins/provider-codex/src/usage-source.ts create mode 100644 plugins/provider-usage-sources/.gitignore create mode 100644 plugins/provider-usage-sources/README.md create mode 100644 plugins/provider-usage-sources/package.json create mode 100644 plugins/provider-usage-sources/server.ts create mode 100644 plugins/provider-usage-sources/skills/provider-usage-sources/SKILL.md rename plugins/{provider-claude-code => provider-usage-sources}/src/usage-contract.ts (84%) create mode 100644 plugins/provider-usage-sources/src/usage-source.test.ts create mode 100644 plugins/provider-usage-sources/tsconfig.json create mode 100644 plugins/provider-usage-sources/vitest.config.ts create mode 100644 plugins/provider-usage/usage-normalization.test.ts create mode 100644 plugins/provider-usage/usage-normalization.ts diff --git a/.bb/skills/verify-bb/features/plugin-provider-usage.md b/.bb/skills/verify-bb/features/plugin-provider-usage.md index bc500ecf92..ba524a32f6 100644 --- a/.bb/skills/verify-bb/features/plugin-provider-usage.md +++ b/.bb/skills/verify-bb/features/plugin-provider-usage.md @@ -4,7 +4,7 @@ Status: **2026-09-05: 2 passed, 1 partial/blocked**. See [the audit](../MAINTENA ## Setup and entry points -Enable Provider usage; configure at least one provider advertising usage maintenance. Open its usage card and Settings → Usage. +Enable Provider usage and the bundled Provider usage sources adapter; configure at least one provider advertising usage maintenance. Open its usage card and Settings → Usage. Use the main skill’s isolated targets and evidence rules. A plugin can be present in this checkout but disabled in an installation. Enable it only in the test @@ -17,6 +17,9 @@ SKILL.md. Inspect nested `--help` before selecting flags and IDs. - `plugins/provider-usage/package.json` - `plugins/provider-usage/server.ts` - `plugins/provider-usage/app.tsx` +- `plugins/provider-usage-sources/server.ts` +- `plugins/account-pool/src/usage-source.ts` +- `apps/app/src/components/settings/UsageLimitsSettingsSection.tsx` ## Feature recipes @@ -24,7 +27,7 @@ SKILL.md. Inspect nested `--help` before selecting flags and IDs. | --- | --- | --- | | All capable providers | Refresh with two supported providers and one unsupported provider. | Cards show only supported data using current provider names/icons and configured ordering. | | Quota windows and errors | Inspect real returned windows/resets and a controlled refresh failure. | Values match the provider response; unknown/unavailable data is distinct from exhausted quota. | -| CLI and SDK parity | Compare bb settings usage --json with bb.sdk.system.usageLimits() and the visible card. | All surfaces represent the same underlying provider maintenance data. | +| CLI and SDK parity | Inspect `bb plugin rpc list --method provider-usage.v1.listResources --json`, list the adapter resources, and fetch one returned resource ID. Compare its selected host/provider with `bb settings usage --json`. | Discovery is independent of display plugins, inventory collects no quota, and fetch returns only the chosen resource. Pool resources remain separate from direct host maintenance. | ## Evidence and cleanup @@ -38,3 +41,17 @@ External account changes use authorized disposable targets. ## Maintenance notes - Open Settings → Usage limits and the sidebar Provider usage disclosure. Compare core settings usage / sdk.system.usageLimits with plugin getUsage, which wraps per-machine providers and normalizes optional fields; it is not byte-for-byte the core response. Source: `plugins/provider-usage/app.tsx:112`. + +## Usage source prototype follow-up (2026-09-11) + +- Passed live: pool defaults on both displays, provider tabs and pooled account ordering, + matching weekly/model/plan labels, explicit machine selection, and automatic Cursor + maintenance adaptation without changing its provider. Screenshot evidence is in the + implementing thread’s `usage-review/normalization.md`. +- Passed targeted tests: arbitrary maintenance providers, no collection during inventory, + removed resources, disconnected hosts, request coalescing, force refresh, empty pools, + first-load/stale failures, known identity deduplication, and unknown-identity separation. +- A new source should be tested with the display plugin disabled. The adapter has no + dependency on the display and publishes both source methods from its own registration. +- Do not deduplicate by email. Filter by selected location before normalizing observations; + explicit machine selection must remain available even when that account exists in a pool. diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx index d328111c98..79cf2a53a2 100644 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx +++ b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx @@ -1,3 +1,4 @@ +import { selectUsageResources } from "@/lib/usage-normalization"; import { useUsageSources, useUsageMeasurements, @@ -546,28 +547,31 @@ export function UsageLimitsSettingsSection() { resource.scope.hostId === selectedLocation?.id, ), ); - const listedResources = selectedSources - .flatMap(({ source, query }) => - (query.data?.resources ?? []) - .filter((resource) => - selectedLocation?.kind === "source" - ? resource.scope.kind === "shared" - : resource.scope.kind === "host" && - resource.scope.hostId === selectedLocation?.id, - ) - .map((resource) => ({ - key: `${source.pluginId}:${resource.id}`, - pluginId: source.pluginId, - resource, - })), - ) - .sort((a, b) => { - const rank = (id: string) => { - const index = providers.findIndex((provider) => provider.id === id); - return index < 0 ? providers.length : index; - }; - return rank(a.resource.providerId) - rank(b.resource.providerId); - }); + const listedResources = selectUsageResources( + selectedSources + .flatMap(({ source, query }) => + (query.data?.resources ?? []) + .filter((resource) => + selectedLocation?.kind === "source" + ? resource.scope.kind === "shared" + : resource.scope.kind === "host" && + resource.scope.hostId === selectedLocation?.id, + ) + .map((resource) => ({ + key: `${source.pluginId}:${resource.id}`, + pluginId: source.pluginId, + resource, + })), + ) + .sort((a, b) => { + const rank = (id: string) => { + const index = providers.findIndex((provider) => provider.id === id); + return index < 0 ? providers.length : index; + }; + return rank(a.resource.providerId) - rank(b.resource.providerId); + }), + (entry) => entry.resource, + ); const measurements = useUsageMeasurements( listedResources @@ -580,13 +584,16 @@ export function UsageLimitsSettingsSection() { ) .map(({ pluginId, resource }) => ({ pluginId, resourceId: resource.id })), ); - const resources = listedResources - .map((entry, index) => ({ - ...entry, - isError: measurements.queries[index]?.isError ?? false, - resource: { ...entry.resource, ...measurements.queries[index]?.data }, - })) - .filter(({ resource }) => resource.usage?.status !== "not_installed"); + const resources = selectUsageResources( + listedResources + .map((entry, index) => ({ + ...entry, + isError: measurements.queries[index]?.isError ?? false, + resource: { ...entry.resource, ...measurements.queries[index]?.data }, + })) + .filter(({ resource }) => resource.usage?.status !== "not_installed"), + (entry) => entry.resource, + ); return ( { + calls.discover.mockResolvedValue([ + { pluginId: "pool", displayName: "Account Pooler" }, + ]); + calls.rpc.mockImplementation(async ({ method, input }) => + method.endsWith("listResources") + ? { + label: "Account Pooler", + resources: ["first", "duplicate", "unknown", "unknown2"].map( + (id) => ({ + id, + providerId: "custom", + accountKey: id.startsWith("unknown") ? null : "issuer:account:1", + label: "person@example.com", + scope: { kind: "shared" }, + }), + ), + } + : { + accountKey: input.resourceId.startsWith("unknown") + ? null + : "issuer:account:1", + observedAt: 123, + usage: { + status: "ok", + accountEmail: "person@example.com", + planLabel: "max", + plan: { id: "max", multiplier: 20 }, + windows: [ + { + id: "week", + kind: "weekly", + label: "168 hour window", + model: null, + resetsAt: null, + cost: null, + usedPercent: 42, + }, + ], + }, + }, + ); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + try { + render( + + + + + , + ); + await waitFor(() => + expect(screen.getAllByText("42% used")).toHaveLength(3), + ); + expect(screen.getAllByText("Max (20x)")).toHaveLength(3); + expect(screen.getAllByText("Weekly limit")).toHaveLength(3); + expect( + calls.rpc.mock.calls + .filter(([args]) => args.method.endsWith("getResource")) + .map(([args]) => args.input.resourceId), + ).toEqual(["first", "unknown", "unknown2"]); + } finally { + client.clear(); + } +}); diff --git a/apps/app/src/hooks/queries/usage-source-queries.ts b/apps/app/src/hooks/queries/usage-source-queries.ts index 8c39bb54fa..8f2a991756 100644 --- a/apps/app/src/hooks/queries/usage-source-queries.ts +++ b/apps/app/src/hooks/queries/usage-source-queries.ts @@ -1,3 +1,4 @@ +import { normalizeUsageMeasurement } from "@/lib/usage-normalization"; import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { sdk } from "@/lib/sdk"; import { @@ -25,13 +26,18 @@ async function loadResource( else active++; try { signal.throwIfAborted(); - return await sdk.plugins.callRpc({ - pluginId, - method: usageFetchMethod, - input: { resourceId, refresh }, - outputSchema: usageMeasurementSchema, - signal: AbortSignal.any([signal, AbortSignal.timeout(45_000)]), - }); + const value = normalizeUsageMeasurement( + await sdk.plugins.callRpc({ + pluginId, + method: usageFetchMethod, + input: { resourceId, refresh }, + outputSchema: usageMeasurementSchema, + signal: AbortSignal.any([signal, AbortSignal.timeout(45_000)]), + }), + ); + if (value.usage.status === "error") + throw new Error("Usage could not be refreshed."); + return value; } finally { const next = waiting.shift(); if (next === undefined) active--; diff --git a/apps/app/src/lib/usage-normalization.ts b/apps/app/src/lib/usage-normalization.ts new file mode 100644 index 0000000000..9b6f1dfc68 --- /dev/null +++ b/apps/app/src/lib/usage-normalization.ts @@ -0,0 +1,78 @@ +import type { UsageMeasurement } from "./usage-source-contract.js"; + +export function normalizeUsageMeasurement( + measurement: UsageMeasurement, +): UsageMeasurement { + if (measurement.usage.status !== "ok") return measurement; + const usage = measurement.usage; + const labels: Record = { + free: "Free", + go: "Go", + plus: "Plus", + pro: "Pro", + max: "Max", + team: "Team", + business: "Business", + enterprise: "Enterprise", + education: "Education", + edu: "Education", + }; + const plan = usage.plan; + const planName = plan ? labels[plan.id] : undefined; + const windowLabels = { + "five-hour": "Five-hour limit", + daily: "Daily limit", + weekly: "Weekly limit", + custom: "", + }; + return { + ...measurement, + usage: { + ...usage, + planLabel: planName + ? `${planName}${plan?.multiplier == null ? "" : ` (${plan.multiplier}x)`}` + : usage.planLabel, + windows: usage.windows.map((window) => ({ + ...window, + label: + window.kind && window.kind !== "custom" + ? window.model + ? `${window.kind === "weekly" ? "Weekly" : windowLabels[window.kind]} · ${window.model.charAt(0).toUpperCase() + window.model.slice(1)}` + : windowLabels[window.kind] + : window.label, + })), + }, + }; +} + +type Identity = { + providerId: string; + accountKey?: string | null; + scope: { kind: "shared" | "host" }; +}; + +export function selectUsageResources( + resources: readonly T[], + identify: (resource: T) => Identity, +): T[] { + const result: T[] = []; + const known = new Map(); + for (const resource of resources) { + const identity = identify(resource); + if (!identity.accountKey) { + result.push(resource); + continue; + } + const key = JSON.stringify([identity.providerId, identity.accountKey]); + const index = known.get(key); + if (index === undefined) { + known.set(key, result.length); + result.push(resource); + } else if ( + identity.scope.kind === "shared" && + identify(result[index]!).scope.kind !== "shared" + ) + result[index] = resource; + } + return result; +} diff --git a/apps/app/src/lib/usage-source-contract.ts b/apps/app/src/lib/usage-source-contract.ts index 154cc5956a..30f6537436 100644 --- a/apps/app/src/lib/usage-source-contract.ts +++ b/apps/app/src/lib/usage-source-contract.ts @@ -1,10 +1,22 @@ import { z } from "zod"; +export const usagePlanSchema = z.object({ + id: z.string().min(1), + multiplier: z.number().int().positive().nullable(), +}); const accountFields = { + plan: usagePlanSchema.nullable().default(null), accountEmail: z.string().nullable(), planLabel: z.string().nullable(), }; +export const usageWindowKindSchema = z.enum([ + "five-hour", + "daily", + "weekly", + "custom", +]); const usageWindowSchema = z.object({ + kind: usageWindowKindSchema.default("custom"), id: z.string().min(1), label: z.string().min(1), usedPercent: z @@ -41,7 +53,16 @@ const usageSchema = z.discriminatedUnion("status", [ message: z.string(), }), ]); +export const usageAccountKeySchema = z + .string() + .min(1) + .nullable() + .default(null) + .describe( + "Provider-issued quota account identity, namespaced by issuer and account/organization scope. Never use email, a display label, a source-local ID, or credentials. Null means unknown; unknown accounts must not be merged.", + ); export const usageResourceSchema = z.object({ + accountKey: usageAccountKeySchema, id: z .string() .min(1) @@ -68,6 +89,7 @@ export const usageResourceListSchema = z.object({ resources: z.array(usageResourceSchema), }); export const usageMeasurementSchema = z.object({ + accountKey: usageAccountKeySchema, observedAt: z .number() .int() diff --git a/apps/server/src/services/plugins/builtin-registry.ts b/apps/server/src/services/plugins/builtin-registry.ts index f6146c9332..f4b031de60 100644 --- a/apps/server/src/services/plugins/builtin-registry.ts +++ b/apps/server/src/services/plugins/builtin-registry.ts @@ -103,6 +103,11 @@ export const BUILTIN_PLUGINS = [ pluginId: "provider-pi", defaultEnabled: true, }, + { + name: "provider-usage-sources", + pluginId: "provider-usage-sources", + defaultEnabled: true, + }, { name: "provider-usage", pluginId: "provider-usage", diff --git a/apps/server/test/services/plugins/builtin-plugins.test.ts b/apps/server/test/services/plugins/builtin-plugins.test.ts index 1490bacfe4..9e0b8ff37e 100644 --- a/apps/server/test/services/plugins/builtin-plugins.test.ts +++ b/apps/server/test/services/plugins/builtin-plugins.test.ts @@ -263,6 +263,7 @@ describe("builtin plugin reconciliation", () => { ["provider-pi", "./icons/pi.svg"], ["provider-retry", "ArrowReloadHorizontal"], ["provider-usage", "ChartColumn"], + ["provider-usage-sources", "ChartColumn"], ["push-notifications", "BellDot"], ["scheduled-send", "Calendar"], ["secrets", "Lock"], diff --git a/docs/discoverable-rpc-and-provider-usage-plan.md b/docs/discoverable-rpc-and-provider-usage-plan.md index 3c16a13250..3732442d1b 100644 --- a/docs/discoverable-rpc-and-provider-usage-plan.md +++ b/docs/discoverable-rpc-and-provider-usage-plan.md @@ -1,6 +1,6 @@ # Discoverable RPC and replaceable provider usage displays -Status: prototype implemented for discoverable RPC, Account Pooler, Codex, Claude Code, and the core `/settings/usage` page. Provider Usage defines the canonical contract in `plugins/provider-usage/usage-source-contract.ts` and consumes discovered sources through it. The core settings page preserves its existing provider cards and extends the machine picker to select shared sources such as Account Pooler. Shared sources are selected by default. +Status: prototype implemented for discoverable RPC, Account Pooler, the generic Provider usage sources adapter, and the core `/settings/usage` page. Provider Usage defines the canonical contract in `plugins/provider-usage/usage-source-contract.ts` and consumes discovered sources through it. The core settings page preserves its existing provider cards and extends the machine picker to select shared sources such as Account Pooler. Shared sources are selected by default. ## Prototype verification @@ -139,8 +139,8 @@ A consumer author inspects a producer's source or CLI output, copies the relevan Use two methods defined canonically by Provider Usage and copied locally by each producer and independent consumer: -- `provider-usage.v1.listResources({})` returns `{ label?, resources: [{ id, providerId, label, scope }] }`. This is cheap local inventory; it never refreshes usage or contacts providers. The optional label declares an empty shared group. Host-only sources omit it. List order is display order. -- `provider-usage.v1.getResource({ resourceId, refresh })` returns `{ observedAt, usage }` for exactly one listed resource. False permits cached measurements but still returns actual usage. True requests a fresh collection attempt for that resource only. A removed resource fails explicitly, and consumers relist. +- `provider-usage.v1.listResources({})` returns `{ label?, resources: [{ id, providerId, accountKey, label, scope }] }`. This is cheap local inventory; it never refreshes usage or contacts providers. The optional label declares an empty shared group. Host-only sources omit it. List order is display order. +- `provider-usage.v1.getResource({ resourceId, refresh })` returns `{ accountKey, observedAt, usage }` for exactly one listed resource. False permits cached measurements but still returns actual usage. True requests a fresh collection attempt for that resource only. A removed resource fails explicitly, and consumers relist. The sidebar lists all sources to construct its source picker and provider tabs, then fetches only accounts belonging to the selected provider and source/machine. Background reconciliation lists metadata only. Unopened tabs have unknown usage rather than a fabricated healthy badge; retained measurements may still supply badges. Settings fetches the resources in its selected pool or machine. Both keep independent per-resource caches, bounded collection concurrency, stale-data notices, and graceful failures. @@ -149,8 +149,8 @@ Account Pooler lists account metadata without refreshing, then calls its existin ### Source implementations - Account Pooler exposes its accounts and existing quota state with shared scope. Refresh delegates to its existing collection logic; it does not alter routing. -- Provider plugins expose host-local resources by calling their existing host usage maintenance capability. A disconnected host or failed account collection becomes an individual resource outcome and does not discard other results. -- Both register the discoverable method regardless of whether Provider Usage is installed or enabled. +- The headless Provider usage sources adapter exposes host-local resources for every provider declaring `maintenance.usage`, using the existing SDK maintenance API. A disconnected host or failed account collection becomes an individual resource outcome and does not discard other results. +- Both source plugins register the discoverable methods regardless of whether Provider Usage is installed or enabled. ### Display implementation @@ -164,14 +164,14 @@ An alternative display uses the same discovery query and its own copied response Preserve `bb.sdk.system.usageLimits()` and `bb settings usage --json` as existing host-provider maintenance views during the first rollout. Do not silently change their response shape or use them as the unified view. -Provider Usage exposes its display snapshot through `bb plugin rpc call provider-usage getUsage --input-file --json`. The private display request includes `force`, nullable `machineIds`, nullable `providerId`, and `maxAgeMs`; null providerId lists metadata without collecting usage. Source fetch requests use a JSON file containing `resourceId` and `refresh`. Consumers needing replacement-independent data can discover and call the source methods directly. Provider Usage must stop collecting the same host usage independently once provider plugins supply it through discovery. +Provider Usage exposes its display snapshot through `bb plugin rpc call provider-usage getUsage --input-file --json`. The private display request includes `force`, nullable `machineIds`, nullable `providerId`, and `maxAgeMs`; null providerId lists metadata without collecting usage. Source fetch requests use a JSON file containing `resourceId` and `refresh`. Consumers needing replacement-independent data can discover and call the source methods directly. Provider Usage must stop collecting the same host usage independently once the adapter supplies it through discovery. ## Delivery sequence 1. **Schema publication:** implement export support and registration validation. Verify portable wire schemas for real usage types and unchanged anonymous RPC behavior. 2. **Registry and inspection:** add opt-in publication, lifecycle-safe descriptors, targeted discovery route, SDK query, and CLI listing/inspection. 3. **Contracts and documentation:** publish copyable examples, method naming guidance, schema limitations, and lifecycle semantics in Plugin Guide. Add SDK surfaces to `packages/plugin-api-map/src/surfaces.ts` and audit entries to `docs/api_to_audit.md`; update CLI guide templates and skills. -4. **Usage sources:** implement the convention in Account Pooler and provider plugins using existing collection primitives. +4. **Usage sources:** implement the convention in Account Pooler and the independent maintenance adapter using existing collection primitives. 5. **Usage display:** migrate Provider Usage to discovery, expose the unified snapshot through its RPC/CLI, and verify a second display consumer against the same sources. Keep the work server-side unless inspection proves host wire changes are necessary. Existing host usage maintenance remains a primitive. If any server/daemon wire fields change, increment `HOST_DAEMON_PROTOCOL_VERSION` unless previous-daemon compatibility is deliberately preserved and tested. @@ -202,3 +202,35 @@ The result is complete when discovery and inspection are generally usable, usage Usage-state review: both consumers distinguish loading, empty shared groups, unavailable sources, uninstalled providers, per-account authentication/collection failures, plans without reported limits, and offline machines. Shared-account sign-in guidance refers to the source plugin’s settings. Failed source refreshes preserve successful cached observations with a visible notice; disabled sources disappear on discovery reconciliation. Browser fixtures exercise source selection, removal, and retry recovery without changing configured accounts. Unfiltered CLI discovery omits undefined filters instead of serializing them into literal query values. + +## Plugin-owned maintenance adapter and normalization + +Provider Usage owns the canonical contract. Provider usage sources is a separate, +headless, default-enabled bundled plugin that copies it. It discovers host/provider +metadata through `bb.sdk.providers.list({hostId, capability: "usage"})` and fetches +only the requested pair through `bb.sdk.system.usageLimits({hostId, providerId})`. +Codex and Claude Code no longer register separate usage RPC methods. Other providers +declaring maintenance usage work automatically; no provider kit or core adapter is +added. A replacement display can consume the same sources without Provider Usage. + +Resource IDs are opaque source-local addresses. `accountKey` is a nullable, +provider-issued quota identity, namespaced by issuer and account/organization +scope. Labels and email are presentation only. Known matching identities within a +selected location collapse to one observation in stable source order; quota +percentages are never summed. Unknown identities remain distinct. Location +selection happens first: shared sources are the default, and an explicit machine +selection shows that machine, even when it observes the same account as a pool. + +Inventory may return an unknown key until the first measurement. The measurement's +identity is authoritative. The adapter remembers it for later cheap inventory; +it never reads credentials merely to list resources. Codex and Claude Code add +provider-owned identity and normalization metadata to their existing passthrough +maintenance responses. Core transports those extensions without interpreting them. +Older providers remain compatible and report unknown identity/custom labels. + +Known windows carry `kind` (`five-hour`, `daily`, `weekly`, or `custom`) alongside +an optional model family. Known plans carry `{id, multiplier}` alongside their +fallback label. Consumers consistently render Weekly limit and Max (20x), while +retaining unfamiliar provider labels. These additions default to unknown/custom +when consuming older source contracts. Breaking semantics still require a new +method namespace; discovery introduces no independent version negotiation. diff --git a/plugins/account-pool/src/usage-contract.ts b/plugins/account-pool/src/usage-contract.ts index c5255e5c42..1ef6a414af 100644 --- a/plugins/account-pool/src/usage-contract.ts +++ b/plugins/account-pool/src/usage-contract.ts @@ -1,11 +1,23 @@ import { defineRpcContract } from "@get-bb/plugin-sdk"; import { z } from "zod"; +export const usagePlanSchema = z.object({ + id: z.string().min(1), + multiplier: z.number().int().positive().nullable(), +}); const accountFields = { + plan: usagePlanSchema.nullable().default(null), accountEmail: z.string().nullable(), planLabel: z.string().nullable(), }; +export const usageWindowKindSchema = z.enum([ + "five-hour", + "daily", + "weekly", + "custom", +]); const usageWindowSchema = z.object({ + kind: usageWindowKindSchema.default("custom"), id: z.string().min(1), label: z.string().min(1), usedPercent: z @@ -42,7 +54,16 @@ const usageSchema = z.discriminatedUnion("status", [ message: z.string(), }), ]); +export const usageAccountKeySchema = z + .string() + .min(1) + .nullable() + .default(null) + .describe( + "Provider-issued quota account identity, namespaced by issuer and account/organization scope. Never use email, a display label, a source-local ID, or credentials. Null means unknown; unknown accounts must not be merged.", + ); export const usageResourceSchema = z.object({ + accountKey: usageAccountKeySchema, id: z .string() .min(1) @@ -69,6 +90,7 @@ export const usageResourceListSchema = z.object({ resources: z.array(usageResourceSchema), }); export const usageMeasurementSchema = z.object({ + accountKey: usageAccountKeySchema, observedAt: z .number() .int() diff --git a/plugins/account-pool/src/usage-source.ts b/plugins/account-pool/src/usage-source.ts index 5632552785..c74d9a3dec 100644 --- a/plugins/account-pool/src/usage-source.ts +++ b/plugins/account-pool/src/usage-source.ts @@ -32,6 +32,16 @@ export function usagePlanLabel( : null; } +function accountKey(account: AccountSummary): string | null { + return account.provider === "codex" + ? account.codexAccountId + ? `openai:chatgpt:${account.codexAccountId}` + : null + : account.accountUuid + ? `anthropic:account:${account.accountUuid}` + : null; +} + export function registerUsageSource(bb: BbPluginApi, hub: AccountPoolHub) { bb.rpc.register( usageSourceRpcContract, @@ -42,6 +52,7 @@ export function registerUsageSource(bb: BbPluginApi, hub: AccountPoolHub) { label: "Account Pooler", resources: accounts.map((account) => ({ id: account.id, + accountKey: accountKey(account), providerId: account.provider === "claude" ? "claude-code" : "codex", label: account.email ?? @@ -78,6 +89,14 @@ export function registerUsageSource(bb: BbPluginApi, hub: AccountPoolHub) { ) => { if (utilization === null) return; windows.push({ + kind: + label === "Five-hour limit" + ? "five-hour" + : label === "Daily limit" + ? "daily" + : label.startsWith("Weekly") + ? "weekly" + : "custom", id, label, usedPercent: Math.round(utilization * 100), @@ -123,6 +142,14 @@ export function registerUsageSource(bb: BbPluginApi, hub: AccountPoolHub) { ); } const accountFields = { + plan: account.subscriptionType + ? { + id: account.subscriptionType.toLowerCase(), + multiplier: + Number(account.rateLimitTier?.match(/max_(\d+)x/u)?.[1]) || + null, + } + : null, accountEmail: account.email, planLabel: usagePlanLabel(account), }; @@ -135,6 +162,7 @@ export function registerUsageSource(bb: BbPluginApi, hub: AccountPoolHub) { : null); return { id: account.id, + accountKey: accountKey(account), providerId: account.provider === "claude" ? "claude-code" : "codex", label: account.label, scope: { kind: "shared" }, @@ -146,7 +174,11 @@ export function registerUsageSource(bb: BbPluginApi, hub: AccountPoolHub) { }; }); const resource = resources[0]!; - return { observedAt: resource.observedAt, usage: resource.usage }; + return { + accountKey: resource.accountKey, + observedAt: resource.observedAt, + usage: resource.usage, + }; }, }, { diff --git a/plugins/bb-official.json b/plugins/bb-official.json index c158ee66d8..9afb824999 100644 --- a/plugins/bb-official.json +++ b/plugins/bb-official.json @@ -141,5 +141,9 @@ "environment-modal-sandbox": { "category": "environments", "screenshots": [] + }, + "provider-usage-sources": { + "category": "agents-and-providers", + "screenshots": [] } } diff --git a/plugins/provider-claude-code/server.ts b/plugins/provider-claude-code/server.ts index bf08984c7f..28e2ee95ee 100644 --- a/plugins/provider-claude-code/server.ts +++ b/plugins/provider-claude-code/server.ts @@ -1,4 +1,3 @@ -import { registerUsageSource } from "./src/usage-source.js"; import type { BbPluginApi } from "@get-bb/plugin-sdk"; import { CLAUDE_CODE_ACTIVE_CATALOG_DATA, @@ -8,7 +7,6 @@ import { import { CLAUDE_NATIVE_ROOTS_DECLARATION } from "./src/native-roots.js"; export default function plugin(bb: BbPluginApi) { - registerUsageSource(bb); bb.settings.define({ memoryEnabled: { type: "boolean", diff --git a/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md b/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md index 9a10314170..ad1f6dbb40 100644 --- a/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md +++ b/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md @@ -21,4 +21,4 @@ threads or change settings merely to answer a question. ## Discoverable usage -This plugin exposes cheap `provider-usage.v1.listResources` inventory and `provider-usage.v1.getResource` measurements for independent usage displays. Fetch takes `{ resourceId, refresh }` and returns actual usage for that resource even when refresh is false; listing never collects quota. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect provider-claude-code --method provider-usage.v1.listResources --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. +The bundled Provider usage sources plugin adapts this provider’s maintenance data into cheap `provider-usage.v1.listResources` inventory and `provider-usage.v1.getResource` measurements for independent usage displays. Fetch takes `{ resourceId, refresh }` and returns actual usage for that resource even when refresh is false; listing never collects quota. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect provider-usage-sources --method provider-usage.v1.listResources --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. diff --git a/plugins/provider-claude-code/src/bridge/provider-maintenance.ts b/plugins/provider-claude-code/src/bridge/provider-maintenance.ts index f6683ccde0..2e3bf5e543 100644 --- a/plugins/provider-claude-code/src/bridge/provider-maintenance.ts +++ b/plugins/provider-claude-code/src/bridge/provider-maintenance.ts @@ -46,7 +46,10 @@ type ClaudeCredentials = z.infer< const claudeAccountSchema = z.object({ oauthAccount: z - .object({ emailAddress: z.string().email().nullish() }) + .object({ + emailAddress: z.string().email().nullish(), + accountUuid: z.string().uuid().nullish(), + }) .nullish(), }); @@ -290,21 +293,23 @@ async function readCredentials(): Promise { } } -async function readAccountEmail(): Promise { +async function readAccount() { try { const parsed = claudeAccountSchema.safeParse( JSON.parse( await fs.readFile(path.join(os.homedir(), ".claude.json"), "utf8"), ), ); - return parsed.success - ? (parsed.data.oauthAccount?.emailAddress ?? null) - : null; + return parsed.success ? (parsed.data.oauthAccount ?? null) : null; } catch { return null; } } +async function readAccountEmail(): Promise { + return (await readAccount())?.emailAddress ?? null; +} + function planLabel(credentials: ClaudeCredentials): string | null { const maxMatch = (credentials.rateLimitTier ?? "").match(/max_(\d+)x/u); if (maxMatch) return `Max (${maxMatch[1]}x)`; @@ -410,10 +415,12 @@ function resetIso(value: string | null | undefined): string | null { function usageWindow( value: z.infer | null | undefined, label: string, + kind: "five-hour" | "weekly", ): ProviderUsageWindow | null { if (!value || value.utilization == null) return null; return { label, + kind, usedPercent: clampPercent(value.utilization), resetsAt: resetIso(value.resets_at), }; @@ -440,6 +447,8 @@ function scopedWindows( } seen.add(label.toLowerCase()); windows.push({ + kind: "weekly", + model: label.toLowerCase(), label, usedPercent: clampPercent(limit.percent), resetsAt: resetIso(limit.resets_at), @@ -463,14 +472,22 @@ function normalizeUsage( }; } const windows = [ - usageWindow(parsed.data.five_hour, "Current session"), - usageWindow(parsed.data.seven_day, "Weekly limit"), + usageWindow(parsed.data.five_hour, "Current session", "five-hour"), + usageWindow(parsed.data.seven_day, "Weekly limit", "weekly"), ...scopedWindows(parsed.data.limits), ].filter((window): window is ProviderUsageWindow => window !== null); return { status: "ok", accountEmail: email, planLabel: planLabel(credentials), + plan: credentials.subscriptionType + ? { + id: credentials.subscriptionType.toLowerCase(), + multiplier: + Number(credentials.rateLimitTier?.match(/max_(\d+)x/u)?.[1]) || + null, + } + : null, windows, }; } @@ -480,10 +497,11 @@ export async function getClaudeProviderUsage(): Promise { if ((await resolveExecutablePath(command)) === null) { return { supported: true, usage: { status: "not_installed" } }; } - const [credentials, email] = await Promise.all([ + const [credentials, account] = await Promise.all([ readCredentials(), - readAccountEmail(), + readAccount(), ]); + const email = account?.emailAddress ?? null; if (!credentials) { return { supported: true, usage: { status: "unauthenticated" } }; } @@ -520,7 +538,12 @@ export async function getClaudeProviderUsage(): Promise { } return { supported: true, - usage: normalizeUsage(await response.json(), credentials, email), + usage: { + ...normalizeUsage(await response.json(), credentials, email), + accountKey: account?.accountUuid + ? `anthropic:account:${account.accountUuid}` + : null, + }, }; } catch (error) { return { diff --git a/plugins/provider-claude-code/src/usage-source.test.ts b/plugins/provider-claude-code/src/usage-source.test.ts deleted file mode 100644 index 27202ddba9..0000000000 --- a/plugins/provider-claude-code/src/usage-source.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { expect, it, vi } from "vitest"; -import { - createFakePluginHost, - makeHostResponse, -} from "@get-bb/plugin-sdk/testing"; -import { registerUsageSource } from "./usage-source.js"; -import { - usageMeasurementSchema, - usageResourceListSchema, - usageListMethod, - usageFetchMethod, -} from "./usage-contract.js"; - -it("publishes usage independently of displays and isolates disconnected hosts", async () => { - const collect = vi.fn(async () => ({ - "claude-code": { - status: "ok" as const, - accountEmail: "user@example.com", - planLabel: "Team", - windows: [{ label: "Weekly", usedPercent: 42, resetsAt: null }], - }, - })); - const { bb, harness } = createFakePluginHost({ - sdk: { - hosts: { - list: async () => [ - makeHostResponse({ id: "online", status: "connected" }), - makeHostResponse({ id: "offline", status: "disconnected" }), - ], - }, - system: { usageLimits: collect }, - }, - }); - try { - registerUsageSource(bb); - const inventory = usageResourceListSchema.parse( - await harness.behavior.callRpc(usageListMethod, {}), - ); - expect(inventory.resources.map((resource) => resource.id)).toEqual([ - "online", - "offline", - ]); - expect(collect).not.toHaveBeenCalled(); - const read = async (resourceId: string, refresh: boolean) => - usageMeasurementSchema.parse( - await harness.behavior.callRpc(usageFetchMethod, { - resourceId, - refresh, - }), - ); - expect(await read("online", false)).toMatchObject({ - usage: { status: "ok", windows: [{ usedPercent: 42 }] }, - }); - expect(collect).toHaveBeenCalledWith({ - hostId: "online", - providerId: "claude-code", - }); - await read("online", false); - expect(collect).toHaveBeenCalledTimes(1); - await read("online", true); - expect(collect).toHaveBeenCalledTimes(2); - expect(await read("offline", false)).toMatchObject({ - observedAt: null, - usage: { status: "error" }, - }); - expect(collect).toHaveBeenCalledTimes(2); - await expect(read("removed", false)).rejects.toThrow("no longer exists"); - } finally { - await harness.lifecycle.dispose(); - } -}); diff --git a/plugins/provider-claude-code/src/usage-source.ts b/plugins/provider-claude-code/src/usage-source.ts deleted file mode 100644 index 96906f131b..0000000000 --- a/plugins/provider-claude-code/src/usage-source.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { BbPluginApi } from "@get-bb/plugin-sdk"; -import { - usageSourceRpcContract, - usageListMethod, - usageFetchMethod, - type UsageResource, -} from "./usage-contract.js"; -export function registerUsageSource(bb: BbPluginApi) { - const cache = new Map(); - const pending = new Map>(); - bb.rpc.register( - usageSourceRpcContract, - { - async [usageListMethod]() { - const hosts = await bb.sdk.hosts.list(); - for (const id of cache.keys()) - if (!hosts.some((host) => host.id === id)) cache.delete(id); - return { - resources: hosts.map((host) => ({ - id: host.id, - providerId: "claude-code", - label: "Claude Code", - scope: { - kind: "host" as const, - hostId: host.id, - hostName: host.name, - }, - })), - }; - }, - async [usageFetchMethod]({ resourceId, refresh }) { - const host = (await bb.sdk.hosts.list()).find( - (host) => host.id === resourceId, - ); - if (!host) throw new Error("Usage resource no longer exists."); - const previous = cache.get(host.id); - const base = { - id: host.id, - providerId: "claude-code", - label: "Claude Code", - scope: { - kind: "host" as const, - hostId: host.id, - hostName: host.name, - }, - observedAt: previous?.observedAt ?? null, - }; - if (host.status === "disconnected") { - return { - ...base, - usage: { - status: "error" as const, - accountEmail: null, - planLabel: null, - message: "Machine is disconnected.", - }, - }; - } - if ( - !refresh && - previous?.usage.status === "ok" && - previous.observedAt !== null && - Date.now() - previous.observedAt < 60_000 - ) { - return { ...previous, scope: base.scope }; - } - const running = pending.get(host.id); - if (running !== undefined) return running; - const load = (async (): Promise => { - try { - const result = await bb.sdk.system.usageLimits({ - hostId: host.id, - providerId: "claude-code", - }); - const usage = result["claude-code"]; - if (usage === undefined) - throw new Error("Provider returned no usage information."); - const resource = { - ...base, - observedAt: usage.status === "ok" ? Date.now() : base.observedAt, - usage: - usage.status === "ok" - ? { - ...usage, - windows: usage.windows.map((window, index) => ({ - ...window, - id: `${index}:${window.label}`, - model: null, - cost: window.cost ?? null, - })), - } - : usage.status === "error" - ? usage - : { - status: usage.status, - accountEmail: null, - planLabel: null, - }, - }; - cache.set(host.id, resource); - return resource; - } catch { - return { - ...base, - usage: { - status: "error", - accountEmail: null, - planLabel: null, - message: "Usage could not be collected from this machine.", - }, - }; - } - })().finally(() => pending.delete(host.id)); - pending.set(host.id, load); - return load; - }, - }, - { - experimental_discoverable: true, - experimental_description: - "Claude Code usage from host-local credentials. Inventory never collects usage.", - }, - ); -} diff --git a/plugins/provider-codex/server.ts b/plugins/provider-codex/server.ts index 50bddc585a..bb0bfb4e2a 100644 --- a/plugins/provider-codex/server.ts +++ b/plugins/provider-codex/server.ts @@ -1,10 +1,8 @@ -import { registerUsageSource } from "./src/usage-source.js"; import type { BbPluginApi } from "@get-bb/plugin-sdk"; import { codexExtensionKinds } from "./src/extension-kinds.js"; import { CODEX_NATIVE_ROOTS_DECLARATION } from "./src/native-roots.js"; export default function plugin(bb: BbPluginApi) { - registerUsageSource(bb); bb.experimental_aiServices.register({ id: "codex", displayName: "Codex (ChatGPT account or API key)", diff --git a/plugins/provider-codex/skills/codex-provider/SKILL.md b/plugins/provider-codex/skills/codex-provider/SKILL.md index ee27d20d6a..54aa3a4ce7 100644 --- a/plugins/provider-codex/skills/codex-provider/SKILL.md +++ b/plugins/provider-codex/skills/codex-provider/SKILL.md @@ -19,4 +19,4 @@ upstream product behavior. ## Discoverable usage -This plugin exposes cheap `provider-usage.v1.listResources` inventory and `provider-usage.v1.getResource` measurements for independent usage displays. Fetch takes `{ resourceId, refresh }` and returns actual usage for that resource even when refresh is false; listing never collects quota. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect provider-codex --method provider-usage.v1.listResources --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. +The bundled Provider usage sources plugin adapts this provider’s maintenance data into cheap `provider-usage.v1.listResources` inventory and `provider-usage.v1.getResource` measurements for independent usage displays. Fetch takes `{ resourceId, refresh }` and returns actual usage for that resource even when refresh is false; listing never collects quota. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect provider-usage-sources --method provider-usage.v1.listResources --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. diff --git a/plugins/provider-codex/src/bridge/provider-maintenance.test.ts b/plugins/provider-codex/src/bridge/provider-maintenance.test.ts index 0571c1a992..7908369e17 100644 --- a/plugins/provider-codex/src/bridge/provider-maintenance.test.ts +++ b/plugins/provider-codex/src/bridge/provider-maintenance.test.ts @@ -55,13 +55,20 @@ describe("Codex provider maintenance", () => { status: "ok", accountEmail: "codex@example.com", planLabel: "Plus", + plan: { id: "plus", multiplier: null }, windows: [ { label: "Current session", + kind: "five-hour", usedPercent: 42, resetsAt: "2025-06-15T15:06:40.000Z", }, - { label: "Weekly limit", usedPercent: 100, resetsAt: null }, + { + label: "Weekly limit", + kind: "weekly", + usedPercent: 100, + resetsAt: null, + }, ], }); }); @@ -271,11 +278,14 @@ describe("Codex credential health and usage", () => { supported: true, usage: { status: "ok", + accountKey: "openai:chatgpt:account-123", accountEmail: "codex@example.com", planLabel: "Plus", + plan: { id: "plus", multiplier: null }, windows: [ { label: "Current session", + kind: "five-hour", usedPercent: 10, resetsAt: "2025-06-15T15:06:40.000Z", }, diff --git a/plugins/provider-codex/src/bridge/provider-maintenance.ts b/plugins/provider-codex/src/bridge/provider-maintenance.ts index 271906100f..974aba740c 100644 --- a/plugins/provider-codex/src/bridge/provider-maintenance.ts +++ b/plugins/provider-codex/src/bridge/provider-maintenance.ts @@ -256,6 +256,14 @@ function usageWindow( ): ProviderUsageWindow | null { if (!value) return null; return { + kind: + value.limit_window_seconds === 18_000 + ? "five-hour" + : value.limit_window_seconds === 86_400 + ? "daily" + : value.limit_window_seconds === 604_800 + ? "weekly" + : "custom", label: value.limit_window_seconds === 604_800 ? "Weekly limit" : fallbackLabel, usedPercent: clampPercent(value.used_percent), @@ -300,6 +308,9 @@ function normalizeUsage(raw: unknown, email: string | null): ProviderUsage { status: "ok", accountEmail: email, planLabel: planLabel(parsed.data.plan_type), + plan: parsed.data.plan_type + ? { id: parsed.data.plan_type, multiplier: null } + : null, windows, }; } @@ -366,7 +377,12 @@ export async function getCodexProviderUsage(): Promise { } return { supported: true, - usage: normalizeUsage(await response.json(), credentials.accountEmail), + usage: { + ...normalizeUsage(await response.json(), credentials.accountEmail), + accountKey: credentials.accountId + ? `openai:chatgpt:${credentials.accountId}` + : null, + }, }; } catch (error) { return { diff --git a/plugins/provider-codex/src/usage-contract.ts b/plugins/provider-codex/src/usage-contract.ts deleted file mode 100644 index c5255e5c42..0000000000 --- a/plugins/provider-codex/src/usage-contract.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { defineRpcContract } from "@get-bb/plugin-sdk"; -import { z } from "zod"; - -const accountFields = { - accountEmail: z.string().nullable(), - planLabel: z.string().nullable(), -}; -const usageWindowSchema = z.object({ - id: z.string().min(1), - label: z.string().min(1), - usedPercent: z - .number() - .nonnegative() - .describe("Percentage consumed; may exceed 100 for overage."), - resetsAt: z - .string() - .nullable() - .describe("ISO timestamp, or null when no reset is known."), - model: z - .string() - .nullable() - .describe("Applicable model family, or null for all models."), - cost: z - .object({ - usedUsdCents: z.number().nonnegative(), - limitUsdCents: z.number().positive(), - }) - .nullable(), -}); -const usageSchema = z.discriminatedUnion("status", [ - z.object({ - status: z.literal("ok"), - ...accountFields, - windows: z.array(usageWindowSchema), - }), - z.object({ status: z.literal("not_installed"), ...accountFields }), - z.object({ status: z.literal("unauthenticated"), ...accountFields }), - z.object({ status: z.literal("expired"), ...accountFields }), - z.object({ - status: z.literal("error"), - ...accountFields, - message: z.string(), - }), -]); -export const usageResourceSchema = z.object({ - id: z - .string() - .min(1) - .describe("Stable resource ID within this source plugin."), - providerId: z.string().min(1), - label: z.string().min(1), - scope: z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("shared") }), - z.object({ - kind: z.literal("host"), - hostId: z.string().min(1), - hostName: z.string().min(1), - }), - ]), -}); -export const usageResourceListSchema = z.object({ - label: z - .string() - .min(1) - .optional() - .describe( - "Declares a shared group even when empty. Host-only sources omit it; machine groups use host names.", - ), - resources: z.array(usageResourceSchema), -}); -export const usageMeasurementSchema = z.object({ - observedAt: z - .number() - .int() - .nonnegative() - .nullable() - .describe( - "Last successful measurement time in epoch milliseconds; null if never observed.", - ), - usage: usageSchema, -}); -export const usageListInputSchema = z.object({}); -export const usageFetchInputSchema = z.object({ - resourceId: z.string().min(1), - refresh: z - .boolean() - .describe( - "False permits a cached measurement but still returns actual usage. True requests a fresh collection attempt for this resource only.", - ), -}); -export type UsageResourceList = z.infer; -export type UsageMeasurement = z.infer; -export type UsageResource = z.infer & - UsageMeasurement; -export const usageListMethod = "provider-usage.v1.listResources"; -export const usageFetchMethod = "provider-usage.v1.getResource"; -export const usageSourceRpcContract = defineRpcContract({ - [usageListMethod]: { - input: usageListInputSchema, - output: usageResourceListSchema, - experimental_description: - "Cheap complete inventory of resources owned by this source. Reads local metadata only; never refreshes quota or contacts providers. IDs are stable and source-local. Resource order is display order. Shared label preserves empty groups. Resources may disappear between list and fetch.", - }, - [usageFetchMethod]: { - input: usageFetchInputSchema, - output: usageMeasurementSchema, - experimental_description: - "Returns actual usage for exactly one listed resource, even when refresh is false. False permits cached observations; true requests a fresh attempt. Never collects other resources as a side effect. A removed resource fails the RPC; consumers relist. Per-account authentication and collection failures are usage states. observedAt is the last successful measurement time.", - }, -}); diff --git a/plugins/provider-codex/src/usage-source.test.ts b/plugins/provider-codex/src/usage-source.test.ts deleted file mode 100644 index 11c7d9a901..0000000000 --- a/plugins/provider-codex/src/usage-source.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { expect, it, vi } from "vitest"; -import { - createFakePluginHost, - makeHostResponse, -} from "@get-bb/plugin-sdk/testing"; -import { registerUsageSource } from "./usage-source.js"; -import { - usageMeasurementSchema, - usageResourceListSchema, - usageListMethod, - usageFetchMethod, -} from "./usage-contract.js"; - -it("publishes usage independently of displays and isolates disconnected hosts", async () => { - const collect = vi.fn(async () => ({ - codex: { - status: "ok" as const, - accountEmail: "user@example.com", - planLabel: "Team", - windows: [{ label: "Weekly", usedPercent: 42, resetsAt: null }], - }, - })); - const { bb, harness } = createFakePluginHost({ - sdk: { - hosts: { - list: async () => [ - makeHostResponse({ id: "online", status: "connected" }), - makeHostResponse({ id: "offline", status: "disconnected" }), - ], - }, - system: { usageLimits: collect }, - }, - }); - try { - registerUsageSource(bb); - const inventory = usageResourceListSchema.parse( - await harness.behavior.callRpc(usageListMethod, {}), - ); - expect(inventory.resources.map((resource) => resource.id)).toEqual([ - "online", - "offline", - ]); - expect(collect).not.toHaveBeenCalled(); - const read = async (resourceId: string, refresh: boolean) => - usageMeasurementSchema.parse( - await harness.behavior.callRpc(usageFetchMethod, { - resourceId, - refresh, - }), - ); - expect(await read("online", false)).toMatchObject({ - usage: { status: "ok", windows: [{ usedPercent: 42 }] }, - }); - expect(collect).toHaveBeenCalledWith({ - hostId: "online", - providerId: "codex", - }); - await read("online", false); - expect(collect).toHaveBeenCalledTimes(1); - await read("online", true); - expect(collect).toHaveBeenCalledTimes(2); - expect(await read("offline", false)).toMatchObject({ - observedAt: null, - usage: { status: "error" }, - }); - expect(collect).toHaveBeenCalledTimes(2); - await expect(read("removed", false)).rejects.toThrow("no longer exists"); - } finally { - await harness.lifecycle.dispose(); - } -}); diff --git a/plugins/provider-codex/src/usage-source.ts b/plugins/provider-codex/src/usage-source.ts deleted file mode 100644 index b655e0e17d..0000000000 --- a/plugins/provider-codex/src/usage-source.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { BbPluginApi } from "@get-bb/plugin-sdk"; -import { - usageSourceRpcContract, - usageListMethod, - usageFetchMethod, - type UsageResource, -} from "./usage-contract.js"; -export function registerUsageSource(bb: BbPluginApi) { - const cache = new Map(); - const pending = new Map>(); - bb.rpc.register( - usageSourceRpcContract, - { - async [usageListMethod]() { - const hosts = await bb.sdk.hosts.list(); - for (const id of cache.keys()) - if (!hosts.some((host) => host.id === id)) cache.delete(id); - return { - resources: hosts.map((host) => ({ - id: host.id, - providerId: "codex", - label: "Codex", - scope: { - kind: "host" as const, - hostId: host.id, - hostName: host.name, - }, - })), - }; - }, - async [usageFetchMethod]({ resourceId, refresh }) { - const host = (await bb.sdk.hosts.list()).find( - (host) => host.id === resourceId, - ); - if (!host) throw new Error("Usage resource no longer exists."); - const previous = cache.get(host.id); - const base = { - id: host.id, - providerId: "codex", - label: "Codex", - scope: { - kind: "host" as const, - hostId: host.id, - hostName: host.name, - }, - observedAt: previous?.observedAt ?? null, - }; - if (host.status === "disconnected") { - return { - ...base, - usage: { - status: "error" as const, - accountEmail: null, - planLabel: null, - message: "Machine is disconnected.", - }, - }; - } - if ( - !refresh && - previous?.usage.status === "ok" && - previous.observedAt !== null && - Date.now() - previous.observedAt < 60_000 - ) { - return { ...previous, scope: base.scope }; - } - const running = pending.get(host.id); - if (running !== undefined) return running; - const load = (async (): Promise => { - try { - const result = await bb.sdk.system.usageLimits({ - hostId: host.id, - providerId: "codex", - }); - const usage = result["codex"]; - if (usage === undefined) - throw new Error("Provider returned no usage information."); - const resource = { - ...base, - observedAt: usage.status === "ok" ? Date.now() : base.observedAt, - usage: - usage.status === "ok" - ? { - ...usage, - windows: usage.windows.map((window, index) => ({ - ...window, - id: `${index}:${window.label}`, - model: null, - cost: window.cost ?? null, - })), - } - : usage.status === "error" - ? usage - : { - status: usage.status, - accountEmail: null, - planLabel: null, - }, - }; - cache.set(host.id, resource); - return resource; - } catch { - return { - ...base, - usage: { - status: "error", - accountEmail: null, - planLabel: null, - message: "Usage could not be collected from this machine.", - }, - }; - } - })().finally(() => pending.delete(host.id)); - pending.set(host.id, load); - return load; - }, - }, - { - experimental_discoverable: true, - experimental_description: - "Codex usage from host-local credentials. Inventory never collects usage.", - }, - ); -} diff --git a/plugins/provider-usage-sources/.gitignore b/plugins/provider-usage-sources/.gitignore new file mode 100644 index 0000000000..1eae0cf670 --- /dev/null +++ b/plugins/provider-usage-sources/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/plugins/provider-usage-sources/README.md b/plugins/provider-usage-sources/README.md new file mode 100644 index 0000000000..6990abef77 --- /dev/null +++ b/plugins/provider-usage-sources/README.md @@ -0,0 +1,22 @@ +# Provider usage sources + +A headless adapter from existing provider maintenance usage to the contract +owned by Provider Usage. Bundled and enabled by default, independently of either +display. Any enabled provider declaring `maintenance.usage` is included without +implementing RPC methods or depending on this plugin. + +Inventory lists host/provider metadata without collecting quota. Fetch addresses +one opaque resource ID returned by inventory. It collects actual usage even when +`refresh` is false, with a 60-second cache; `refresh: true` requests a fresh +measurement. Concurrent requests share collection, while a forced request waits +for a fresh attempt if an ordinary collection is already running. + +Provider-owned maintenance extensions can include `accountKey`, `plan`, and +window `kind`/`model`. The adapter validates these against its copied usage +contract. Missing or invalid optional extensions become unknown identity, no +structured plan, or a custom window; existing display labels remain usable. +Core and the provider kit do not register, import, or interpret this contract. + +Use the Plugin Guide for the RPC API and published JSON Schemas for the exact +contract. Disable this adapter to replace it with another source implementation. +Neither Provider Usage nor a replacement display needs to be enabled to call it. diff --git a/plugins/provider-usage-sources/package.json b/plugins/provider-usage-sources/package.json new file mode 100644 index 0000000000..107cdf63c2 --- /dev/null +++ b/plugins/provider-usage-sources/package.json @@ -0,0 +1,39 @@ +{ + "name": "bb-plugin-provider-usage-sources", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Publish provider maintenance usage as discoverable resources.", + "engines": { + "bb": ">=0.0", + "bbPluginSdk": ">=0.4.56" + }, + "bb": { + "name": "Provider usage sources", + "description": "Publish provider maintenance usage as discoverable resources.", + "server": "./server.ts", + "branding": { + "icon": "ChartColumn" + }, + "skills": [ + "./skills" + ] + }, + "keywords": [ + "bb-plugin" + ], + "scripts": { + "test": "vitest run --config vitest.config.ts", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "npm:@typescript/typescript6@^6.0.2", + "typescript-7": "npm:typescript@^7.0.2", + "vitest": "^4.1.1" + }, + "dependencies": { + "@get-bb/plugin-sdk": "workspace:*", + "zod": "^4.3.6" + } +} diff --git a/plugins/provider-usage-sources/server.ts b/plugins/provider-usage-sources/server.ts new file mode 100644 index 0000000000..891c538e0b --- /dev/null +++ b/plugins/provider-usage-sources/server.ts @@ -0,0 +1,156 @@ +import type { BbPluginApi } from "@get-bb/plugin-sdk"; +import { z } from "zod"; +import { + usageSourceRpcContract, + usageListMethod, + usageFetchMethod, + usageMeasurementSchema, + usagePlanSchema, + usageWindowKindSchema, + type UsageMeasurement, +} from "./src/usage-contract.js"; + +const locatorSchema = z.tuple([z.string().min(1), z.string().min(1)]); +const metadataSchema = z.object({ + accountKey: z.string().min(1).nullable().catch(null), + plan: usagePlanSchema.nullable().catch(null), +}); +const windowMetadataSchema = z.object({ + kind: usageWindowKindSchema.catch("custom"), + model: z.string().nullable().catch(null), +}); + +export default function usageSourcesPlugin(bb: BbPluginApi) { + const cache = new Map(); + const pending = new Map< + string, + { refresh: boolean; promise: Promise } + >(); + const load = async ( + resourceId: string, + refresh: boolean, + ): Promise => { + const [hostId, providerId] = locatorSchema.parse(JSON.parse(resourceId)); + const host = (await bb.sdk.hosts.list()).find((host) => host.id === hostId); + if (!host) throw new Error("Usage resource no longer exists."); + const previous = cache.get(resourceId); + const unavailable = (message: string) => + usageMeasurementSchema.parse({ + accountKey: previous?.accountKey ?? null, + observedAt: previous?.observedAt ?? null, + usage: { + status: "error", + accountEmail: null, + planLabel: null, + message, + }, + }); + if (host.status === "disconnected") + return unavailable("Machine is disconnected."); + const providers = await bb.sdk.providers.list({ + hostId, + capability: "usage", + }); + if (!providers.some((provider) => provider.id === providerId)) + throw new Error("Usage resource no longer exists."); + if ( + !refresh && + previous?.usage.status === "ok" && + previous.observedAt !== null && + Date.now() - previous.observedAt < 60_000 + ) + return previous; + const promise = (async () => { + try { + const result = await bb.sdk.system.usageLimits({ hostId, providerId }); + const usage = result[providerId]; + if (!usage) throw new Error("Provider returned no usage information."); + const metadata = metadataSchema.parse(usage); + const value = usageMeasurementSchema.parse({ + accountKey: metadata.accountKey, + observedAt: + usage.status === "ok" ? Date.now() : (previous?.observedAt ?? null), + usage: { + accountEmail: null, + planLabel: null, + ...usage, + plan: metadata.plan, + ...(usage.status === "ok" + ? { + windows: usage.windows.map((window, index) => ({ + ...window, + ...windowMetadataSchema.parse(window), + id: `${index}:${window.label}`, + cost: window.cost ?? null, + })), + } + : {}), + }, + }); + cache.set(resourceId, value); + return value; + } catch { + return unavailable("Usage could not be collected from this machine."); + } + })(); + return promise; + }; + const collect = async ( + resourceId: string, + refresh: boolean, + ): Promise => { + const running = pending.get(resourceId); + if (running) { + if (!refresh || running.refresh) return running.promise; + await running.promise.catch(() => undefined); + return collect(resourceId, refresh); + } + const promise = load(resourceId, refresh).finally(() => + pending.delete(resourceId), + ); + pending.set(resourceId, { refresh, promise }); + return promise; + }; + bb.rpc.register( + usageSourceRpcContract, + { + async [usageListMethod]() { + const hosts = await bb.sdk.hosts.list(); + const resources = ( + await Promise.all( + hosts.map(async (host) => { + const providers = await bb.sdk.providers.list({ + hostId: host.id, + capability: "usage", + }); + return providers.map((provider) => { + const id = JSON.stringify([host.id, provider.id]); + return { + id, + accountKey: cache.get(id)?.accountKey ?? null, + providerId: provider.id, + label: provider.displayName, + scope: { + kind: "host" as const, + hostId: host.id, + hostName: host.name, + }, + }; + }); + }), + ) + ).flat(); + const ids = new Set(resources.map((resource) => resource.id)); + for (const id of cache.keys()) if (!ids.has(id)) cache.delete(id); + return { resources }; + }, + [usageFetchMethod]: ({ resourceId, refresh }) => + collect(resourceId, refresh), + }, + { + experimental_discoverable: true, + experimental_description: + "Host-local usage from providers declaring maintenance.usage. Inventory reads provider metadata only. Independent of display plugins.", + }, + ); +} diff --git a/plugins/provider-usage-sources/skills/provider-usage-sources/SKILL.md b/plugins/provider-usage-sources/skills/provider-usage-sources/SKILL.md new file mode 100644 index 0000000000..478c278c58 --- /dev/null +++ b/plugins/provider-usage-sources/skills/provider-usage-sources/SKILL.md @@ -0,0 +1,26 @@ +--- +name: provider-usage-sources +description: Inspect host-local usage resources published by the maintenance adapter. +--- + +# Inspect usage sources + +The bundled Provider usage sources plugin adapts every enabled provider declaring +`maintenance.usage`, including third-party providers. It is independent of the +Provider Usage display. Account Pooler publishes its shared resources separately. + +Inspect methods and schemas: + +```sh +bb plugin rpc inspect provider-usage-sources --json +bb plugin rpc list --method provider-usage.v1.listResources --json +``` + +Call `provider-usage.v1.listResources` with `{}` through +`bb plugin rpc call provider-usage-sources --input-file --json`. +Choose an opaque resource ID from that response and call +`provider-usage.v1.getResource` with `{ "resourceId": "", "refresh": false }`. +Listing is cheap metadata; fetching returns actual data for only that resource. +Use `refresh: true` for a fresh attempt. Machine disconnection, authentication, +and collection failures are distinct states. Unknown account identity is never +inferred from email. `bb settings usage` remains the direct maintenance view. diff --git a/plugins/provider-claude-code/src/usage-contract.ts b/plugins/provider-usage-sources/src/usage-contract.ts similarity index 84% rename from plugins/provider-claude-code/src/usage-contract.ts rename to plugins/provider-usage-sources/src/usage-contract.ts index c5255e5c42..1ef6a414af 100644 --- a/plugins/provider-claude-code/src/usage-contract.ts +++ b/plugins/provider-usage-sources/src/usage-contract.ts @@ -1,11 +1,23 @@ import { defineRpcContract } from "@get-bb/plugin-sdk"; import { z } from "zod"; +export const usagePlanSchema = z.object({ + id: z.string().min(1), + multiplier: z.number().int().positive().nullable(), +}); const accountFields = { + plan: usagePlanSchema.nullable().default(null), accountEmail: z.string().nullable(), planLabel: z.string().nullable(), }; +export const usageWindowKindSchema = z.enum([ + "five-hour", + "daily", + "weekly", + "custom", +]); const usageWindowSchema = z.object({ + kind: usageWindowKindSchema.default("custom"), id: z.string().min(1), label: z.string().min(1), usedPercent: z @@ -42,7 +54,16 @@ const usageSchema = z.discriminatedUnion("status", [ message: z.string(), }), ]); +export const usageAccountKeySchema = z + .string() + .min(1) + .nullable() + .default(null) + .describe( + "Provider-issued quota account identity, namespaced by issuer and account/organization scope. Never use email, a display label, a source-local ID, or credentials. Null means unknown; unknown accounts must not be merged.", + ); export const usageResourceSchema = z.object({ + accountKey: usageAccountKeySchema, id: z .string() .min(1) @@ -69,6 +90,7 @@ export const usageResourceListSchema = z.object({ resources: z.array(usageResourceSchema), }); export const usageMeasurementSchema = z.object({ + accountKey: usageAccountKeySchema, observedAt: z .number() .int() diff --git a/plugins/provider-usage-sources/src/usage-source.test.ts b/plugins/provider-usage-sources/src/usage-source.test.ts new file mode 100644 index 0000000000..013f3a43f6 --- /dev/null +++ b/plugins/provider-usage-sources/src/usage-source.test.ts @@ -0,0 +1,195 @@ +import { expect, it, vi } from "vitest"; +import { + createFakePluginHost, + makeHostResponse, +} from "@get-bb/plugin-sdk/testing"; +import plugin from "../server.js"; +import { + usageListMethod, + usageFetchMethod, + usageMeasurementSchema, + usageResourceListSchema, +} from "./usage-contract.js"; + +it("adapts arbitrary maintenance providers without a display and only measures the requested resource", async () => { + const collect = vi.fn(async () => ({ + custom: { + status: "ok" as const, + accountEmail: "same@example.com", + planLabel: "Custom subscription", + windows: [ + { label: "Tokens this month", usedPercent: 42, resetsAt: null }, + ], + }, + })); + let removed = false; + const { bb, harness } = createFakePluginHost({ + sdk: { + hosts: { + list: async () => [ + makeHostResponse({ id: "online", status: "connected" }), + makeHostResponse({ id: "offline", status: "disconnected" }), + ], + }, + providers: { + list: async () => + removed + ? [] + : [ + { id: "custom", displayName: "Custom" }, + { id: "another", displayName: "Another" }, + ], + }, + system: { usageLimits: collect }, + }, + }); + try { + plugin(bb); + const list = async () => + usageResourceListSchema.parse( + await harness.behavior.callRpc(usageListMethod, {}), + ); + const read = async (host: string, provider: string, refresh = false) => + usageMeasurementSchema.parse( + await harness.behavior.callRpc(usageFetchMethod, { + resourceId: JSON.stringify([host, provider]), + refresh, + }), + ); + expect((await list()).resources).toHaveLength(4); + expect(collect).not.toHaveBeenCalled(); + expect(await read("online", "custom")).toMatchObject({ + accountKey: null, + usage: { + status: "ok", + plan: null, + planLabel: "Custom subscription", + windows: [{ kind: "custom", label: "Tokens this month" }], + }, + }); + expect(collect).toHaveBeenCalledWith({ + hostId: "online", + providerId: "custom", + }); + await read("online", "custom"); + expect(collect).toHaveBeenCalledTimes(1); + await read("online", "custom", true); + expect(collect).toHaveBeenCalledTimes(2); + expect(await read("offline", "custom")).toMatchObject({ + observedAt: null, + usage: { status: "error" }, + }); + expect(collect).toHaveBeenCalledTimes(2); + removed = true; + expect((await list()).resources).toEqual([]); + await expect(read("online", "custom")).rejects.toThrow("no longer exists"); + } finally { + await harness.lifecycle.dispose(); + } +}); + +it("forwards validated provider-owned identity and normalization metadata while tolerating older providers", async () => { + const { bb, harness } = createFakePluginHost({ + sdk: { + hosts: { + list: async () => [ + makeHostResponse({ id: "host", status: "connected" }), + ], + }, + providers: { + list: async () => [{ id: "custom", displayName: "Custom" }], + }, + system: { + usageLimits: async () => ({ + custom: { + status: "ok", + accountKey: "issuer:organization:123", + accountEmail: "same@example.com", + planLabel: "max", + plan: { id: "max", multiplier: 20 }, + windows: [ + { + label: "168 hour window", + kind: "weekly", + model: "fable", + usedPercent: 50, + resetsAt: null, + }, + ], + }, + }), + }, + }, + }); + try { + plugin(bb); + const value = usageMeasurementSchema.parse( + await harness.behavior.callRpc(usageFetchMethod, { + resourceId: JSON.stringify(["host", "custom"]), + refresh: false, + }), + ); + expect(value).toMatchObject({ + accountKey: "issuer:organization:123", + usage: { + plan: { id: "max", multiplier: 20 }, + windows: [{ kind: "weekly", model: "fable" }], + }, + }); + const inventory = usageResourceListSchema.parse( + await harness.behavior.callRpc(usageListMethod, {}), + ); + expect(inventory.resources[0]?.accountKey).toBe(value.accountKey); + } finally { + await harness.lifecycle.dispose(); + } +}); + +it("coalesces concurrent reads and makes a forced refresh wait for a fresh collection", async () => { + let finish: (() => void) | undefined; + const gate = new Promise((resolve) => { + finish = resolve; + }); + const collect = vi.fn(async () => { + await gate; + return { + custom: { + status: "ok" as const, + accountEmail: null, + planLabel: null, + windows: [], + }, + }; + }); + const { bb, harness } = createFakePluginHost({ + sdk: { + hosts: { + list: async () => [ + makeHostResponse({ id: "host", status: "connected" }), + ], + }, + providers: { + list: async () => [{ id: "custom", displayName: "Custom" }], + }, + system: { usageLimits: collect }, + }, + }); + try { + plugin(bb); + const read = (refresh: boolean) => + harness.behavior.callRpc(usageFetchMethod, { + resourceId: JSON.stringify(["host", "custom"]), + refresh, + }); + const first = read(false); + const second = read(false); + const forced = read(true); + await vi.waitFor(() => expect(collect).toHaveBeenCalledTimes(1)); + finish!(); + await Promise.all([first, second, forced]); + expect(collect).toHaveBeenCalledTimes(2); + } finally { + finish?.(); + await harness.lifecycle.dispose(); + } +}); diff --git a/plugins/provider-usage-sources/tsconfig.json b/plugins/provider-usage-sources/tsconfig.json new file mode 100644 index 0000000000..6acac8aade --- /dev/null +++ b/plugins/provider-usage-sources/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "noEmit": true, + "skipLibCheck": true, + "paths": { + "@get-bb/plugin-sdk": [ + "../../packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts" + ], + "@get-bb/plugin-sdk/app": [ + "../../packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts" + ] + }, + "types": ["node"] + }, + "include": ["server.ts", "src", "vitest.config.ts"] +} diff --git a/plugins/provider-usage-sources/vitest.config.ts b/plugins/provider-usage-sources/vitest.config.ts new file mode 100644 index 0000000000..7ab217d248 --- /dev/null +++ b/plugins/provider-usage-sources/vitest.config.ts @@ -0,0 +1,15 @@ +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; + +export default defineWorkspaceTestConfig({ + test: { + silent: "passed-only", + projects: sharedWorkerProjects({ + pkgDir: __dirname, + name: "bb-plugin-provider-usage-sources", + include: ["src/**/*.test.ts"], + }), + }, +}); diff --git a/plugins/provider-usage/README.md b/plugins/provider-usage/README.md index ee77b4c97c..6ecb9abe6e 100644 --- a/plugins/provider-usage/README.md +++ b/plugins/provider-usage/README.md @@ -21,3 +21,9 @@ to inspect their published contracts. RPC calls accept JSON through `bb settings usage --json` and `bb.sdk.system.usageLimits()` remain the host-local provider-maintenance view; they do not aggregate shared pool accounts. + +The headless Provider usage sources plugin automatically adapts providers declaring +maintenance usage. The contract remains owned here; it is not part of the provider +kit or core runtime. Known provider-issued account identities are deduplicated +within the selected location. Unknown identities are never merged by email. +Structured plan and quota-window metadata give both displays consistent labels. diff --git a/plugins/provider-usage/server.test.ts b/plugins/provider-usage/server.test.ts index 2887aa2c3b..bec9e7514e 100644 --- a/plugins/provider-usage/server.test.ts +++ b/plugins/provider-usage/server.test.ts @@ -263,3 +263,92 @@ it("keeps an unconfigured shared group without hosts or measurement requests", a await host.harness.lifecycle.dispose(); } }); + +it("collapses known account observations per machine, preserves unknown identities, and normalizes display labels", async () => { + const { bb, harness } = createFakePluginHost({ + sdk: { + hosts: { + list: async () => [ + makeHostResponse({ id: "host", status: "connected" }), + ], + }, + providers: { list: async () => [] }, + plugins: { + experimental_discoverRpc: async () => [ + { pluginId: "adapter", displayName: "Adapter" }, + { pluginId: "custom", displayName: "Custom" }, + ], + callRpc: async ({ pluginId, method }) => + method === usageListMethod + ? { + resources: [ + { + id: "account", + providerId: "codex", + accountKey: null, + label: "same@example.com", + scope: { kind: "host", hostId: "host", hostName: "Host" }, + }, + { + id: "unknown", + providerId: "other", + accountKey: null, + label: "same@example.com", + scope: { kind: "host", hostId: "host", hostName: "Host" }, + }, + ], + } + : { + accountKey: "issuer:account:1", + observedAt: 123, + usage: { + status: "ok", + accountEmail: "same@example.com", + planLabel: "max", + plan: { id: "max", multiplier: 20 }, + windows: [ + { + id: "week", + kind: "weekly", + label: "168 hour window", + model: null, + resetsAt: null, + cost: null, + usedPercent: pluginId === "adapter" ? 42 : 81, + }, + ], + }, + }, + }, + }, + }); + try { + plugin(bb); + const snapshot = await harness.behavior.callRpc("getUsage", { + force: false, + machineIds: ["host"], + providerId: "codex", + maxAgeMs: 0, + }); + expect(snapshot).toMatchObject({ + machines: [ + { + providers: [ + { + id: "adapter:account", + usage: { + planLabel: "Max (20x)", + windows: [{ label: "Weekly limit", usedPercent: 42 }], + }, + }, + { id: "adapter:unknown", usage: null }, + { id: "custom:unknown", usage: null }, + ], + }, + ], + }); + expect(JSON.stringify(snapshot)).not.toContain("81"); + } finally { + await harness.lifecycle.dispose(); + } +}); diff --git a/plugins/provider-usage/server.ts b/plugins/provider-usage/server.ts index 5099a10275..3aa79d1bdb 100644 --- a/plugins/provider-usage/server.ts +++ b/plugins/provider-usage/server.ts @@ -1,3 +1,7 @@ +import { + normalizeUsageMeasurement, + selectUsageResources, +} from "./usage-normalization.js"; import { defineRpcContract, type BbPluginApi } from "@get-bb/plugin-sdk"; import { z } from "zod/mini"; import { @@ -188,7 +192,10 @@ export default function providerUsagePlugin(bb: BbPluginApi): void { outputSchema: usageSourceRpcContract[usageFetchMethod].output, signal: AbortSignal.timeout(45_000), }) - .then((value) => { + .then((raw) => { + const value = normalizeUsageMeasurement(raw); + if (value.usage.status === "error") + throw new Error("Usage could not be refreshed."); measurements.set(key, { value, loadedAt: Date.now() }); failures.delete(key); return value; @@ -294,6 +301,11 @@ export default function providerUsagePlugin(bb: BbPluginApi): void { providers: [], error: null, })); + const candidates: Array<{ + source: SourceResult; + resource: UsageResourceList["resources"][number]; + machineId: string; + }> = []; for (const source of inventories.values()) { const hasShared = source.label !== null || @@ -315,8 +327,21 @@ export default function providerUsagePlugin(bb: BbPluginApi): void { resource.scope.kind === "shared" ? `source:${source.pluginId}` : resource.scope.hostId; - const machine = machines.find((machine) => machine.id === machineId); - if (!machine) continue; + candidates.push({ source, resource, machineId }); + } + } + for (const machine of machines) { + const visible = selectUsageResources( + candidates.filter((candidate) => candidate.machineId === machine.id), + ({ source, resource }) => ({ + ...resource, + accountKey: measurements.has(keyOf(source.pluginId, resource.id)) + ? measurements.get(keyOf(source.pluginId, resource.id))!.value + .accountKey + : resource.accountKey, + }), + ); + for (const { source, resource } of visible) { const key = keyOf(source.pluginId, resource.id); const cached = measurements.get(key); machine.providers.push( diff --git a/plugins/provider-usage/usage-normalization.test.ts b/plugins/provider-usage/usage-normalization.test.ts new file mode 100644 index 0000000000..6cacfd074d --- /dev/null +++ b/plugins/provider-usage/usage-normalization.test.ts @@ -0,0 +1,96 @@ +import { expect, it } from "vitest"; +import { + normalizeUsageMeasurement, + selectUsageResources, +} from "./usage-normalization.js"; +import { usageMeasurementSchema } from "./usage-source-contract.js"; + +it("merges only matching provider-issued identities, preferring shared observations without summing limits", () => { + const host = { + id: "machine", + providerId: "custom", + accountKey: "issuer:account:1", + scope: { kind: "host" as const }, + percent: 40, + }; + const shared = { + ...host, + id: "pool", + scope: { kind: "shared" as const }, + percent: 50, + }; + const unknown = { ...host, id: "unknown", accountKey: null }; + const other = { ...host, id: "other", accountKey: "issuer:account:2" }; + const differentProvider = { ...shared, providerId: "different" }; + expect( + selectUsageResources( + [ + host, + shared, + unknown, + { ...unknown, id: "unknown2" }, + other, + differentProvider, + ], + (resource) => resource, + ), + ).toEqual([ + shared, + unknown, + { ...unknown, id: "unknown2" }, + other, + differentProvider, + ]); + expect(selectUsageResources([host], (resource) => resource)).toEqual([host]); +}); + +it("normalizes structured plans and windows while retaining custom provider labels", () => { + const raw = usageMeasurementSchema.parse({ + observedAt: 123, + usage: { + status: "ok", + accountEmail: null, + planLabel: "max", + plan: { id: "max", multiplier: 20 }, + windows: [ + { + id: "weekly", + kind: "weekly", + label: "168 hour window", + model: null, + usedPercent: 50, + resetsAt: null, + cost: null, + }, + { + id: "fable", + kind: "weekly", + label: "Fable", + model: "fable", + usedPercent: 25, + resetsAt: null, + cost: null, + }, + { + id: "custom", + label: "Monthly tokens", + model: null, + usedPercent: 5, + resetsAt: null, + cost: null, + }, + ], + }, + }); + expect(normalizeUsageMeasurement(raw)).toMatchObject({ + accountKey: null, + usage: { + planLabel: "Max (20x)", + windows: [ + { label: "Weekly limit" }, + { label: "Weekly · Fable" }, + { label: "Monthly tokens" }, + ], + }, + }); +}); diff --git a/plugins/provider-usage/usage-normalization.ts b/plugins/provider-usage/usage-normalization.ts new file mode 100644 index 0000000000..9b6f1dfc68 --- /dev/null +++ b/plugins/provider-usage/usage-normalization.ts @@ -0,0 +1,78 @@ +import type { UsageMeasurement } from "./usage-source-contract.js"; + +export function normalizeUsageMeasurement( + measurement: UsageMeasurement, +): UsageMeasurement { + if (measurement.usage.status !== "ok") return measurement; + const usage = measurement.usage; + const labels: Record = { + free: "Free", + go: "Go", + plus: "Plus", + pro: "Pro", + max: "Max", + team: "Team", + business: "Business", + enterprise: "Enterprise", + education: "Education", + edu: "Education", + }; + const plan = usage.plan; + const planName = plan ? labels[plan.id] : undefined; + const windowLabels = { + "five-hour": "Five-hour limit", + daily: "Daily limit", + weekly: "Weekly limit", + custom: "", + }; + return { + ...measurement, + usage: { + ...usage, + planLabel: planName + ? `${planName}${plan?.multiplier == null ? "" : ` (${plan.multiplier}x)`}` + : usage.planLabel, + windows: usage.windows.map((window) => ({ + ...window, + label: + window.kind && window.kind !== "custom" + ? window.model + ? `${window.kind === "weekly" ? "Weekly" : windowLabels[window.kind]} · ${window.model.charAt(0).toUpperCase() + window.model.slice(1)}` + : windowLabels[window.kind] + : window.label, + })), + }, + }; +} + +type Identity = { + providerId: string; + accountKey?: string | null; + scope: { kind: "shared" | "host" }; +}; + +export function selectUsageResources( + resources: readonly T[], + identify: (resource: T) => Identity, +): T[] { + const result: T[] = []; + const known = new Map(); + for (const resource of resources) { + const identity = identify(resource); + if (!identity.accountKey) { + result.push(resource); + continue; + } + const key = JSON.stringify([identity.providerId, identity.accountKey]); + const index = known.get(key); + if (index === undefined) { + known.set(key, result.length); + result.push(resource); + } else if ( + identity.scope.kind === "shared" && + identify(result[index]!).scope.kind !== "shared" + ) + result[index] = resource; + } + return result; +} diff --git a/plugins/provider-usage/usage-source-contract.ts b/plugins/provider-usage/usage-source-contract.ts index c5255e5c42..1ef6a414af 100644 --- a/plugins/provider-usage/usage-source-contract.ts +++ b/plugins/provider-usage/usage-source-contract.ts @@ -1,11 +1,23 @@ import { defineRpcContract } from "@get-bb/plugin-sdk"; import { z } from "zod"; +export const usagePlanSchema = z.object({ + id: z.string().min(1), + multiplier: z.number().int().positive().nullable(), +}); const accountFields = { + plan: usagePlanSchema.nullable().default(null), accountEmail: z.string().nullable(), planLabel: z.string().nullable(), }; +export const usageWindowKindSchema = z.enum([ + "five-hour", + "daily", + "weekly", + "custom", +]); const usageWindowSchema = z.object({ + kind: usageWindowKindSchema.default("custom"), id: z.string().min(1), label: z.string().min(1), usedPercent: z @@ -42,7 +54,16 @@ const usageSchema = z.discriminatedUnion("status", [ message: z.string(), }), ]); +export const usageAccountKeySchema = z + .string() + .min(1) + .nullable() + .default(null) + .describe( + "Provider-issued quota account identity, namespaced by issuer and account/organization scope. Never use email, a display label, a source-local ID, or credentials. Null means unknown; unknown accounts must not be merged.", + ); export const usageResourceSchema = z.object({ + accountKey: usageAccountKeySchema, id: z .string() .min(1) @@ -69,6 +90,7 @@ export const usageResourceListSchema = z.object({ resources: z.array(usageResourceSchema), }); export const usageMeasurementSchema = z.object({ + accountKey: usageAccountKeySchema, observedAt: z .number() .int() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9bf6444a38..9569ca0b47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3995,6 +3995,28 @@ importers: specifier: ^4.1.1 version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.4.0))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) + plugins/provider-usage-sources: + dependencies: + '@get-bb/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + zod: + specifier: 4.3.6 + version: 4.3.6 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.10 + typescript: + specifier: npm:@typescript/typescript6@^6.0.2 + version: '@typescript/typescript6@6.0.2' + typescript-7: + specifier: npm:typescript@^7.0.2 + version: typescript@7.0.2 + vitest: + specifier: ^4.1.1 + version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.4.0))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) + plugins/push-notifications: dependencies: undici: diff --git a/turbo.json b/turbo.json index f7c98d453d..baf88959a5 100644 --- a/turbo.json +++ b/turbo.json @@ -932,6 +932,9 @@ "bb-plugin-provider-pi#typecheck": { "dependsOn": ["@get-bb/plugin-sdk#build:types", "topo"] }, + "bb-plugin-provider-usage-sources#typecheck": { + "dependsOn": ["@get-bb/plugin-sdk#build:types", "topo"] + }, "bb-plugin-provider-usage#typecheck": { "dependsOn": ["@get-bb/plugin-sdk#build:types", "topo"] }, From f92a017e617a9adc8c6266ff700d0946608349dd Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 11 Sep 2026 10:14:37 -0700 Subject: [PATCH 19/53] Implement usage contracts explicitly in provider plugins --- .../features/plugin-provider-usage.md | 18 +- .../src/services/plugins/builtin-registry.ts | 5 - .../services/plugins/builtin-plugins.test.ts | 1 - ...iscoverable-rpc-and-provider-usage-plan.md | 34 +-- plugins/bb-official.json | 4 - plugins/provider-acp/package.json | 3 +- plugins/provider-acp/server.ts | 2 + .../provider-acp/skills/acp-provider/SKILL.md | 10 + .../src/usage-contract.ts | 0 plugins/provider-acp/src/usage-source.test.ts | 215 +++++++++++++++++ .../src/usage-source.ts} | 43 ++-- plugins/provider-claude-code/server.ts | 2 + .../skills/claude-code-provider/SKILL.md | 2 +- .../src/usage-contract.ts | 132 +++++++++++ .../src/usage-source.test.ts | 223 ++++++++++++++++++ .../provider-claude-code/src/usage-source.ts | 169 +++++++++++++ plugins/provider-codex/server.ts | 2 + .../skills/codex-provider/SKILL.md | 2 +- plugins/provider-codex/src/usage-contract.ts | 132 +++++++++++ .../src/usage-source.test.ts | 54 +++-- plugins/provider-codex/src/usage-source.ts | 169 +++++++++++++ plugins/provider-usage-sources/.gitignore | 2 - plugins/provider-usage-sources/README.md | 22 -- plugins/provider-usage-sources/package.json | 39 --- .../skills/provider-usage-sources/SKILL.md | 26 -- plugins/provider-usage-sources/tsconfig.json | 21 -- .../provider-usage-sources/vitest.config.ts | 15 -- plugins/provider-usage/README.md | 14 +- pnpm-lock.yaml | 22 -- turbo.json | 3 - 30 files changed, 1159 insertions(+), 227 deletions(-) rename plugins/{provider-usage-sources => provider-acp}/src/usage-contract.ts (100%) create mode 100644 plugins/provider-acp/src/usage-source.test.ts rename plugins/{provider-usage-sources/server.ts => provider-acp/src/usage-source.ts} (81%) create mode 100644 plugins/provider-claude-code/src/usage-contract.ts create mode 100644 plugins/provider-claude-code/src/usage-source.test.ts create mode 100644 plugins/provider-claude-code/src/usage-source.ts create mode 100644 plugins/provider-codex/src/usage-contract.ts rename plugins/{provider-usage-sources => provider-codex}/src/usage-source.test.ts (76%) create mode 100644 plugins/provider-codex/src/usage-source.ts delete mode 100644 plugins/provider-usage-sources/.gitignore delete mode 100644 plugins/provider-usage-sources/README.md delete mode 100644 plugins/provider-usage-sources/package.json delete mode 100644 plugins/provider-usage-sources/skills/provider-usage-sources/SKILL.md delete mode 100644 plugins/provider-usage-sources/tsconfig.json delete mode 100644 plugins/provider-usage-sources/vitest.config.ts diff --git a/.bb/skills/verify-bb/features/plugin-provider-usage.md b/.bb/skills/verify-bb/features/plugin-provider-usage.md index ba524a32f6..e28cfa993c 100644 --- a/.bb/skills/verify-bb/features/plugin-provider-usage.md +++ b/.bb/skills/verify-bb/features/plugin-provider-usage.md @@ -4,7 +4,7 @@ Status: **2026-09-05: 2 passed, 1 partial/blocked**. See [the audit](../MAINTENA ## Setup and entry points -Enable Provider usage and the bundled Provider usage sources adapter; configure at least one provider advertising usage maintenance. Open its usage card and Settings → Usage. +Enable Provider usage and a provider implementing the usage RPC contract. Open its usage card and Settings → Usage. Use the main skill’s isolated targets and evidence rules. A plugin can be present in this checkout but disabled in an installation. Enable it only in the test @@ -17,7 +17,9 @@ SKILL.md. Inspect nested `--help` before selecting flags and IDs. - `plugins/provider-usage/package.json` - `plugins/provider-usage/server.ts` - `plugins/provider-usage/app.tsx` -- `plugins/provider-usage-sources/server.ts` +- `plugins/provider-codex/src/usage-source.ts` +- `plugins/provider-claude-code/src/usage-source.ts` +- `plugins/provider-acp/src/usage-source.ts` - `plugins/account-pool/src/usage-source.ts` - `apps/app/src/components/settings/UsageLimitsSettingsSection.tsx` @@ -27,7 +29,7 @@ SKILL.md. Inspect nested `--help` before selecting flags and IDs. | --- | --- | --- | | All capable providers | Refresh with two supported providers and one unsupported provider. | Cards show only supported data using current provider names/icons and configured ordering. | | Quota windows and errors | Inspect real returned windows/resets and a controlled refresh failure. | Values match the provider response; unknown/unavailable data is distinct from exhausted quota. | -| CLI and SDK parity | Inspect `bb plugin rpc list --method provider-usage.v1.listResources --json`, list the adapter resources, and fetch one returned resource ID. Compare its selected host/provider with `bb settings usage --json`. | Discovery is independent of display plugins, inventory collects no quota, and fetch returns only the chosen resource. Pool resources remain separate from direct host maintenance. | +| CLI and SDK parity | Inspect `bb plugin rpc list --method provider-usage.v1.listResources --json`, list a provider plugin’s resources, and fetch one returned resource ID. Compare its selected host/provider with `bb settings usage --json`. | Discovery is independent of display plugins, inventory collects no quota, and fetch returns only the chosen resource. Pool resources remain separate from direct host maintenance. | ## Evidence and cleanup @@ -45,13 +47,13 @@ External account changes use authorized disposable targets. ## Usage source prototype follow-up (2026-09-11) - Passed live: pool defaults on both displays, provider tabs and pooled account ordering, - matching weekly/model/plan labels, explicit machine selection, and automatic Cursor - maintenance adaptation without changing its provider. Screenshot evidence is in the - implementing thread’s `usage-review/normalization.md`. -- Passed targeted tests: arbitrary maintenance providers, no collection during inventory, + matching weekly/model/plan labels, explicit machine selection, and Cursor + usage published directly by the ACP provider plugin. Screenshot evidence is in the + implementing thread’s `usage-review/explicit-providers.md`. +- Passed targeted tests: provider ownership filtering, no collection during inventory, removed resources, disconnected hosts, request coalescing, force refresh, empty pools, first-load/stale failures, known identity deduplication, and unknown-identity separation. -- A new source should be tested with the display plugin disabled. The adapter has no +- A new source should be tested with the display plugin disabled. Each provider implementation has no dependency on the display and publishes both source methods from its own registration. - Do not deduplicate by email. Filter by selected location before normalizing observations; explicit machine selection must remain available even when that account exists in a pool. diff --git a/apps/server/src/services/plugins/builtin-registry.ts b/apps/server/src/services/plugins/builtin-registry.ts index f4b031de60..f6146c9332 100644 --- a/apps/server/src/services/plugins/builtin-registry.ts +++ b/apps/server/src/services/plugins/builtin-registry.ts @@ -103,11 +103,6 @@ export const BUILTIN_PLUGINS = [ pluginId: "provider-pi", defaultEnabled: true, }, - { - name: "provider-usage-sources", - pluginId: "provider-usage-sources", - defaultEnabled: true, - }, { name: "provider-usage", pluginId: "provider-usage", diff --git a/apps/server/test/services/plugins/builtin-plugins.test.ts b/apps/server/test/services/plugins/builtin-plugins.test.ts index 9e0b8ff37e..1490bacfe4 100644 --- a/apps/server/test/services/plugins/builtin-plugins.test.ts +++ b/apps/server/test/services/plugins/builtin-plugins.test.ts @@ -263,7 +263,6 @@ describe("builtin plugin reconciliation", () => { ["provider-pi", "./icons/pi.svg"], ["provider-retry", "ArrowReloadHorizontal"], ["provider-usage", "ChartColumn"], - ["provider-usage-sources", "ChartColumn"], ["push-notifications", "BellDot"], ["scheduled-send", "Calendar"], ["secrets", "Lock"], diff --git a/docs/discoverable-rpc-and-provider-usage-plan.md b/docs/discoverable-rpc-and-provider-usage-plan.md index 3732442d1b..9f3a05732d 100644 --- a/docs/discoverable-rpc-and-provider-usage-plan.md +++ b/docs/discoverable-rpc-and-provider-usage-plan.md @@ -1,6 +1,6 @@ # Discoverable RPC and replaceable provider usage displays -Status: prototype implemented for discoverable RPC, Account Pooler, the generic Provider usage sources adapter, and the core `/settings/usage` page. Provider Usage defines the canonical contract in `plugins/provider-usage/usage-source-contract.ts` and consumes discovered sources through it. The core settings page preserves its existing provider cards and extends the machine picker to select shared sources such as Account Pooler. Shared sources are selected by default. +Status: prototype implemented for discoverable RPC, Account Pooler, the Codex, Claude Code, and ACP provider plugins, and the core `/settings/usage` page. Provider Usage defines the canonical contract in `plugins/provider-usage/usage-source-contract.ts` and consumes discovered sources through it. The core settings page preserves its existing provider cards and extends the machine picker to select shared sources such as Account Pooler. Shared sources are selected by default. ## Prototype verification @@ -149,8 +149,8 @@ Account Pooler lists account metadata without refreshing, then calls its existin ### Source implementations - Account Pooler exposes its accounts and existing quota state with shared scope. Refresh delegates to its existing collection logic; it does not alter routing. -- The headless Provider usage sources adapter exposes host-local resources for every provider declaring `maintenance.usage`, using the existing SDK maintenance API. A disconnected host or failed account collection becomes an individual resource outcome and does not discard other results. -- Both source plugins register the discoverable methods regardless of whether Provider Usage is installed or enabled. +- Codex, Claude Code, and ACP explicitly implement the contract for their own usage-capable providers, using the existing SDK maintenance API. A disconnected host or failed account collection becomes an individual resource outcome and does not discard other results. +- All source plugins register the discoverable methods regardless of whether Provider Usage is installed or enabled. ### Display implementation @@ -164,14 +164,14 @@ An alternative display uses the same discovery query and its own copied response Preserve `bb.sdk.system.usageLimits()` and `bb settings usage --json` as existing host-provider maintenance views during the first rollout. Do not silently change their response shape or use them as the unified view. -Provider Usage exposes its display snapshot through `bb plugin rpc call provider-usage getUsage --input-file --json`. The private display request includes `force`, nullable `machineIds`, nullable `providerId`, and `maxAgeMs`; null providerId lists metadata without collecting usage. Source fetch requests use a JSON file containing `resourceId` and `refresh`. Consumers needing replacement-independent data can discover and call the source methods directly. Provider Usage must stop collecting the same host usage independently once the adapter supplies it through discovery. +Provider Usage exposes its display snapshot through `bb plugin rpc call provider-usage getUsage --input-file --json`. The private display request includes `force`, nullable `machineIds`, nullable `providerId`, and `maxAgeMs`; null providerId lists metadata without collecting usage. Source fetch requests use a JSON file containing `resourceId` and `refresh`. Consumers needing replacement-independent data can discover and call the source methods directly. Provider Usage must stop collecting the same host usage independently once its provider plugin supplies it through discovery. ## Delivery sequence 1. **Schema publication:** implement export support and registration validation. Verify portable wire schemas for real usage types and unchanged anonymous RPC behavior. 2. **Registry and inspection:** add opt-in publication, lifecycle-safe descriptors, targeted discovery route, SDK query, and CLI listing/inspection. 3. **Contracts and documentation:** publish copyable examples, method naming guidance, schema limitations, and lifecycle semantics in Plugin Guide. Add SDK surfaces to `packages/plugin-api-map/src/surfaces.ts` and audit entries to `docs/api_to_audit.md`; update CLI guide templates and skills. -4. **Usage sources:** implement the convention in Account Pooler and the independent maintenance adapter using existing collection primitives. +4. **Usage sources:** implement the convention in Account Pooler and the provider plugins using existing collection primitives. 5. **Usage display:** migrate Provider Usage to discovery, expose the unified snapshot through its RPC/CLI, and verify a second display consumer against the same sources. Keep the work server-side unless inspection proves host wire changes are necessary. Existing host usage maintenance remains a primitive. If any server/daemon wire fields change, increment `HOST_DAEMON_PROTOCOL_VERSION` unless previous-daemon compatibility is deliberately preserved and tested. @@ -203,15 +203,19 @@ The result is complete when discovery and inspection are generally usable, usage Usage-state review: both consumers distinguish loading, empty shared groups, unavailable sources, uninstalled providers, per-account authentication/collection failures, plans without reported limits, and offline machines. Shared-account sign-in guidance refers to the source plugin’s settings. Failed source refreshes preserve successful cached observations with a visible notice; disabled sources disappear on discovery reconciliation. Browser fixtures exercise source selection, removal, and retry recovery without changing configured accounts. Unfiltered CLI discovery omits undefined filters instead of serializing them into literal query values. -## Plugin-owned maintenance adapter and normalization +## Explicit provider implementations and normalization -Provider Usage owns the canonical contract. Provider usage sources is a separate, -headless, default-enabled bundled plugin that copies it. It discovers host/provider -metadata through `bb.sdk.providers.list({hostId, capability: "usage"})` and fetches -only the requested pair through `bb.sdk.system.usageLimits({hostId, providerId})`. -Codex and Claude Code no longer register separate usage RPC methods. Other providers -declaring maintenance usage work automatically; no provider kit or core adapter is -added. A replacement display can consume the same sources without Provider Usage. +Provider Usage owns the canonical contract. Codex, Claude Code, and ACP copy it +into their own source and explicitly register the two methods. Each implementation +filters inventory and fetches by its own plugin ownership; ACP includes its dynamically +configured usage-capable agents. Collection uses the existing targeted maintenance +SDK API. Account Pooler implements the same contract for its shared accounts. + +There is no extra adapter plugin. Declaring `maintenance.usage` alone does not +publish this contract; other provider authors must implement it explicitly. The +small amount of source duplication is intentional while the broader provider-contract +design develops. The provider kit and core runtime do not import this contract. +A replacement display can consume these sources without enabling Provider Usage. Resource IDs are opaque source-local addresses. `accountKey` is a nullable, provider-issued quota identity, namespaced by issuer and account/organization @@ -222,11 +226,11 @@ selection happens first: shared sources are the default, and an explicit machine selection shows that machine, even when it observes the same account as a pool. Inventory may return an unknown key until the first measurement. The measurement's -identity is authoritative. The adapter remembers it for later cheap inventory; +identity is authoritative. Each source remembers it for later cheap inventory; it never reads credentials merely to list resources. Codex and Claude Code add provider-owned identity and normalization metadata to their existing passthrough maintenance responses. Core transports those extensions without interpreting them. -Older providers remain compatible and report unknown identity/custom labels. +Implementations without optional normalization metadata remain compatible and report unknown identity/custom labels. Known windows carry `kind` (`five-hour`, `daily`, `weekly`, or `custom`) alongside an optional model family. Known plans carry `{id, multiplier}` alongside their diff --git a/plugins/bb-official.json b/plugins/bb-official.json index 9afb824999..c158ee66d8 100644 --- a/plugins/bb-official.json +++ b/plugins/bb-official.json @@ -141,9 +141,5 @@ "environment-modal-sandbox": { "category": "environments", "screenshots": [] - }, - "provider-usage-sources": { - "category": "agents-and-providers", - "screenshots": [] } } diff --git a/plugins/provider-acp/package.json b/plugins/provider-acp/package.json index a16f299175..8b0ad163b5 100644 --- a/plugins/provider-acp/package.json +++ b/plugins/provider-acp/package.json @@ -5,7 +5,8 @@ "type": "module", "description": "Run bb threads with ACP agents (supports Cursor, opencode, omp and more).", "engines": { - "bb": ">=0.0" + "bb": ">=0.0", + "bbPluginSdk": ">=0.4.56" }, "bb": { "name": "ACP providers", diff --git a/plugins/provider-acp/server.ts b/plugins/provider-acp/server.ts index 568a89d56e..3b8e9226b0 100644 --- a/plugins/provider-acp/server.ts +++ b/plugins/provider-acp/server.ts @@ -1,3 +1,4 @@ +import { registerUsageSource } from "./src/usage-source.js"; import type { BbPluginApi, PluginProviderDeclaration, @@ -43,6 +44,7 @@ async function sleepUntilAbort(ms: number, signal: AbortSignal): Promise { export default async function acpProvidersPlugin( bb: BbPluginApi, ): Promise { + registerUsageSource(bb); const host = bb.hosts.experimental_client({ contract: acpHostContract }); const settings = bb.settings.define({ customAgents: { diff --git a/plugins/provider-acp/skills/acp-provider/SKILL.md b/plugins/provider-acp/skills/acp-provider/SKILL.md index 515f84e379..fc25922e14 100644 --- a/plugins/provider-acp/skills/acp-provider/SKILL.md +++ b/plugins/provider-acp/skills/acp-provider/SKILL.md @@ -21,3 +21,13 @@ models selectable through BB's model field. OpenCode ACP supports the core `bb thread compact` command; Cursor ACP does not expose compatible compaction. Check the actual agent's capabilities before attempting provider-specific recovery. + +## Usage resources + +This plugin directly implements Provider Usage's discoverable +`provider-usage.v1.listResources` and `provider-usage.v1.getResource` contracts for +its own usage-capable ACP agents, including Cursor. Listing is cheap metadata; +fetching measures only the returned host/provider resource ID. Inspect the copied +contract with `bb plugin rpc inspect provider-acp --json`. No display plugin needs +to be enabled. Other provider plugins must explicitly implement the usage contract; +`maintenance.usage` alone does not publish RPC methods. diff --git a/plugins/provider-usage-sources/src/usage-contract.ts b/plugins/provider-acp/src/usage-contract.ts similarity index 100% rename from plugins/provider-usage-sources/src/usage-contract.ts rename to plugins/provider-acp/src/usage-contract.ts diff --git a/plugins/provider-acp/src/usage-source.test.ts b/plugins/provider-acp/src/usage-source.test.ts new file mode 100644 index 0000000000..e08b0cfde3 --- /dev/null +++ b/plugins/provider-acp/src/usage-source.test.ts @@ -0,0 +1,215 @@ +import { expect, it, vi } from "vitest"; +import { + createFakePluginHost, + makeHostResponse, +} from "@get-bb/plugin-sdk/testing"; +import { registerUsageSource as plugin } from "./usage-source.js"; +import { + usageListMethod, + usageFetchMethod, + usageMeasurementSchema, + usageResourceListSchema, +} from "./usage-contract.js"; + +it("publishes only its own maintenance providers without a display and only measures the requested resource", async () => { + const collect = vi.fn(async () => ({ + "acp-custom": { + status: "ok" as const, + accountEmail: "same@example.com", + planLabel: "Custom subscription", + windows: [ + { label: "Tokens this month", usedPercent: 42, resetsAt: null }, + ], + }, + })); + let removed = false; + const { bb, harness } = createFakePluginHost({ + sdk: { + hosts: { + list: async () => [ + makeHostResponse({ id: "online", status: "connected" }), + makeHostResponse({ id: "offline", status: "disconnected" }), + ], + }, + providers: { + list: async () => + removed + ? [] + : [ + { + id: "foreign", + displayName: "Foreign", + pluginId: "unrelated", + }, + { + id: "acp-custom", + displayName: "Custom", + pluginId: "provider-acp", + }, + { + id: "another", + displayName: "Another", + pluginId: "provider-acp", + }, + ], + }, + system: { usageLimits: collect }, + }, + }); + try { + plugin(bb); + const list = async () => + usageResourceListSchema.parse( + await harness.behavior.callRpc(usageListMethod, {}), + ); + const read = async (host: string, provider: string, refresh = false) => + usageMeasurementSchema.parse( + await harness.behavior.callRpc(usageFetchMethod, { + resourceId: JSON.stringify([host, provider]), + refresh, + }), + ); + expect((await list()).resources).toHaveLength(4); + expect(collect).not.toHaveBeenCalled(); + await expect(read("online", "foreign")).rejects.toThrow("no longer exists"); + expect(await read("online", "acp-custom")).toMatchObject({ + accountKey: null, + usage: { + status: "ok", + plan: null, + planLabel: "Custom subscription", + windows: [{ kind: "custom", label: "Tokens this month" }], + }, + }); + expect(collect).toHaveBeenCalledWith({ + hostId: "online", + providerId: "acp-custom", + }); + await read("online", "acp-custom"); + expect(collect).toHaveBeenCalledTimes(1); + await read("online", "acp-custom", true); + expect(collect).toHaveBeenCalledTimes(2); + expect(await read("offline", "acp-custom")).toMatchObject({ + observedAt: null, + usage: { status: "error" }, + }); + expect(collect).toHaveBeenCalledTimes(2); + removed = true; + expect((await list()).resources).toEqual([]); + await expect(read("online", "acp-custom")).rejects.toThrow( + "no longer exists", + ); + } finally { + await harness.lifecycle.dispose(); + } +}); + +it("forwards validated provider-owned identity and normalization metadata while tolerating older providers", async () => { + const { bb, harness } = createFakePluginHost({ + sdk: { + hosts: { + list: async () => [ + makeHostResponse({ id: "host", status: "connected" }), + ], + }, + providers: { + list: async () => [ + { id: "acp-custom", displayName: "Custom", pluginId: "provider-acp" }, + ], + }, + system: { + usageLimits: async () => ({ + "acp-custom": { + status: "ok", + accountKey: "issuer:organization:123", + accountEmail: "same@example.com", + planLabel: "max", + plan: { id: "max", multiplier: 20 }, + windows: [ + { + label: "168 hour window", + kind: "weekly", + model: "fable", + usedPercent: 50, + resetsAt: null, + }, + ], + }, + }), + }, + }, + }); + try { + plugin(bb); + const value = usageMeasurementSchema.parse( + await harness.behavior.callRpc(usageFetchMethod, { + resourceId: JSON.stringify(["host", "acp-custom"]), + refresh: false, + }), + ); + expect(value).toMatchObject({ + accountKey: "issuer:organization:123", + usage: { + plan: { id: "max", multiplier: 20 }, + windows: [{ kind: "weekly", model: "fable" }], + }, + }); + const inventory = usageResourceListSchema.parse( + await harness.behavior.callRpc(usageListMethod, {}), + ); + expect(inventory.resources[0]?.accountKey).toBe(value.accountKey); + } finally { + await harness.lifecycle.dispose(); + } +}); + +it("coalesces concurrent reads and makes a forced refresh wait for a fresh collection", async () => { + let finish: (() => void) | undefined; + const gate = new Promise((resolve) => { + finish = resolve; + }); + const collect = vi.fn(async () => { + await gate; + return { + "acp-custom": { + status: "ok" as const, + accountEmail: null, + planLabel: null, + windows: [], + }, + }; + }); + const { bb, harness } = createFakePluginHost({ + sdk: { + hosts: { + list: async () => [ + makeHostResponse({ id: "host", status: "connected" }), + ], + }, + providers: { + list: async () => [ + { id: "acp-custom", displayName: "Custom", pluginId: "provider-acp" }, + ], + }, + system: { usageLimits: collect }, + }, + }); + try { + plugin(bb); + const read = (refresh: boolean) => + harness.behavior.callRpc(usageFetchMethod, { + resourceId: JSON.stringify(["host", "acp-custom"]), + refresh, + }); + const first = read(false); + const second = read(false); + const forced = read(true); + await vi.waitFor(() => expect(collect).toHaveBeenCalledTimes(1)); + finish!(); + await Promise.all([first, second, forced]); + expect(collect).toHaveBeenCalledTimes(2); + } finally { + finish?.(); + await harness.lifecycle.dispose(); + } +}); diff --git a/plugins/provider-usage-sources/server.ts b/plugins/provider-acp/src/usage-source.ts similarity index 81% rename from plugins/provider-usage-sources/server.ts rename to plugins/provider-acp/src/usage-source.ts index 891c538e0b..890405a4fa 100644 --- a/plugins/provider-usage-sources/server.ts +++ b/plugins/provider-acp/src/usage-source.ts @@ -8,7 +8,7 @@ import { usagePlanSchema, usageWindowKindSchema, type UsageMeasurement, -} from "./src/usage-contract.js"; +} from "./usage-contract.js"; const locatorSchema = z.tuple([z.string().min(1), z.string().min(1)]); const metadataSchema = z.object({ @@ -20,7 +20,7 @@ const windowMetadataSchema = z.object({ model: z.string().nullable().catch(null), }); -export default function usageSourcesPlugin(bb: BbPluginApi) { +export function registerUsageSource(bb: BbPluginApi) { const cache = new Map(); const pending = new Map< string, @@ -51,7 +51,12 @@ export default function usageSourcesPlugin(bb: BbPluginApi) { hostId, capability: "usage", }); - if (!providers.some((provider) => provider.id === providerId)) + if ( + !providers.some( + (provider) => + provider.id === providerId && provider.pluginId === "provider-acp", + ) + ) throw new Error("Usage resource no longer exists."); if ( !refresh && @@ -123,20 +128,22 @@ export default function usageSourcesPlugin(bb: BbPluginApi) { hostId: host.id, capability: "usage", }); - return providers.map((provider) => { - const id = JSON.stringify([host.id, provider.id]); - return { - id, - accountKey: cache.get(id)?.accountKey ?? null, - providerId: provider.id, - label: provider.displayName, - scope: { - kind: "host" as const, - hostId: host.id, - hostName: host.name, - }, - }; - }); + return providers + .filter((provider) => provider.pluginId === "provider-acp") + .map((provider) => { + const id = JSON.stringify([host.id, provider.id]); + return { + id, + accountKey: cache.get(id)?.accountKey ?? null, + providerId: provider.id, + label: provider.displayName, + scope: { + kind: "host" as const, + hostId: host.id, + hostName: host.name, + }, + }; + }); }), ) ).flat(); @@ -150,7 +157,7 @@ export default function usageSourcesPlugin(bb: BbPluginApi) { { experimental_discoverable: true, experimental_description: - "Host-local usage from providers declaring maintenance.usage. Inventory reads provider metadata only. Independent of display plugins.", + "Host-local usage owned by the acp provider plugin. Inventory reads metadata only. Independent of display plugins.", }, ); } diff --git a/plugins/provider-claude-code/server.ts b/plugins/provider-claude-code/server.ts index 28e2ee95ee..bf08984c7f 100644 --- a/plugins/provider-claude-code/server.ts +++ b/plugins/provider-claude-code/server.ts @@ -1,3 +1,4 @@ +import { registerUsageSource } from "./src/usage-source.js"; import type { BbPluginApi } from "@get-bb/plugin-sdk"; import { CLAUDE_CODE_ACTIVE_CATALOG_DATA, @@ -7,6 +8,7 @@ import { import { CLAUDE_NATIVE_ROOTS_DECLARATION } from "./src/native-roots.js"; export default function plugin(bb: BbPluginApi) { + registerUsageSource(bb); bb.settings.define({ memoryEnabled: { type: "boolean", diff --git a/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md b/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md index ad1f6dbb40..a216f76b38 100644 --- a/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md +++ b/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md @@ -21,4 +21,4 @@ threads or change settings merely to answer a question. ## Discoverable usage -The bundled Provider usage sources plugin adapts this provider’s maintenance data into cheap `provider-usage.v1.listResources` inventory and `provider-usage.v1.getResource` measurements for independent usage displays. Fetch takes `{ resourceId, refresh }` and returns actual usage for that resource even when refresh is false; listing never collects quota. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect provider-usage-sources --method provider-usage.v1.listResources --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. +This plugin directly publishes cheap `provider-usage.v1.listResources` inventory and `provider-usage.v1.getResource` measurements for independent usage displays. Fetch takes `{ resourceId, refresh }` and returns actual usage for that resource even when refresh is false; listing never collects quota. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect provider-claude-code --method provider-usage.v1.listResources --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. diff --git a/plugins/provider-claude-code/src/usage-contract.ts b/plugins/provider-claude-code/src/usage-contract.ts new file mode 100644 index 0000000000..1ef6a414af --- /dev/null +++ b/plugins/provider-claude-code/src/usage-contract.ts @@ -0,0 +1,132 @@ +import { defineRpcContract } from "@get-bb/plugin-sdk"; +import { z } from "zod"; + +export const usagePlanSchema = z.object({ + id: z.string().min(1), + multiplier: z.number().int().positive().nullable(), +}); +const accountFields = { + plan: usagePlanSchema.nullable().default(null), + accountEmail: z.string().nullable(), + planLabel: z.string().nullable(), +}; +export const usageWindowKindSchema = z.enum([ + "five-hour", + "daily", + "weekly", + "custom", +]); +const usageWindowSchema = z.object({ + kind: usageWindowKindSchema.default("custom"), + id: z.string().min(1), + label: z.string().min(1), + usedPercent: z + .number() + .nonnegative() + .describe("Percentage consumed; may exceed 100 for overage."), + resetsAt: z + .string() + .nullable() + .describe("ISO timestamp, or null when no reset is known."), + model: z + .string() + .nullable() + .describe("Applicable model family, or null for all models."), + cost: z + .object({ + usedUsdCents: z.number().nonnegative(), + limitUsdCents: z.number().positive(), + }) + .nullable(), +}); +const usageSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("ok"), + ...accountFields, + windows: z.array(usageWindowSchema), + }), + z.object({ status: z.literal("not_installed"), ...accountFields }), + z.object({ status: z.literal("unauthenticated"), ...accountFields }), + z.object({ status: z.literal("expired"), ...accountFields }), + z.object({ + status: z.literal("error"), + ...accountFields, + message: z.string(), + }), +]); +export const usageAccountKeySchema = z + .string() + .min(1) + .nullable() + .default(null) + .describe( + "Provider-issued quota account identity, namespaced by issuer and account/organization scope. Never use email, a display label, a source-local ID, or credentials. Null means unknown; unknown accounts must not be merged.", + ); +export const usageResourceSchema = z.object({ + accountKey: usageAccountKeySchema, + id: z + .string() + .min(1) + .describe("Stable resource ID within this source plugin."), + providerId: z.string().min(1), + label: z.string().min(1), + scope: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("shared") }), + z.object({ + kind: z.literal("host"), + hostId: z.string().min(1), + hostName: z.string().min(1), + }), + ]), +}); +export const usageResourceListSchema = z.object({ + label: z + .string() + .min(1) + .optional() + .describe( + "Declares a shared group even when empty. Host-only sources omit it; machine groups use host names.", + ), + resources: z.array(usageResourceSchema), +}); +export const usageMeasurementSchema = z.object({ + accountKey: usageAccountKeySchema, + observedAt: z + .number() + .int() + .nonnegative() + .nullable() + .describe( + "Last successful measurement time in epoch milliseconds; null if never observed.", + ), + usage: usageSchema, +}); +export const usageListInputSchema = z.object({}); +export const usageFetchInputSchema = z.object({ + resourceId: z.string().min(1), + refresh: z + .boolean() + .describe( + "False permits a cached measurement but still returns actual usage. True requests a fresh collection attempt for this resource only.", + ), +}); +export type UsageResourceList = z.infer; +export type UsageMeasurement = z.infer; +export type UsageResource = z.infer & + UsageMeasurement; +export const usageListMethod = "provider-usage.v1.listResources"; +export const usageFetchMethod = "provider-usage.v1.getResource"; +export const usageSourceRpcContract = defineRpcContract({ + [usageListMethod]: { + input: usageListInputSchema, + output: usageResourceListSchema, + experimental_description: + "Cheap complete inventory of resources owned by this source. Reads local metadata only; never refreshes quota or contacts providers. IDs are stable and source-local. Resource order is display order. Shared label preserves empty groups. Resources may disappear between list and fetch.", + }, + [usageFetchMethod]: { + input: usageFetchInputSchema, + output: usageMeasurementSchema, + experimental_description: + "Returns actual usage for exactly one listed resource, even when refresh is false. False permits cached observations; true requests a fresh attempt. Never collects other resources as a side effect. A removed resource fails the RPC; consumers relist. Per-account authentication and collection failures are usage states. observedAt is the last successful measurement time.", + }, +}); diff --git a/plugins/provider-claude-code/src/usage-source.test.ts b/plugins/provider-claude-code/src/usage-source.test.ts new file mode 100644 index 0000000000..545a6f0623 --- /dev/null +++ b/plugins/provider-claude-code/src/usage-source.test.ts @@ -0,0 +1,223 @@ +import { expect, it, vi } from "vitest"; +import { + createFakePluginHost, + makeHostResponse, +} from "@get-bb/plugin-sdk/testing"; +import { registerUsageSource as plugin } from "./usage-source.js"; +import { + usageListMethod, + usageFetchMethod, + usageMeasurementSchema, + usageResourceListSchema, +} from "./usage-contract.js"; + +it("publishes only its own maintenance providers without a display and only measures the requested resource", async () => { + const collect = vi.fn(async () => ({ + "claude-code": { + status: "ok" as const, + accountEmail: "same@example.com", + planLabel: "Custom subscription", + windows: [ + { label: "Tokens this month", usedPercent: 42, resetsAt: null }, + ], + }, + })); + let removed = false; + const { bb, harness } = createFakePluginHost({ + sdk: { + hosts: { + list: async () => [ + makeHostResponse({ id: "online", status: "connected" }), + makeHostResponse({ id: "offline", status: "disconnected" }), + ], + }, + providers: { + list: async () => + removed + ? [] + : [ + { + id: "foreign", + displayName: "Foreign", + pluginId: "unrelated", + }, + { + id: "claude-code", + displayName: "Custom", + pluginId: "provider-claude-code", + }, + { + id: "another", + displayName: "Another", + pluginId: "unrelated", + }, + ], + }, + system: { usageLimits: collect }, + }, + }); + try { + plugin(bb); + const list = async () => + usageResourceListSchema.parse( + await harness.behavior.callRpc(usageListMethod, {}), + ); + const read = async (host: string, provider: string, refresh = false) => + usageMeasurementSchema.parse( + await harness.behavior.callRpc(usageFetchMethod, { + resourceId: JSON.stringify([host, provider]), + refresh, + }), + ); + expect((await list()).resources).toHaveLength(2); + expect(collect).not.toHaveBeenCalled(); + await expect(read("online", "foreign")).rejects.toThrow("no longer exists"); + expect(await read("online", "claude-code")).toMatchObject({ + accountKey: null, + usage: { + status: "ok", + plan: null, + planLabel: "Custom subscription", + windows: [{ kind: "custom", label: "Tokens this month" }], + }, + }); + expect(collect).toHaveBeenCalledWith({ + hostId: "online", + providerId: "claude-code", + }); + await read("online", "claude-code"); + expect(collect).toHaveBeenCalledTimes(1); + await read("online", "claude-code", true); + expect(collect).toHaveBeenCalledTimes(2); + expect(await read("offline", "claude-code")).toMatchObject({ + observedAt: null, + usage: { status: "error" }, + }); + expect(collect).toHaveBeenCalledTimes(2); + removed = true; + expect((await list()).resources).toEqual([]); + await expect(read("online", "claude-code")).rejects.toThrow( + "no longer exists", + ); + } finally { + await harness.lifecycle.dispose(); + } +}); + +it("forwards validated provider-owned identity and normalization metadata while tolerating older providers", async () => { + const { bb, harness } = createFakePluginHost({ + sdk: { + hosts: { + list: async () => [ + makeHostResponse({ id: "host", status: "connected" }), + ], + }, + providers: { + list: async () => [ + { + id: "claude-code", + displayName: "Custom", + pluginId: "provider-claude-code", + }, + ], + }, + system: { + usageLimits: async () => ({ + "claude-code": { + status: "ok", + accountKey: "issuer:organization:123", + accountEmail: "same@example.com", + planLabel: "max", + plan: { id: "max", multiplier: 20 }, + windows: [ + { + label: "168 hour window", + kind: "weekly", + model: "fable", + usedPercent: 50, + resetsAt: null, + }, + ], + }, + }), + }, + }, + }); + try { + plugin(bb); + const value = usageMeasurementSchema.parse( + await harness.behavior.callRpc(usageFetchMethod, { + resourceId: JSON.stringify(["host", "claude-code"]), + refresh: false, + }), + ); + expect(value).toMatchObject({ + accountKey: "issuer:organization:123", + usage: { + plan: { id: "max", multiplier: 20 }, + windows: [{ kind: "weekly", model: "fable" }], + }, + }); + const inventory = usageResourceListSchema.parse( + await harness.behavior.callRpc(usageListMethod, {}), + ); + expect(inventory.resources[0]?.accountKey).toBe(value.accountKey); + } finally { + await harness.lifecycle.dispose(); + } +}); + +it("coalesces concurrent reads and makes a forced refresh wait for a fresh collection", async () => { + let finish: (() => void) | undefined; + const gate = new Promise((resolve) => { + finish = resolve; + }); + const collect = vi.fn(async () => { + await gate; + return { + "claude-code": { + status: "ok" as const, + accountEmail: null, + planLabel: null, + windows: [], + }, + }; + }); + const { bb, harness } = createFakePluginHost({ + sdk: { + hosts: { + list: async () => [ + makeHostResponse({ id: "host", status: "connected" }), + ], + }, + providers: { + list: async () => [ + { + id: "claude-code", + displayName: "Custom", + pluginId: "provider-claude-code", + }, + ], + }, + system: { usageLimits: collect }, + }, + }); + try { + plugin(bb); + const read = (refresh: boolean) => + harness.behavior.callRpc(usageFetchMethod, { + resourceId: JSON.stringify(["host", "claude-code"]), + refresh, + }); + const first = read(false); + const second = read(false); + const forced = read(true); + await vi.waitFor(() => expect(collect).toHaveBeenCalledTimes(1)); + finish!(); + await Promise.all([first, second, forced]); + expect(collect).toHaveBeenCalledTimes(2); + } finally { + finish?.(); + await harness.lifecycle.dispose(); + } +}); diff --git a/plugins/provider-claude-code/src/usage-source.ts b/plugins/provider-claude-code/src/usage-source.ts new file mode 100644 index 0000000000..645a358be1 --- /dev/null +++ b/plugins/provider-claude-code/src/usage-source.ts @@ -0,0 +1,169 @@ +import type { BbPluginApi } from "@get-bb/plugin-sdk"; +import { z } from "zod"; +import { + usageSourceRpcContract, + usageListMethod, + usageFetchMethod, + usageMeasurementSchema, + usagePlanSchema, + usageWindowKindSchema, + type UsageMeasurement, +} from "./usage-contract.js"; + +const locatorSchema = z.tuple([z.string().min(1), z.string().min(1)]); +const metadataSchema = z.object({ + accountKey: z.string().min(1).nullable().catch(null), + plan: usagePlanSchema.nullable().catch(null), +}); +const windowMetadataSchema = z.object({ + kind: usageWindowKindSchema.catch("custom"), + model: z.string().nullable().catch(null), +}); + +export function registerUsageSource(bb: BbPluginApi) { + const cache = new Map(); + const pending = new Map< + string, + { refresh: boolean; promise: Promise } + >(); + const load = async ( + resourceId: string, + refresh: boolean, + ): Promise => { + const [hostId, providerId] = locatorSchema.parse(JSON.parse(resourceId)); + const host = (await bb.sdk.hosts.list()).find((host) => host.id === hostId); + if (!host) throw new Error("Usage resource no longer exists."); + const previous = cache.get(resourceId); + const unavailable = (message: string) => + usageMeasurementSchema.parse({ + accountKey: previous?.accountKey ?? null, + observedAt: previous?.observedAt ?? null, + usage: { + status: "error", + accountEmail: null, + planLabel: null, + message, + }, + }); + if (host.status === "disconnected") + return unavailable("Machine is disconnected."); + const providers = await bb.sdk.providers.list({ + hostId, + capability: "usage", + }); + if ( + !providers.some( + (provider) => + provider.id === providerId && + provider.pluginId === "provider-claude-code" && + provider.id === "claude-code", + ) + ) + throw new Error("Usage resource no longer exists."); + if ( + !refresh && + previous?.usage.status === "ok" && + previous.observedAt !== null && + Date.now() - previous.observedAt < 60_000 + ) + return previous; + const promise = (async () => { + try { + const result = await bb.sdk.system.usageLimits({ hostId, providerId }); + const usage = result[providerId]; + if (!usage) throw new Error("Provider returned no usage information."); + const metadata = metadataSchema.parse(usage); + const value = usageMeasurementSchema.parse({ + accountKey: metadata.accountKey, + observedAt: + usage.status === "ok" ? Date.now() : (previous?.observedAt ?? null), + usage: { + accountEmail: null, + planLabel: null, + ...usage, + plan: metadata.plan, + ...(usage.status === "ok" + ? { + windows: usage.windows.map((window, index) => ({ + ...window, + ...windowMetadataSchema.parse(window), + id: `${index}:${window.label}`, + cost: window.cost ?? null, + })), + } + : {}), + }, + }); + cache.set(resourceId, value); + return value; + } catch { + return unavailable("Usage could not be collected from this machine."); + } + })(); + return promise; + }; + const collect = async ( + resourceId: string, + refresh: boolean, + ): Promise => { + const running = pending.get(resourceId); + if (running) { + if (!refresh || running.refresh) return running.promise; + await running.promise.catch(() => undefined); + return collect(resourceId, refresh); + } + const promise = load(resourceId, refresh).finally(() => + pending.delete(resourceId), + ); + pending.set(resourceId, { refresh, promise }); + return promise; + }; + bb.rpc.register( + usageSourceRpcContract, + { + async [usageListMethod]() { + const hosts = await bb.sdk.hosts.list(); + const resources = ( + await Promise.all( + hosts.map(async (host) => { + const providers = await bb.sdk.providers.list({ + hostId: host.id, + capability: "usage", + }); + return providers + .filter( + (provider) => + provider.pluginId === "provider-claude-code" && + provider.id === "claude-code", + ) + .map((provider) => { + const id = JSON.stringify([host.id, provider.id]); + return { + id, + accountKey: cache.get(id)?.accountKey ?? null, + providerId: provider.id, + label: provider.displayName, + scope: { + kind: "host" as const, + hostId: host.id, + hostName: host.name, + }, + }; + }); + }), + ) + ).flat(); + const ids = new Set(resources.map((resource) => resource.id)); + for (const id of cache.keys()) if (!ids.has(id)) cache.delete(id); + return { resources }; + }, + [usageFetchMethod]: ({ resourceId, refresh }) => + collect(resourceId, refresh), + }, + { + experimental_discoverable: true, + experimental_description: + "Host-local usage owned by the claude-code provider plugin. Inventory reads metadata only. Independent of display plugins.", + }, + ); +} diff --git a/plugins/provider-codex/server.ts b/plugins/provider-codex/server.ts index bb0bfb4e2a..50bddc585a 100644 --- a/plugins/provider-codex/server.ts +++ b/plugins/provider-codex/server.ts @@ -1,8 +1,10 @@ +import { registerUsageSource } from "./src/usage-source.js"; import type { BbPluginApi } from "@get-bb/plugin-sdk"; import { codexExtensionKinds } from "./src/extension-kinds.js"; import { CODEX_NATIVE_ROOTS_DECLARATION } from "./src/native-roots.js"; export default function plugin(bb: BbPluginApi) { + registerUsageSource(bb); bb.experimental_aiServices.register({ id: "codex", displayName: "Codex (ChatGPT account or API key)", diff --git a/plugins/provider-codex/skills/codex-provider/SKILL.md b/plugins/provider-codex/skills/codex-provider/SKILL.md index 54aa3a4ce7..53fdc5c723 100644 --- a/plugins/provider-codex/skills/codex-provider/SKILL.md +++ b/plugins/provider-codex/skills/codex-provider/SKILL.md @@ -19,4 +19,4 @@ upstream product behavior. ## Discoverable usage -The bundled Provider usage sources plugin adapts this provider’s maintenance data into cheap `provider-usage.v1.listResources` inventory and `provider-usage.v1.getResource` measurements for independent usage displays. Fetch takes `{ resourceId, refresh }` and returns actual usage for that resource even when refresh is false; listing never collects quota. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect provider-usage-sources --method provider-usage.v1.listResources --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. +This plugin directly publishes cheap `provider-usage.v1.listResources` inventory and `provider-usage.v1.getResource` measurements for independent usage displays. Fetch takes `{ resourceId, refresh }` and returns actual usage for that resource even when refresh is false; listing never collects quota. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect provider-codex --method provider-usage.v1.listResources --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. diff --git a/plugins/provider-codex/src/usage-contract.ts b/plugins/provider-codex/src/usage-contract.ts new file mode 100644 index 0000000000..1ef6a414af --- /dev/null +++ b/plugins/provider-codex/src/usage-contract.ts @@ -0,0 +1,132 @@ +import { defineRpcContract } from "@get-bb/plugin-sdk"; +import { z } from "zod"; + +export const usagePlanSchema = z.object({ + id: z.string().min(1), + multiplier: z.number().int().positive().nullable(), +}); +const accountFields = { + plan: usagePlanSchema.nullable().default(null), + accountEmail: z.string().nullable(), + planLabel: z.string().nullable(), +}; +export const usageWindowKindSchema = z.enum([ + "five-hour", + "daily", + "weekly", + "custom", +]); +const usageWindowSchema = z.object({ + kind: usageWindowKindSchema.default("custom"), + id: z.string().min(1), + label: z.string().min(1), + usedPercent: z + .number() + .nonnegative() + .describe("Percentage consumed; may exceed 100 for overage."), + resetsAt: z + .string() + .nullable() + .describe("ISO timestamp, or null when no reset is known."), + model: z + .string() + .nullable() + .describe("Applicable model family, or null for all models."), + cost: z + .object({ + usedUsdCents: z.number().nonnegative(), + limitUsdCents: z.number().positive(), + }) + .nullable(), +}); +const usageSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("ok"), + ...accountFields, + windows: z.array(usageWindowSchema), + }), + z.object({ status: z.literal("not_installed"), ...accountFields }), + z.object({ status: z.literal("unauthenticated"), ...accountFields }), + z.object({ status: z.literal("expired"), ...accountFields }), + z.object({ + status: z.literal("error"), + ...accountFields, + message: z.string(), + }), +]); +export const usageAccountKeySchema = z + .string() + .min(1) + .nullable() + .default(null) + .describe( + "Provider-issued quota account identity, namespaced by issuer and account/organization scope. Never use email, a display label, a source-local ID, or credentials. Null means unknown; unknown accounts must not be merged.", + ); +export const usageResourceSchema = z.object({ + accountKey: usageAccountKeySchema, + id: z + .string() + .min(1) + .describe("Stable resource ID within this source plugin."), + providerId: z.string().min(1), + label: z.string().min(1), + scope: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("shared") }), + z.object({ + kind: z.literal("host"), + hostId: z.string().min(1), + hostName: z.string().min(1), + }), + ]), +}); +export const usageResourceListSchema = z.object({ + label: z + .string() + .min(1) + .optional() + .describe( + "Declares a shared group even when empty. Host-only sources omit it; machine groups use host names.", + ), + resources: z.array(usageResourceSchema), +}); +export const usageMeasurementSchema = z.object({ + accountKey: usageAccountKeySchema, + observedAt: z + .number() + .int() + .nonnegative() + .nullable() + .describe( + "Last successful measurement time in epoch milliseconds; null if never observed.", + ), + usage: usageSchema, +}); +export const usageListInputSchema = z.object({}); +export const usageFetchInputSchema = z.object({ + resourceId: z.string().min(1), + refresh: z + .boolean() + .describe( + "False permits a cached measurement but still returns actual usage. True requests a fresh collection attempt for this resource only.", + ), +}); +export type UsageResourceList = z.infer; +export type UsageMeasurement = z.infer; +export type UsageResource = z.infer & + UsageMeasurement; +export const usageListMethod = "provider-usage.v1.listResources"; +export const usageFetchMethod = "provider-usage.v1.getResource"; +export const usageSourceRpcContract = defineRpcContract({ + [usageListMethod]: { + input: usageListInputSchema, + output: usageResourceListSchema, + experimental_description: + "Cheap complete inventory of resources owned by this source. Reads local metadata only; never refreshes quota or contacts providers. IDs are stable and source-local. Resource order is display order. Shared label preserves empty groups. Resources may disappear between list and fetch.", + }, + [usageFetchMethod]: { + input: usageFetchInputSchema, + output: usageMeasurementSchema, + experimental_description: + "Returns actual usage for exactly one listed resource, even when refresh is false. False permits cached observations; true requests a fresh attempt. Never collects other resources as a side effect. A removed resource fails the RPC; consumers relist. Per-account authentication and collection failures are usage states. observedAt is the last successful measurement time.", + }, +}); diff --git a/plugins/provider-usage-sources/src/usage-source.test.ts b/plugins/provider-codex/src/usage-source.test.ts similarity index 76% rename from plugins/provider-usage-sources/src/usage-source.test.ts rename to plugins/provider-codex/src/usage-source.test.ts index 013f3a43f6..f2af60f80d 100644 --- a/plugins/provider-usage-sources/src/usage-source.test.ts +++ b/plugins/provider-codex/src/usage-source.test.ts @@ -3,7 +3,7 @@ import { createFakePluginHost, makeHostResponse, } from "@get-bb/plugin-sdk/testing"; -import plugin from "../server.js"; +import { registerUsageSource as plugin } from "./usage-source.js"; import { usageListMethod, usageFetchMethod, @@ -11,9 +11,9 @@ import { usageResourceListSchema, } from "./usage-contract.js"; -it("adapts arbitrary maintenance providers without a display and only measures the requested resource", async () => { +it("publishes only its own maintenance providers without a display and only measures the requested resource", async () => { const collect = vi.fn(async () => ({ - custom: { + codex: { status: "ok" as const, accountEmail: "same@example.com", planLabel: "Custom subscription", @@ -36,8 +36,21 @@ it("adapts arbitrary maintenance providers without a display and only measures t removed ? [] : [ - { id: "custom", displayName: "Custom" }, - { id: "another", displayName: "Another" }, + { + id: "foreign", + displayName: "Foreign", + pluginId: "unrelated", + }, + { + id: "codex", + displayName: "Custom", + pluginId: "provider-codex", + }, + { + id: "another", + displayName: "Another", + pluginId: "unrelated", + }, ], }, system: { usageLimits: collect }, @@ -56,9 +69,10 @@ it("adapts arbitrary maintenance providers without a display and only measures t refresh, }), ); - expect((await list()).resources).toHaveLength(4); + expect((await list()).resources).toHaveLength(2); expect(collect).not.toHaveBeenCalled(); - expect(await read("online", "custom")).toMatchObject({ + await expect(read("online", "foreign")).rejects.toThrow("no longer exists"); + expect(await read("online", "codex")).toMatchObject({ accountKey: null, usage: { status: "ok", @@ -69,20 +83,20 @@ it("adapts arbitrary maintenance providers without a display and only measures t }); expect(collect).toHaveBeenCalledWith({ hostId: "online", - providerId: "custom", + providerId: "codex", }); - await read("online", "custom"); + await read("online", "codex"); expect(collect).toHaveBeenCalledTimes(1); - await read("online", "custom", true); + await read("online", "codex", true); expect(collect).toHaveBeenCalledTimes(2); - expect(await read("offline", "custom")).toMatchObject({ + expect(await read("offline", "codex")).toMatchObject({ observedAt: null, usage: { status: "error" }, }); expect(collect).toHaveBeenCalledTimes(2); removed = true; expect((await list()).resources).toEqual([]); - await expect(read("online", "custom")).rejects.toThrow("no longer exists"); + await expect(read("online", "codex")).rejects.toThrow("no longer exists"); } finally { await harness.lifecycle.dispose(); } @@ -97,11 +111,13 @@ it("forwards validated provider-owned identity and normalization metadata while ], }, providers: { - list: async () => [{ id: "custom", displayName: "Custom" }], + list: async () => [ + { id: "codex", displayName: "Custom", pluginId: "provider-codex" }, + ], }, system: { usageLimits: async () => ({ - custom: { + codex: { status: "ok", accountKey: "issuer:organization:123", accountEmail: "same@example.com", @@ -125,7 +141,7 @@ it("forwards validated provider-owned identity and normalization metadata while plugin(bb); const value = usageMeasurementSchema.parse( await harness.behavior.callRpc(usageFetchMethod, { - resourceId: JSON.stringify(["host", "custom"]), + resourceId: JSON.stringify(["host", "codex"]), refresh: false, }), ); @@ -153,7 +169,7 @@ it("coalesces concurrent reads and makes a forced refresh wait for a fresh colle const collect = vi.fn(async () => { await gate; return { - custom: { + codex: { status: "ok" as const, accountEmail: null, planLabel: null, @@ -169,7 +185,9 @@ it("coalesces concurrent reads and makes a forced refresh wait for a fresh colle ], }, providers: { - list: async () => [{ id: "custom", displayName: "Custom" }], + list: async () => [ + { id: "codex", displayName: "Custom", pluginId: "provider-codex" }, + ], }, system: { usageLimits: collect }, }, @@ -178,7 +196,7 @@ it("coalesces concurrent reads and makes a forced refresh wait for a fresh colle plugin(bb); const read = (refresh: boolean) => harness.behavior.callRpc(usageFetchMethod, { - resourceId: JSON.stringify(["host", "custom"]), + resourceId: JSON.stringify(["host", "codex"]), refresh, }); const first = read(false); diff --git a/plugins/provider-codex/src/usage-source.ts b/plugins/provider-codex/src/usage-source.ts new file mode 100644 index 0000000000..fcf90ba90b --- /dev/null +++ b/plugins/provider-codex/src/usage-source.ts @@ -0,0 +1,169 @@ +import type { BbPluginApi } from "@get-bb/plugin-sdk"; +import { z } from "zod"; +import { + usageSourceRpcContract, + usageListMethod, + usageFetchMethod, + usageMeasurementSchema, + usagePlanSchema, + usageWindowKindSchema, + type UsageMeasurement, +} from "./usage-contract.js"; + +const locatorSchema = z.tuple([z.string().min(1), z.string().min(1)]); +const metadataSchema = z.object({ + accountKey: z.string().min(1).nullable().catch(null), + plan: usagePlanSchema.nullable().catch(null), +}); +const windowMetadataSchema = z.object({ + kind: usageWindowKindSchema.catch("custom"), + model: z.string().nullable().catch(null), +}); + +export function registerUsageSource(bb: BbPluginApi) { + const cache = new Map(); + const pending = new Map< + string, + { refresh: boolean; promise: Promise } + >(); + const load = async ( + resourceId: string, + refresh: boolean, + ): Promise => { + const [hostId, providerId] = locatorSchema.parse(JSON.parse(resourceId)); + const host = (await bb.sdk.hosts.list()).find((host) => host.id === hostId); + if (!host) throw new Error("Usage resource no longer exists."); + const previous = cache.get(resourceId); + const unavailable = (message: string) => + usageMeasurementSchema.parse({ + accountKey: previous?.accountKey ?? null, + observedAt: previous?.observedAt ?? null, + usage: { + status: "error", + accountEmail: null, + planLabel: null, + message, + }, + }); + if (host.status === "disconnected") + return unavailable("Machine is disconnected."); + const providers = await bb.sdk.providers.list({ + hostId, + capability: "usage", + }); + if ( + !providers.some( + (provider) => + provider.id === providerId && + provider.pluginId === "provider-codex" && + provider.id === "codex", + ) + ) + throw new Error("Usage resource no longer exists."); + if ( + !refresh && + previous?.usage.status === "ok" && + previous.observedAt !== null && + Date.now() - previous.observedAt < 60_000 + ) + return previous; + const promise = (async () => { + try { + const result = await bb.sdk.system.usageLimits({ hostId, providerId }); + const usage = result[providerId]; + if (!usage) throw new Error("Provider returned no usage information."); + const metadata = metadataSchema.parse(usage); + const value = usageMeasurementSchema.parse({ + accountKey: metadata.accountKey, + observedAt: + usage.status === "ok" ? Date.now() : (previous?.observedAt ?? null), + usage: { + accountEmail: null, + planLabel: null, + ...usage, + plan: metadata.plan, + ...(usage.status === "ok" + ? { + windows: usage.windows.map((window, index) => ({ + ...window, + ...windowMetadataSchema.parse(window), + id: `${index}:${window.label}`, + cost: window.cost ?? null, + })), + } + : {}), + }, + }); + cache.set(resourceId, value); + return value; + } catch { + return unavailable("Usage could not be collected from this machine."); + } + })(); + return promise; + }; + const collect = async ( + resourceId: string, + refresh: boolean, + ): Promise => { + const running = pending.get(resourceId); + if (running) { + if (!refresh || running.refresh) return running.promise; + await running.promise.catch(() => undefined); + return collect(resourceId, refresh); + } + const promise = load(resourceId, refresh).finally(() => + pending.delete(resourceId), + ); + pending.set(resourceId, { refresh, promise }); + return promise; + }; + bb.rpc.register( + usageSourceRpcContract, + { + async [usageListMethod]() { + const hosts = await bb.sdk.hosts.list(); + const resources = ( + await Promise.all( + hosts.map(async (host) => { + const providers = await bb.sdk.providers.list({ + hostId: host.id, + capability: "usage", + }); + return providers + .filter( + (provider) => + provider.pluginId === "provider-codex" && + provider.id === "codex", + ) + .map((provider) => { + const id = JSON.stringify([host.id, provider.id]); + return { + id, + accountKey: cache.get(id)?.accountKey ?? null, + providerId: provider.id, + label: provider.displayName, + scope: { + kind: "host" as const, + hostId: host.id, + hostName: host.name, + }, + }; + }); + }), + ) + ).flat(); + const ids = new Set(resources.map((resource) => resource.id)); + for (const id of cache.keys()) if (!ids.has(id)) cache.delete(id); + return { resources }; + }, + [usageFetchMethod]: ({ resourceId, refresh }) => + collect(resourceId, refresh), + }, + { + experimental_discoverable: true, + experimental_description: + "Host-local usage owned by the codex provider plugin. Inventory reads metadata only. Independent of display plugins.", + }, + ); +} diff --git a/plugins/provider-usage-sources/.gitignore b/plugins/provider-usage-sources/.gitignore deleted file mode 100644 index 1eae0cf670..0000000000 --- a/plugins/provider-usage-sources/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -dist/ -node_modules/ diff --git a/plugins/provider-usage-sources/README.md b/plugins/provider-usage-sources/README.md deleted file mode 100644 index 6990abef77..0000000000 --- a/plugins/provider-usage-sources/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Provider usage sources - -A headless adapter from existing provider maintenance usage to the contract -owned by Provider Usage. Bundled and enabled by default, independently of either -display. Any enabled provider declaring `maintenance.usage` is included without -implementing RPC methods or depending on this plugin. - -Inventory lists host/provider metadata without collecting quota. Fetch addresses -one opaque resource ID returned by inventory. It collects actual usage even when -`refresh` is false, with a 60-second cache; `refresh: true` requests a fresh -measurement. Concurrent requests share collection, while a forced request waits -for a fresh attempt if an ordinary collection is already running. - -Provider-owned maintenance extensions can include `accountKey`, `plan`, and -window `kind`/`model`. The adapter validates these against its copied usage -contract. Missing or invalid optional extensions become unknown identity, no -structured plan, or a custom window; existing display labels remain usable. -Core and the provider kit do not register, import, or interpret this contract. - -Use the Plugin Guide for the RPC API and published JSON Schemas for the exact -contract. Disable this adapter to replace it with another source implementation. -Neither Provider Usage nor a replacement display needs to be enabled to call it. diff --git a/plugins/provider-usage-sources/package.json b/plugins/provider-usage-sources/package.json deleted file mode 100644 index 107cdf63c2..0000000000 --- a/plugins/provider-usage-sources/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "bb-plugin-provider-usage-sources", - "version": "0.1.0", - "private": true, - "type": "module", - "description": "Publish provider maintenance usage as discoverable resources.", - "engines": { - "bb": ">=0.0", - "bbPluginSdk": ">=0.4.56" - }, - "bb": { - "name": "Provider usage sources", - "description": "Publish provider maintenance usage as discoverable resources.", - "server": "./server.ts", - "branding": { - "icon": "ChartColumn" - }, - "skills": [ - "./skills" - ] - }, - "keywords": [ - "bb-plugin" - ], - "scripts": { - "test": "vitest run --config vitest.config.ts", - "typecheck": "tsc --noEmit" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "npm:@typescript/typescript6@^6.0.2", - "typescript-7": "npm:typescript@^7.0.2", - "vitest": "^4.1.1" - }, - "dependencies": { - "@get-bb/plugin-sdk": "workspace:*", - "zod": "^4.3.6" - } -} diff --git a/plugins/provider-usage-sources/skills/provider-usage-sources/SKILL.md b/plugins/provider-usage-sources/skills/provider-usage-sources/SKILL.md deleted file mode 100644 index 478c278c58..0000000000 --- a/plugins/provider-usage-sources/skills/provider-usage-sources/SKILL.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: provider-usage-sources -description: Inspect host-local usage resources published by the maintenance adapter. ---- - -# Inspect usage sources - -The bundled Provider usage sources plugin adapts every enabled provider declaring -`maintenance.usage`, including third-party providers. It is independent of the -Provider Usage display. Account Pooler publishes its shared resources separately. - -Inspect methods and schemas: - -```sh -bb plugin rpc inspect provider-usage-sources --json -bb plugin rpc list --method provider-usage.v1.listResources --json -``` - -Call `provider-usage.v1.listResources` with `{}` through -`bb plugin rpc call provider-usage-sources --input-file --json`. -Choose an opaque resource ID from that response and call -`provider-usage.v1.getResource` with `{ "resourceId": "", "refresh": false }`. -Listing is cheap metadata; fetching returns actual data for only that resource. -Use `refresh: true` for a fresh attempt. Machine disconnection, authentication, -and collection failures are distinct states. Unknown account identity is never -inferred from email. `bb settings usage` remains the direct maintenance view. diff --git a/plugins/provider-usage-sources/tsconfig.json b/plugins/provider-usage-sources/tsconfig.json deleted file mode 100644 index 6acac8aade..0000000000 --- a/plugins/provider-usage-sources/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "strict": true, - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "lib": ["ES2022", "DOM"], - "noEmit": true, - "skipLibCheck": true, - "paths": { - "@get-bb/plugin-sdk": [ - "../../packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts" - ], - "@get-bb/plugin-sdk/app": [ - "../../packages/plugin-sdk/bundled-types/bb-plugin-sdk-app.d.ts" - ] - }, - "types": ["node"] - }, - "include": ["server.ts", "src", "vitest.config.ts"] -} diff --git a/plugins/provider-usage-sources/vitest.config.ts b/plugins/provider-usage-sources/vitest.config.ts deleted file mode 100644 index 7ab217d248..0000000000 --- a/plugins/provider-usage-sources/vitest.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { - defineWorkspaceTestConfig, - sharedWorkerProjects, -} from "../../vitest.shared.js"; - -export default defineWorkspaceTestConfig({ - test: { - silent: "passed-only", - projects: sharedWorkerProjects({ - pkgDir: __dirname, - name: "bb-plugin-provider-usage-sources", - include: ["src/**/*.test.ts"], - }), - }, -}); diff --git a/plugins/provider-usage/README.md b/plugins/provider-usage/README.md index 6ecb9abe6e..491016322d 100644 --- a/plugins/provider-usage/README.md +++ b/plugins/provider-usage/README.md @@ -22,8 +22,12 @@ to inspect their published contracts. RPC calls accept JSON through `bb settings usage --json` and `bb.sdk.system.usageLimits()` remain the host-local provider-maintenance view; they do not aggregate shared pool accounts. -The headless Provider usage sources plugin automatically adapts providers declaring -maintenance usage. The contract remains owned here; it is not part of the provider -kit or core runtime. Known provider-issued account identities are deduplicated -within the selected location. Unknown identities are never merged by email. -Structured plan and quota-window metadata give both displays consistent labels. +Codex, Claude Code, and ACP provider plugins explicitly implement the usage contract +for their own providers. Account Pooler implements it for shared accounts. The +contract is owned here and copied into each source; no additional adapter plugin, +provider-kit helper, or core runtime convention is required. Other providers must +explicitly implement the contract to appear in these displays. + +Known provider-issued account identities are deduplicated within the selected +location. Unknown identities are never merged by email. Structured plan and quota +window metadata give both displays consistent labels. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9569ca0b47..9bf6444a38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3995,28 +3995,6 @@ importers: specifier: ^4.1.1 version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.4.0))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) - plugins/provider-usage-sources: - dependencies: - '@get-bb/plugin-sdk': - specifier: workspace:* - version: link:../../packages/plugin-sdk - zod: - specifier: 4.3.6 - version: 4.3.6 - devDependencies: - '@types/node': - specifier: ^22.0.0 - version: 22.19.10 - typescript: - specifier: npm:@typescript/typescript6@^6.0.2 - version: '@typescript/typescript6@6.0.2' - typescript-7: - specifier: npm:typescript@^7.0.2 - version: typescript@7.0.2 - vitest: - specifier: ^4.1.1 - version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.4.0))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) - plugins/push-notifications: dependencies: undici: diff --git a/turbo.json b/turbo.json index baf88959a5..f7c98d453d 100644 --- a/turbo.json +++ b/turbo.json @@ -932,9 +932,6 @@ "bb-plugin-provider-pi#typecheck": { "dependsOn": ["@get-bb/plugin-sdk#build:types", "topo"] }, - "bb-plugin-provider-usage-sources#typecheck": { - "dependsOn": ["@get-bb/plugin-sdk#build:types", "topo"] - }, "bb-plugin-provider-usage#typecheck": { "dependsOn": ["@get-bb/plugin-sdk#build:types", "topo"] }, From 211b93830236afc0d6d87e3bf3466d7166133610 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 11 Sep 2026 13:50:06 -0700 Subject: [PATCH 20/53] Reconcile usage contracts with current host and SDK APIs --- .../settings/UsageLimitsSettingsSection.tsx | 13 ++++++++--- .../UsageSourcesSettingsSection.test.tsx | 1 + plugins/account-pool/package.json | 2 +- plugins/provider-acp/package.json | 2 +- plugins/provider-claude-code/package.json | 2 +- plugins/provider-codex/package.json | 2 +- plugins/provider-usage/app.test.tsx | 22 ++++++++++--------- plugins/provider-usage/package.json | 2 +- 8 files changed, 28 insertions(+), 18 deletions(-) diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx index 79cf2a53a2..39219f564e 100644 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx +++ b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx @@ -7,7 +7,7 @@ import type { UsageResourceList, UsageMeasurement, } from "@/lib/usage-source-contract"; -import { useId, useState } from "react"; +import { useId, useMemo, useState } from "react"; import type { ProviderInfo } from "@bb/domain"; import type { ProviderUsage, @@ -34,7 +34,11 @@ import { useSystemProviders, type ProviderUsageQueryState, } from "@/hooks/queries/system-queries"; -import { selectPrimaryHost, useHosts } from "@/hooks/queries/host-queries"; +import { + selectHosts, + selectPrimaryHost, + useHosts, +} from "@/hooks/queries/host-queries"; import { getProviderIconInfo } from "@/lib/provider-icon"; import { ProviderIconMark } from "./ProviderIconMark"; import { cn } from "@bb/shared-ui/lib/utils"; @@ -485,7 +489,10 @@ export function UsageLimitsSettingsSectionContent({ export function UsageLimitsSettingsSection() { const systemConfigQuery = useSystemConfig(); const hostsQuery = useHosts(); - const hosts = hostsQuery.data ?? []; + const hosts = useMemo( + () => selectHosts(hostsQuery.data, "persistent"), + [hostsQuery.data], + ); const [selectedLocationId, setSelectedLocationId] = useState( null, ); diff --git a/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx b/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx index 46240a3b8a..b3f2a9380a 100644 --- a/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx +++ b/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx @@ -27,6 +27,7 @@ vi.mock("@/hooks/queries/host-queries", () => ({ useHosts: () => ({ data: [makeHost({ id: "host-a", name: "Build machine" })], }), + selectHosts: (hosts: unknown[] | undefined) => hosts ?? [], selectPrimaryHost: (hosts: unknown[]) => hosts[0], })); diff --git a/plugins/account-pool/package.json b/plugins/account-pool/package.json index 0948040322..6f632c2309 100644 --- a/plugins/account-pool/package.json +++ b/plugins/account-pool/package.json @@ -6,7 +6,7 @@ "description": "Routes Claude and Codex API traffic across provider account pools.", "engines": { "bb": ">=0.0", - "bbPluginSdk": ">=0.4.56" + "bbPluginSdk": ">=0.4.85" }, "bb": { "name": "Account Pooler [Experimental]", diff --git a/plugins/provider-acp/package.json b/plugins/provider-acp/package.json index 8b0ad163b5..7f536db558 100644 --- a/plugins/provider-acp/package.json +++ b/plugins/provider-acp/package.json @@ -6,7 +6,7 @@ "description": "Run bb threads with ACP agents (supports Cursor, opencode, omp and more).", "engines": { "bb": ">=0.0", - "bbPluginSdk": ">=0.4.56" + "bbPluginSdk": ">=0.4.85" }, "bb": { "name": "ACP providers", diff --git a/plugins/provider-claude-code/package.json b/plugins/provider-claude-code/package.json index 6a5ddf0459..632a839c5f 100644 --- a/plugins/provider-claude-code/package.json +++ b/plugins/provider-claude-code/package.json @@ -6,7 +6,7 @@ "description": "Run bb threads with Claude Code.", "engines": { "bb": ">=0.0", - "bbPluginSdk": ">=0.4.56" + "bbPluginSdk": ">=0.4.85" }, "bb": { "name": "Claude Code provider", diff --git a/plugins/provider-codex/package.json b/plugins/provider-codex/package.json index 8cc0d6a372..d0435738ab 100644 --- a/plugins/provider-codex/package.json +++ b/plugins/provider-codex/package.json @@ -6,7 +6,7 @@ "description": "Run bb threads with Codex.", "engines": { "bb": ">=0.0", - "bbPluginSdk": ">=0.4.56" + "bbPluginSdk": ">=0.4.85" }, "bb": { "name": "Codex provider", diff --git a/plugins/provider-usage/app.test.tsx b/plugins/provider-usage/app.test.tsx index 8d0371c515..2a7d807748 100644 --- a/plugins/provider-usage/app.test.tsx +++ b/plugins/provider-usage/app.test.tsx @@ -65,8 +65,8 @@ describe("provider usage footer disclosure", () => { accountLabel: email, displayName: displayName, logoUrl: `/api/v1/system/providers/${providerId}/logo`, - iconGlyph: null, - iconTint: null, + icon: null, + strings: { iconTint: null }, signInHint: "Sign in.", expiredHint: "Sign in again.", usage: { @@ -106,8 +106,10 @@ describe("provider usage footer disclosure", () => { displayName: "Claude Code", logoUrl: "/api/v1/system/providers/claude-code/logo?h=claude", - iconGlyph: null, - iconTint: { light: "#D97757", dark: "#E38A6E" }, + icon: null, + strings: { + iconTint: { light: "#D97757", dark: "#E38A6E" }, + }, signInHint: "Sign in to Claude Code.", expiredHint: "Sign in to Claude Code again.", usage: { @@ -130,8 +132,8 @@ describe("provider usage footer disclosure", () => { accountLabel: null, displayName: "Codex", logoUrl: "/api/v1/system/providers/codex/logo?h=codex", - iconGlyph: null, - iconTint: null, + icon: null, + strings: { iconTint: null }, signInHint: "Sign in to Codex.", expiredHint: "Sign in to Codex again.", usage: { @@ -162,8 +164,8 @@ describe("provider usage footer disclosure", () => { accountLabel: null, displayName: "Codex", logoUrl: "/api/v1/system/providers/codex/logo?h=codex", - iconGlyph: null, - iconTint: null, + icon: null, + strings: { iconTint: null }, signInHint: "Sign in to Codex.", expiredHint: "Sign in to Codex again.", usage: { @@ -447,8 +449,8 @@ it.each([ accountLabel: "review@example.com", displayName: "Codex", logoUrl: null, - iconGlyph: null, - iconTint: null, + icon: null, + strings: { iconTint: null }, signInHint: "Sign in to this account in the source plugin’s settings.", expiredHint: "Sign in again in the source plugin’s settings.", usage, diff --git a/plugins/provider-usage/package.json b/plugins/provider-usage/package.json index 57eb1ed1ef..51a7e6b741 100644 --- a/plugins/provider-usage/package.json +++ b/plugins/provider-usage/package.json @@ -6,7 +6,7 @@ "description": "Show live agent-provider usage in the bb sidebar footer", "engines": { "bb": ">=0.0", - "bbPluginSdk": ">=0.4.84" + "bbPluginSdk": ">=0.4.85" }, "bb": { "name": "Provider usage", From cd1e3d909a873fe464f81fd843d6082f7d729235 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 11 Sep 2026 14:32:03 -0700 Subject: [PATCH 21/53] Group settings usage accounts under consistent provider headings --- .../settings/UsageLimitsSettingsSection.tsx | 147 +++++++++++++----- .../UsageSourcesSettingsSection.test.tsx | 2 + ...iscoverable-rpc-and-provider-usage-plan.md | 2 +- plugins/provider-usage/README.md | 2 +- 4 files changed, 113 insertions(+), 40 deletions(-) diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx index 39219f564e..ce03faa856 100644 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx +++ b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx @@ -277,6 +277,95 @@ function ProviderUsageBlock({ ); } +function UsageResourceGroup({ + config, + resources, + isLoading, +}: { + config: ProviderConfig; + resources: NonNullable; + isLoading: boolean; +}) { + const headingId = useId(); + const ProviderIcon = getProviderIconInfo( + "agent", + config.providerId, + config.provider ?? null, + )?.icon; + return ( +
+
+ {ProviderIcon ? ( + + ) : null} +

+ {config.name} +

+
+
+ {resources.map(({ key, resource, isError }) => { + const email = + resource.usage?.accountEmail ?? + (resource.label === config.name ? "Account" : resource.label); + const usage = + resource.usage?.status === "ok" + ? { + ...resource.usage, + windows: resource.usage.windows.map(({ cost, ...window }) => + cost === null ? window : { ...window, cost }, + ), + } + : resource.usage; + const accountConfig = + resource.scope.kind === "shared" + ? { + ...config, + signInHint: + "Sign in to this account in the source plugin’s settings, then reload usage.", + expiredHint: + "This account’s session expired. Sign in again in the source plugin’s settings, then reload usage.", + } + : config; + return ( +
+
+

+ {email} +

+ {usage?.status === "ok" && usage.planLabel ? ( + {usage.planLabel} + ) : null} +
+ +
+ ); + })} +
+
+ ); +} + function ProviderUsageBody({ config, usage, @@ -356,6 +445,15 @@ export function UsageLimitsSettingsSectionContent({ const providerById = new Map( providers.map((provider) => [provider.id, provider] as const), ); + const resourceGroups = new Map< + string, + NonNullable + >(); + for (const entry of resources ?? []) { + const group = resourceGroups.get(entry.resource.providerId); + if (group) group.push(entry); + else resourceGroups.set(entry.resource.providerId, [entry]); + } const reportedProviderIds = Object.keys(usage); const orderedProviderIds = [ ...providers @@ -427,44 +525,17 @@ export function UsageLimitsSettingsSectionContent({

{emptyMessage}

) ) : ( - resources.map(({ key, resource, isError: resourceIsError }) => { - const config = providerConfig( - resource.providerId, - providerById.get(resource.providerId), - ); - return ( - - cost === null ? window : { ...window, cost }, - ), - } - : resource.usage - } - isLoading={isLoading || isFetching} - isError={ - resourceIsError === true && resource.usage === undefined - } - /> - ); - }) + [...resourceGroups].map(([providerId, accounts]) => ( + + )) ) ) : providerConfigs.length === 0 ? (

{emptyMessage}

diff --git a/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx b/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx index b3f2a9380a..2561c33bba 100644 --- a/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx +++ b/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx @@ -359,6 +359,8 @@ it("deduplicates known inventory identities without merging unknown accounts sha await waitFor(() => expect(screen.getAllByText("42% used")).toHaveLength(3), ); + expect(screen.getAllByRole("heading", { level: 3 })).toHaveLength(1); + expect(screen.getAllByRole("heading", { level: 4 })).toHaveLength(3); expect(screen.getAllByText("Max (20x)")).toHaveLength(3); expect(screen.getAllByText("Weekly limit")).toHaveLength(3); expect( diff --git a/docs/discoverable-rpc-and-provider-usage-plan.md b/docs/discoverable-rpc-and-provider-usage-plan.md index 9f3a05732d..d52b61c3c9 100644 --- a/docs/discoverable-rpc-and-provider-usage-plan.md +++ b/docs/discoverable-rpc-and-provider-usage-plan.md @@ -154,7 +154,7 @@ Account Pooler lists account metadata without refreshing, then calls its existin ### Display implementation -Provider Usage discovers sources whenever it loads or refreshes data, invokes them with bounded concurrency and bounded wait, and renders successful results even if another source fails. The core settings prototype preserves the existing provider-card presentation and refresh control. Its source picker defaults to a shared source when available, or the primary machine otherwise; explicit selections win. Shared-source selections show only that source’s shared accounts, and machine selections show only that machine’s host-local observations. Account headings use email without repeating it as a subtitle. Source-group headings and observation timestamps are not added to this page. Other display plugins can choose their own presentation using the same metadata. +Provider Usage discovers sources whenever it loads or refreshes data, invokes them with bounded concurrency and bounded wait, and renders successful results even if another source fails. The core settings page groups accounts beneath one provider heading and icon, using the same email/plan/usage layout for shared pools and machines. It preserves the refresh control. Its source picker defaults to a shared source when available, or the primary machine otherwise; explicit selections win. Shared-source selections show only that source’s shared accounts, and machine selections show only that machine’s host-local observations. Account headings use email without repeating it as a subtitle. Source-group headings and observation timestamps are not added to this page. Other display plugins can choose their own presentation using the same metadata. Namespace resource keys by reporting plugin ID. Do not deduplicate by email or sum unrelated quota percentages. A shared pool appears once in the source picker; local and pooled observations remain separate choices even when their account emails match. Source removal evicts its current display entries on the next reconciliation. diff --git a/plugins/provider-usage/README.md b/plugins/provider-usage/README.md index 491016322d..bfd8d8a4e6 100644 --- a/plugins/provider-usage/README.md +++ b/plugins/provider-usage/README.md @@ -11,7 +11,7 @@ Account authentication failures and plans without reported limits have separate states; unavailable usage is never represented as zero consumption. Settings → Usage limits consumes the same sources independently, using its -existing full-size provider cards and fetching only resources in the selected pool or machine. Neither display is required for source +full-size provider groups with email-labeled accounts and fetching only resources in the selected pool or machine. Neither display is required for source plugins to publish their usage. Use `bb plugin rpc list --method provider-usage.v1.listResources --json` to find sources From 2f4166e950a5aab5ce707e08e18875b89ca9df7e Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 11 Sep 2026 14:37:00 -0700 Subject: [PATCH 22/53] Restore main's usage header alignment within provider groups --- .../settings/UsageLimitsSettingsSection.tsx | 145 +++++++++--------- .../UsageSourcesSettingsSection.test.tsx | 2 +- 2 files changed, 72 insertions(+), 75 deletions(-) diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx index ce03faa856..e25106a643 100644 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx +++ b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx @@ -107,6 +107,7 @@ function UsageWindowRow({ window }: { window: ProviderUsageWindow }) { } interface ProviderUsageBlockProps { + accountLabel?: string; config: ProviderConfig; usage: ProviderUsage | undefined; isLoading: boolean; @@ -204,13 +205,15 @@ function UsageLocationPicker({ } function ProviderUsageBlock({ + accountLabel, config, usage, isLoading, isError, }: ProviderUsageBlockProps) { const planLabel = usage?.status === "ok" ? usage.planLabel : null; - const accountEmail = usage?.status === "ok" ? usage.accountEmail : null; + const accountEmail = + accountLabel ?? (usage?.status === "ok" ? usage.accountEmail : null); const iconInfo = getProviderIconInfo( "agent", config.providerId, @@ -286,83 +289,77 @@ function UsageResourceGroup({ resources: NonNullable; isLoading: boolean; }) { - const headingId = useId(); - const ProviderIcon = getProviderIconInfo( - "agent", - config.providerId, - config.provider ?? null, - )?.icon; + const hasIcon = Boolean( + getProviderIconInfo("agent", config.providerId, config.provider ?? null) + ?.icon, + ); return ( -
-
- {ProviderIcon ? ( - - ) : null} -

- {config.name} -

-
-
- {resources.map(({ key, resource, isError }) => { - const email = - resource.usage?.accountEmail ?? - (resource.label === config.name ? "Account" : resource.label); - const usage = - resource.usage?.status === "ok" - ? { - ...resource.usage, - windows: resource.usage.windows.map(({ cost, ...window }) => - cost === null ? window : { ...window, cost }, - ), - } - : resource.usage; - const accountConfig = - resource.scope.kind === "shared" - ? { - ...config, - signInHint: - "Sign in to this account in the source plugin’s settings, then reload usage.", - expiredHint: - "This account’s session expired. Sign in again in the source plugin’s settings, then reload usage.", - } - : config; +
+ {resources.map(({ key, resource, isError }, index) => { + const email = + resource.usage?.accountEmail ?? + (resource.label === config.name ? "Account" : resource.label); + const usage = + resource.usage?.status === "ok" + ? { + ...resource.usage, + windows: resource.usage.windows.map(({ cost, ...window }) => + cost === null ? window : { ...window, cost }, + ), + } + : resource.usage; + const accountConfig = + resource.scope.kind === "shared" + ? { + ...config, + signInHint: + "Sign in to this account in the source plugin’s settings, then reload usage.", + expiredHint: + "This account’s session expired. Sign in again in the source plugin’s settings, then reload usage.", + } + : config; + const failed = isError === true && usage === undefined; + if (index === 0) return ( -
-
-

- {email} -

- {usage?.status === "ok" && usage.planLabel ? ( - {usage.planLabel} - ) : null} -
- -
+ accountLabel={email} + config={accountConfig} + usage={usage} + isLoading={isLoading} + isError={failed} + /> ); - })} -
-
+ return ( +
+
+

+ {email} +

+ {usage?.status === "ok" && usage.planLabel ? ( + {usage.planLabel} + ) : null} +
+ +
+ ); + })} +
); } diff --git a/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx b/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx index 2561c33bba..0390012de3 100644 --- a/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx +++ b/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx @@ -360,7 +360,7 @@ it("deduplicates known inventory identities without merging unknown accounts sha expect(screen.getAllByText("42% used")).toHaveLength(3), ); expect(screen.getAllByRole("heading", { level: 3 })).toHaveLength(1); - expect(screen.getAllByRole("heading", { level: 4 })).toHaveLength(3); + expect(screen.getAllByText("person@example.com")).toHaveLength(3); expect(screen.getAllByText("Max (20x)")).toHaveLength(3); expect(screen.getAllByText("Weekly limit")).toHaveLength(3); expect( From 60ea5884e2e6b50a2abf6e49995a2de8ee34ed2c Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 11 Sep 2026 14:40:47 -0700 Subject: [PATCH 23/53] Align usage selector icons labels and connection status --- .../settings/UsageLimitsSettingsSection.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx index e25106a643..6797deaf09 100644 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx +++ b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx @@ -174,6 +174,9 @@ function UsageLocationPicker({ {selectedLocation?.name ?? "Source"} + {selectedLocation?.kind === "host" ? ( + + ) : null} @@ -187,12 +190,14 @@ function UsageLocationPicker({ onSelect={() => onSelectLocation(location.id)} className="flex items-center gap-2" > + + {location.name} {location.kind === "host" ? ( - ) : ( - - )} - {location.name} + ) : null} {location.id === selectedLocation?.id ? ( ) : null} From 3e435d5fdd679cd7f5fe86f3b369b5e7b2668156 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 11 Sep 2026 14:44:03 -0700 Subject: [PATCH 24/53] Remove usage selector connection dots --- .../src/components/settings/UsageLimitsSettingsSection.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx index 6797deaf09..bb43e371e5 100644 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx +++ b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx @@ -21,7 +21,6 @@ import { SettingsRowList, SettingsSection, } from "@/components/ui/settings-section"; -import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; import { DropdownMenu, DropdownMenuContent, @@ -174,9 +173,6 @@ function UsageLocationPicker({ {selectedLocation?.name ?? "Source"} - {selectedLocation?.kind === "host" ? ( - - ) : null} @@ -195,9 +191,6 @@ function UsageLocationPicker({ className="size-3.5 shrink-0" /> {location.name} - {location.kind === "host" ? ( - - ) : null} {location.id === selectedLocation?.id ? ( ) : null} From e37e860641b36484dcf154ceefc1d2f2ff4db4c6 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 11 Sep 2026 15:04:28 -0700 Subject: [PATCH 25/53] Move usage settings into Provider Usage and make its footer optional --- .../UsageLimitsSettingsSection.stories.tsx | 250 ------ .../UsageLimitsSettingsSection.test.tsx | 349 --------- .../settings/UsageLimitsSettingsSection.tsx | 722 ------------------ .../UsageSourcesSettingsSection.test.tsx | 374 --------- .../components/settings/settings-sections.ts | 1 - .../src/hooks/queries/usage-source-queries.ts | 117 --- apps/app/src/lib/usage-normalization.ts | 78 -- apps/app/src/lib/usage-source-contract.ts | 117 --- apps/app/src/views/SettingsView.stories.tsx | 112 --- apps/app/src/views/SettingsView.tsx | 3 - .../src/services/plugins/builtin-registry.ts | 2 +- .../services/plugins/builtin-plugins.test.ts | 8 +- docs/api_to_audit.md | 9 +- docs/configuration.md | 8 + ...iscoverable-rpc-and-provider-usage-plan.md | 48 +- packages/plugin-sdk/src/app-contract.ts | 2 + .../src/internal/plugin-app-collector.ts | 8 + .../src/templates/bb-guide-plugins.md | 7 + plugins/provider-usage/README.md | 16 +- plugins/provider-usage/app.tsx | 22 +- plugins/provider-usage/package.json | 4 +- plugins/provider-usage/server.test.ts | 3 + plugins/provider-usage/server.ts | 30 +- plugins/provider-usage/settings-ui.tsx | 77 ++ plugins/provider-usage/settings.test.tsx | 254 ++++++ plugins/provider-usage/settings.tsx | 538 +++++++++++++ .../skills/provider-usage/SKILL.md | 30 + plugins/provider-usage/vitest.config.ts | 13 +- 28 files changed, 1038 insertions(+), 2164 deletions(-) delete mode 100644 apps/app/src/components/settings/UsageLimitsSettingsSection.stories.tsx delete mode 100644 apps/app/src/components/settings/UsageLimitsSettingsSection.test.tsx delete mode 100644 apps/app/src/components/settings/UsageLimitsSettingsSection.tsx delete mode 100644 apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx delete mode 100644 apps/app/src/hooks/queries/usage-source-queries.ts delete mode 100644 apps/app/src/lib/usage-normalization.ts delete mode 100644 apps/app/src/lib/usage-source-contract.ts create mode 100644 plugins/provider-usage/settings-ui.tsx create mode 100644 plugins/provider-usage/settings.test.tsx create mode 100644 plugins/provider-usage/settings.tsx create mode 100644 plugins/provider-usage/skills/provider-usage/SKILL.md diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.stories.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.stories.tsx deleted file mode 100644 index 4b1ea16654..0000000000 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.stories.tsx +++ /dev/null @@ -1,250 +0,0 @@ -import { useState, type ReactNode } from "react"; -import type { Host, ProviderInfo } from "@bb/domain"; -import { makeHost, makeProviderInfo } from "@bb/test-helpers/domain-fixtures"; -import { StoryCard, StoryRow } from "../../../.ladle/story-card"; -import { - UsageLimitsSettingsSectionContent, - type UsageLimitsSettingsSectionContentProps, -} from "./UsageLimitsSettingsSection"; - -export default { - title: "settings/Usage Limits", -}; - -type Usage = UsageLimitsSettingsSectionContentProps["usage"]; - -const noop = () => {}; - -function futureIso(minutesFromNow: number): string { - return new Date(Date.now() + minutesFromNow * 60_000).toISOString(); -} - -const HEALTHY_USAGE: Usage = { - codex: { - status: "ok", - accountEmail: "sawyer@example.com", - planLabel: "Pro", - windows: [ - { - label: "Weekly usage limit", - usedPercent: 8, - resetsAt: futureIso(5 * 24 * 60), - }, - ], - }, - "claude-code": { - status: "ok", - accountEmail: "sawyer@example.com", - planLabel: "Max (20x)", - windows: [ - { - label: "Current session", - usedPercent: 53, - resetsAt: futureIso(187), - }, - { - label: "All models", - usedPercent: 25, - resetsAt: futureIso(67), - }, - { - label: "Fable", - usedPercent: 48, - resetsAt: futureIso(67), - }, - ], - }, - "acp-cursor": { - status: "ok", - accountEmail: "sawyer@example.com", - planLabel: "Pro", - windows: [ - { - label: "Plan usage", - usedPercent: 72, - resetsAt: futureIso(14 * 24 * 60), - }, - { - label: "On-demand spend", - usedPercent: 25, - resetsAt: futureIso(14 * 24 * 60), - cost: { usedUsdCents: 1_250, limitUsdCents: 5_000 }, - }, - ], - }, -}; - -const AUTH_USAGE: Usage = { - codex: { status: "unauthenticated" }, - "claude-code": { status: "expired" }, - "acp-cursor": { status: "not_installed" }, -}; - -const EMPTY_AND_ERROR_USAGE: Usage = { - codex: { - status: "ok", - accountEmail: null, - planLabel: "Team", - windows: [], - }, - "claude-code": { - status: "error", - message: "Claude usage is temporarily unavailable.", - planLabel: "Max (5x)", - accountEmail: null, - }, - "acp-cursor": { status: "not_installed" }, -}; - -const HOSTS: Host[] = [ - makeHost({ - id: "host-macbook", - name: "MacBook Pro", - lastSeenAt: 1_700_000_000_000, - createdAt: 1, - updatedAt: 2, - }), - makeHost({ - id: "host-studio", - name: "Mac Studio", - lastSeenAt: 1_700_000_000_000, - createdAt: 1, - updatedAt: 2, - }), - makeHost({ - id: "host-build", - name: "Build machine", - status: "disconnected", - lastSeenAt: 1_700_000_000_000, - createdAt: 1, - updatedAt: 2, - }), -]; - -function provider(id: string, displayName: string): ProviderInfo { - return makeProviderInfo({ - id, - displayName, - logoUrl: null, - maintenance: { health: true, usage: true, installation: false }, - capabilities: { - supportsThreadArchive: false, - supportsThreadRename: false, - supportsServiceTier: false, - supportsNativeUserQuestion: false, - supportsFork: false, - supportsSessionRewind: false, - modelCatalogScope: "workspace", - permissionModes: ["full"], - }, - }); -} - -const PROVIDERS = [ - provider("codex", "Codex"), - provider("claude-code", "Claude Code"), - provider("acp-cursor", "Cursor"), -]; - -function Stage({ children }: { children: ReactNode }) { - return
{children}
; -} - -type UsagePreviewProps = Pick & - Partial< - Pick< - UsageLimitsSettingsSectionContentProps, - | "locations" - | "isError" - | "isFetching" - | "isLoading" - | "onSelectLocation" - | "selectedLocationId" - > - >; - -function UsagePreview({ - usage, - locations, - isError = false, - isFetching = false, - isLoading = false, - onSelectLocation, - selectedLocationId, -}: UsagePreviewProps) { - return ( - - - - ); -} - -function MultipleMachinesPreview() { - const [selectedLocationId, setSelectedLocationId] = useState( - HOSTS[0]?.id ?? null, - ); - - return ( - ({ - id: host.id, - name: host.name, - kind: "host", - disabled: host.status !== "connected", - }))} - selectedLocationId={selectedLocationId} - onSelectLocation={setSelectedLocationId} - /> - ); -} - -export function Usage() { - return ( - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.test.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.test.tsx deleted file mode 100644 index d66b3d84e6..0000000000 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.test.tsx +++ /dev/null @@ -1,349 +0,0 @@ -// @vitest-environment jsdom - -import type { ComponentProps } from "react"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import type { ProviderInfo } from "@bb/domain"; -import { makeHost, makeProviderInfo } from "@bb/test-helpers/domain-fixtures"; -import { TooltipProvider } from "@bb/shared-ui/tooltip"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { UsageLimitsSettingsSectionContent } from "./UsageLimitsSettingsSection"; - -const primaryHost = makeHost({ - id: "host-primary", - name: "MacBook Pro", - lastSeenAt: 1, - createdAt: 1, - updatedAt: 1, -}); - -const remoteHost = makeHost({ - ...primaryHost, - id: "host-remote", - name: "Build machine", -}); - -function provider( - id: string, - displayName: string, - supportsUsage = true, - strings?: ProviderInfo["strings"], -): ProviderInfo { - return makeProviderInfo({ - id, - displayName, - logoUrl: null, - maintenance: { health: true, usage: supportsUsage, installation: false }, - capabilities: { - supportsThreadArchive: false, - supportsThreadRename: false, - supportsServiceTier: false, - supportsNativeUserQuestion: false, - supportsFork: false, - supportsSessionRewind: false, - modelCatalogScope: "workspace", - permissionModes: ["full"], - }, - ...(strings === undefined ? {} : { strings }), - }); -} - -const FIRST_PARTY_PROVIDERS: ProviderInfo[] = [ - provider("codex", "Codex", true, { - signInHint: "Run `codex` to sign in and see your usage.", - expiredHint: "Your Codex session expired. Run `codex`, then reload usage.", - installUrl: "https://developers.openai.com/codex/cli", - }), - provider("claude-code", "Claude Code", true, { - signInHint: "Run `claude` to sign in and see your usage.", - expiredHint: - "Your Claude session expired. Run `claude`, then reload usage.", - installUrl: "https://claude.com/claude-code", - }), - provider("acp-cursor", "Cursor", true, { - signInHint: "Run `cursor-agent login` to sign in and see your usage.", - expiredHint: - "Your Cursor session expired. Run `cursor-agent login`, then reload usage.", - installUrl: "https://cursor.com/docs/cli/installation", - }), -]; - -afterEach(cleanup); - -function renderContent( - props: ComponentProps, -) { - return render( - - - , - ); -} - -describe("UsageLimitsSettingsSectionContent", () => { - it("renders Cursor plan and on-demand limits", () => { - renderContent({ - usage: { - "acp-cursor": { - status: "ok", - accountEmail: "cursor@example.com", - planLabel: "Pro", - windows: [ - { label: "Plan usage", usedPercent: 50, resetsAt: null }, - { - label: "On-demand spend", - usedPercent: 10, - resetsAt: null, - cost: { usedUsdCents: 500, limitUsdCents: 5_000 }, - }, - ], - }, - }, - isLoading: false, - isError: false, - isFetching: false, - onRefresh: vi.fn(), - }); - - expect(screen.getByRole("heading", { name: "Cursor" })).toBeDefined(); - expect(screen.getByRole("region", { name: "Cursor" })).toBeDefined(); - expect(screen.getByText("cursor@example.com")).toBeDefined(); - expect(screen.getByText("Plan usage")).toBeDefined(); - expect(screen.getByText("50% used")).toBeDefined(); - expect(screen.getByText("On-demand spend")).toBeDefined(); - expect(screen.getByText("$5.00 / $50")).toBeDefined(); - }); - - it("hides an uninstalled provider", () => { - renderContent({ - usage: { - codex: { status: "unauthenticated" }, - "acp-cursor": { status: "not_installed" }, - }, - isLoading: false, - isError: false, - isFetching: false, - onRefresh: vi.fn(), - }); - - expect(screen.queryByRole("heading", { name: "Cursor" })).toBeNull(); - expect(screen.queryByText("Not installed on this machine.")).toBeNull(); - expect(screen.getByRole("heading", { name: "Codex" })).toBeDefined(); - }); - - it("keeps states without usage bars with the provider heading", () => { - renderContent({ - usage: { codex: { status: "unauthenticated" } }, - isLoading: false, - isError: false, - isFetching: false, - onRefresh: vi.fn(), - }); - - const heading = screen.getByRole("heading", { name: "Codex" }); - const status = screen.getByText(/Run `codex` to sign in/u); - expect(heading.parentElement?.contains(status)).toBe(true); - }); - - it("renders usage reported by a plugin provider", () => { - renderContent({ - usage: { - "echo-agent": { - status: "ok", - accountEmail: null, - planLabel: "Team", - windows: [ - { label: "Monthly messages", usedPercent: 25, resetsAt: null }, - ], - }, - }, - providers: [provider("echo-agent", "Echo Agent")], - isLoading: false, - isError: false, - isFetching: false, - onRefresh: vi.fn(), - }); - - expect(screen.getByRole("heading", { name: "Echo Agent" })).toBeDefined(); - expect(screen.getByText("Monthly messages")).toBeDefined(); - expect(screen.getByText("25% used")).toBeDefined(); - }); - - it("renders supported registry providers in registry order", () => { - renderContent({ - usage: { codex: { status: "unauthenticated" } }, - providers: [ - provider("echo-agent", "Echo Agent"), - provider("no-usage", "No Usage", false), - provider("codex", "Codex from registry"), - ], - isLoading: false, - isError: false, - isFetching: false, - onRefresh: vi.fn(), - }); - - expect( - screen - .getAllByRole("heading", { level: 3 }) - .map((heading) => heading.textContent), - ).toEqual(["Echo Agent", "Codex from registry"]); - expect(screen.queryByRole("heading", { name: "No Usage" })).toBeNull(); - expect(screen.getByText("Usage not provided.")).toBeDefined(); - }); - - it("loads supported providers and hides unsupported providers", () => { - renderContent({ - usage: {}, - providers: [ - provider("codex", "Codex"), - provider("echo-agent", "Echo Agent", false), - ], - isLoading: true, - isError: false, - isFetching: true, - onRefresh: vi.fn(), - }); - - expect(screen.getByRole("heading", { name: "Codex" })).toBeDefined(); - expect(screen.queryByRole("heading", { name: "Echo Agent" })).toBeNull(); - expect(screen.getByText("Loading usage…")).toBeDefined(); - expect(screen.queryByText("Usage not provided.")).toBeNull(); - }); - - it("renders completed providers while their peers are still loading", () => { - renderContent({ - usage: { codex: { status: "unauthenticated" } }, - providers: FIRST_PARTY_PROVIDERS.filter( - (entry) => entry.id === "codex" || entry.id === "claude-code", - ), - providerStates: { - codex: { isError: false, isLoading: false }, - "claude-code": { isError: false, isLoading: true }, - }, - isLoading: true, - isError: false, - isFetching: true, - onRefresh: vi.fn(), - }); - - expect(screen.getByText(/Run `codex` to sign in/u)).toBeDefined(); - const claudeHeading = screen.getByRole("heading", { - name: "Claude Code", - }); - const loading = screen.getByText("Loading usage…"); - expect(claudeHeading.parentElement?.contains(loading)).toBe(true); - }); - - it("shows an initial loading message before the provider list arrives", () => { - renderContent({ - usage: {}, - providers: [], - isLoading: true, - isError: false, - isProviderListLoading: true, - isFetching: true, - onRefresh: vi.fn(), - }); - - expect(screen.getByText("Loading providers and usage…")).toBeDefined(); - }); - - it("keeps provider rows visible when the usage request fails", () => { - renderContent({ - usage: {}, - providers: [provider("echo-agent", "Echo Agent")], - isLoading: false, - isError: true, - isFetching: false, - onRefresh: vi.fn(), - }); - - expect(screen.getByRole("heading", { name: "Echo Agent" })).toBeDefined(); - expect(screen.getByText(/Couldn't load usage right now/u)).toBeDefined(); - }); - - it("selects which connected machine supplies usage", () => { - const onSelectLocation = vi.fn(); - renderContent({ - usage: {}, - isLoading: false, - isError: false, - isFetching: false, - onRefresh: vi.fn(), - locations: [primaryHost, remoteHost].map((host) => ({ - id: host.id, - name: host.name, - kind: "host", - disabled: false, - })), - selectedLocationId: primaryHost.id, - onSelectLocation, - }); - - const sectionHeader = screen - .getByRole("heading", { name: "Usage limits" }) - .closest("section")?.firstElementChild; - expect(sectionHeader?.classList.contains("flex-col")).toBe(true); - - fireEvent.pointerDown( - screen.getByRole("button", { name: "Usage source" }), - { button: 0 }, - ); - fireEvent.click(screen.getByRole("menuitem", { name: /Build machine/u })); - - expect(onSelectLocation).toHaveBeenCalledWith(remoteHost.id); - }); - - it("does not show a machine selector when there is only one machine", () => { - renderContent({ - usage: {}, - isLoading: false, - isError: false, - isFetching: false, - onRefresh: vi.fn(), - locations: [ - { - id: primaryHost.id, - name: primaryHost.name, - kind: "host", - disabled: false, - }, - ], - selectedLocationId: primaryHost.id, - onSelectLocation: vi.fn(), - }); - - const sectionHeader = screen - .getByRole("heading", { name: "Usage limits" }) - .closest("section")?.firstElementChild; - expect(sectionHeader?.classList.contains("flex-row")).toBe(true); - expect(sectionHeader?.classList.contains("flex-col")).toBe(false); - expect(screen.queryByRole("button", { name: "Usage source" })).toBeNull(); - }); -}); - -describe("UsageLimitsSettingsSectionContent marks", () => { - it("draws each provider's declared logo beside its usage block", () => { - renderContent({ - providers: [ - { - ...provider("codex", "Codex"), - logoUrl: "/api/v1/system/providers/codex/logo", - }, - ], - usage: {}, - isLoading: false, - isError: false, - isFetching: false, - onRefresh: () => {}, - }); - expect( - document.querySelector( - '[data-provider-logo="/api/v1/system/providers/codex/logo"]', - ), - ).not.toBeNull(); - }); -}); diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx deleted file mode 100644 index bb43e371e5..0000000000 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx +++ /dev/null @@ -1,722 +0,0 @@ -import { selectUsageResources } from "@/lib/usage-normalization"; -import { - useUsageSources, - useUsageMeasurements, -} from "@/hooks/queries/usage-source-queries"; -import type { - UsageResourceList, - UsageMeasurement, -} from "@/lib/usage-source-contract"; -import { useId, useMemo, useState } from "react"; -import type { ProviderInfo } from "@bb/domain"; -import type { - ProviderUsage, - ProviderUsageResponse, - ProviderUsageWindow, -} from "@bb/host-daemon-contract"; -import { Button } from "@bb/shared-ui/button"; -import { Icon } from "@bb/shared-ui/icon"; -import { - SettingsBadge, - SettingsRowList, - SettingsSection, -} from "@/components/ui/settings-section"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@bb/shared-ui/dropdown-menu"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; -import { - useSystemConfig, - useSystemProviders, - type ProviderUsageQueryState, -} from "@/hooks/queries/system-queries"; -import { - selectHosts, - selectPrimaryHost, - useHosts, -} from "@/hooks/queries/host-queries"; -import { getProviderIconInfo } from "@/lib/provider-icon"; -import { ProviderIconMark } from "./ProviderIconMark"; -import { cn } from "@bb/shared-ui/lib/utils"; -import { - formatUsageReset, - formatUsdCents, - usageBarColorClass, -} from "@bb/shared-ui/lib/usage-format"; - -interface ProviderConfig { - name: string; - providerId: string; - signInHint: string; - expiredHint: string; - strings: ProviderInfo["strings"]; - provider: ProviderInfo | undefined; -} - -function providerConfig( - providerId: string, - info: ProviderInfo | undefined, -): ProviderConfig { - const name = info?.displayName ?? providerId; - return { - providerId, - name, - strings: info?.strings, - provider: info, - signInHint: - info?.strings?.signInHint ?? `Sign in to ${name}, then reload usage.`, - expiredHint: - info?.strings?.expiredHint ?? - `Your ${name} session expired. Sign in again, then reload usage.`, - }; -} - -function usageWindowValue(window: ProviderUsageWindow): string { - if (!window.cost) { - return `${window.usedPercent}% used`; - } - return `${formatUsdCents(window.cost.usedUsdCents, true)} / ${formatUsdCents(window.cost.limitUsdCents, false)}`; -} - -function UsageWindowRow({ window }: { window: ProviderUsageWindow }) { - const reset = formatUsageReset(window.resetsAt); - return ( -
-
- {window.label} - - {usageWindowValue(window)} - -
-
-
-
- {reset ?

{reset}

: null} -
- ); -} - -interface ProviderUsageBlockProps { - accountLabel?: string; - config: ProviderConfig; - usage: ProviderUsage | undefined; - isLoading: boolean; - isError: boolean; -} - -interface UsageLocation { - id: string; - name: string; - kind: "host" | "source"; - disabled: boolean; -} - -export interface UsageLimitsSettingsSectionContentProps { - sourceNotice?: string | null; - emptySourceMessage?: string; - resources?: Array<{ - key: string; - isError?: boolean; - resource: UsageResourceList["resources"][number] & - Partial; - }>; - usage: ProviderUsageResponse; - isLoading: boolean; - isError: boolean; - isProviderListLoading?: boolean; - isProviderListError?: boolean; - isFetching: boolean; - onRefresh: () => void; - providerStates?: Readonly>; - providers?: readonly ProviderInfo[]; - locations?: readonly UsageLocation[]; - selectedLocationId?: string | null; - onSelectLocation?: (locationId: string) => void; -} - -function UsageLocationPicker({ - locations, - selectedLocationId, - onSelectLocation, -}: { - locations: readonly UsageLocation[]; - selectedLocationId: string | null; - onSelectLocation: (locationId: string) => void; -}) { - const selectedLocation = - locations.find((location) => location.id === selectedLocationId) ?? - locations[0]; - - return ( - - - - - - {locations.map((location) => { - const connected = !location.disabled; - return ( - onSelectLocation(location.id)} - className="flex items-center gap-2" - > - - {location.name} - {location.id === selectedLocation?.id ? ( - - ) : null} - - ); - })} - - - ); -} - -function ProviderUsageBlock({ - accountLabel, - config, - usage, - isLoading, - isError, -}: ProviderUsageBlockProps) { - const planLabel = usage?.status === "ok" ? usage.planLabel : null; - const accountEmail = - accountLabel ?? (usage?.status === "ok" ? usage.accountEmail : null); - const iconInfo = getProviderIconInfo( - "agent", - config.providerId, - config.provider ?? null, - ); - const ProviderIcon = iconInfo?.icon; - const headingId = useId(); - const showsUsageWindows = - !isError && usage?.status === "ok" && usage.windows.length > 0; - - return ( -
-
-
- {ProviderIcon ? ( - - ) : null} -
-

- {config.name} -

- {accountEmail && accountEmail !== config.name ? ( -

- {accountEmail} -

- ) : null} - {!showsUsageWindows ? ( -
- -
- ) : null} -
-
- {planLabel ? {planLabel} : null} -
- {showsUsageWindows ? ( -
- -
- ) : null} -
- ); -} - -function UsageResourceGroup({ - config, - resources, - isLoading, -}: { - config: ProviderConfig; - resources: NonNullable; - isLoading: boolean; -}) { - const hasIcon = Boolean( - getProviderIconInfo("agent", config.providerId, config.provider ?? null) - ?.icon, - ); - return ( -
- {resources.map(({ key, resource, isError }, index) => { - const email = - resource.usage?.accountEmail ?? - (resource.label === config.name ? "Account" : resource.label); - const usage = - resource.usage?.status === "ok" - ? { - ...resource.usage, - windows: resource.usage.windows.map(({ cost, ...window }) => - cost === null ? window : { ...window, cost }, - ), - } - : resource.usage; - const accountConfig = - resource.scope.kind === "shared" - ? { - ...config, - signInHint: - "Sign in to this account in the source plugin’s settings, then reload usage.", - expiredHint: - "This account’s session expired. Sign in again in the source plugin’s settings, then reload usage.", - } - : config; - const failed = isError === true && usage === undefined; - if (index === 0) - return ( - - ); - return ( -
-
-

- {email} -

- {usage?.status === "ok" && usage.planLabel ? ( - {usage.planLabel} - ) : null} -
- -
- ); - })} -
- ); -} - -function ProviderUsageBody({ - config, - usage, - isLoading, - isError, -}: ProviderUsageBlockProps) { - if (isError) { - return ( -

- Couldn't load usage right now. Try reloading usage. -

- ); - } - if (!usage) { - return ( -

- {isLoading ? "Loading usage…" : "Usage not provided."} -

- ); - } - switch (usage.status) { - case "ok": - if (usage.windows.length === 0) { - return ( -

- No usage limits reported for this plan. -

- ); - } - return ( -
- {usage.windows.map((window) => ( - - ))} -
- ); - case "not_installed": - return ( -

- Not installed on this machine. -

- ); - case "unauthenticated": - return ( -

{config.signInHint}

- ); - case "expired": - return ( -

{config.expiredHint}

- ); - case "error": - return

{usage.message}

; - default: - return null; - } -} - -export function UsageLimitsSettingsSectionContent({ - usage, - sourceNotice = null, - emptySourceMessage = "No providers report usage limits on this machine.", - resources, - isLoading, - isError, - isProviderListLoading = false, - isProviderListError = false, - isFetching, - onRefresh, - providerStates = {}, - providers = [], - locations = [], - selectedLocationId = null, - onSelectLocation, -}: UsageLimitsSettingsSectionContentProps) { - const showLocationPicker = - locations.length > 1 && onSelectLocation !== undefined; - const providerById = new Map( - providers.map((provider) => [provider.id, provider] as const), - ); - const resourceGroups = new Map< - string, - NonNullable - >(); - for (const entry of resources ?? []) { - const group = resourceGroups.get(entry.resource.providerId); - if (group) group.push(entry); - else resourceGroups.set(entry.resource.providerId, [entry]); - } - const reportedProviderIds = Object.keys(usage); - const orderedProviderIds = [ - ...providers - .filter((provider) => provider.maintenance.usage) - .map((provider) => provider.id), - ...reportedProviderIds.filter( - (providerId) => !providerById.has(providerId), - ), - ]; - const providerConfigs = orderedProviderIds - .filter((providerId) => usage[providerId]?.status !== "not_installed") - .map((providerId) => - providerConfig(providerId, providerById.get(providerId)), - ); - const emptyMessage = - isLoading || isProviderListLoading - ? "Loading providers and usage…" - : isError || isProviderListError - ? "Couldn't load providers or usage right now." - : resources !== undefined - ? emptySourceMessage - : "No providers available."; - return ( - - {showLocationPicker ? ( - - ) : null} - - - - - Reload usage data - -
- } - > - - {sourceNotice ? ( -

- {sourceNotice} -

- ) : null} - {resources !== undefined ? ( - resources.length === 0 ? ( - sourceNotice ? null : ( -

{emptyMessage}

- ) - ) : ( - [...resourceGroups].map(([providerId, accounts]) => ( - - )) - ) - ) : providerConfigs.length === 0 ? ( -

{emptyMessage}

- ) : ( - providerConfigs.map((config) => ( - - )) - )} -
- - ); -} - -export function UsageLimitsSettingsSection() { - const systemConfigQuery = useSystemConfig(); - const hostsQuery = useHosts(); - const hosts = useMemo( - () => selectHosts(hostsQuery.data, "persistent"), - [hostsQuery.data], - ); - const [selectedLocationId, setSelectedLocationId] = useState( - null, - ); - const primaryHost = selectPrimaryHost( - hosts, - systemConfigQuery.data?.primaryHostId ?? null, - ); - const selectedHost = - hosts.find((host) => host.id === selectedLocationId) ?? primaryHost; - const usageHostId = - selectedHost?.id ?? systemConfigQuery.data?.primaryHostId ?? undefined; - const providersQuery = useSystemProviders( - usageHostId === undefined - ? { - capability: "usage", - enabled: systemConfigQuery.data !== undefined, - } - : { - capability: "usage", - enabled: systemConfigQuery.data !== undefined, - hostId: usageHostId, - }, - ); - const providers = providersQuery.data ?? []; - const usageQuery = useUsageSources(); - const sharedSources = usageQuery.sources.filter( - ({ query }) => - query.data?.label !== undefined || - query.data?.resources.some( - (resource) => resource.scope.kind === "shared", - ), - ); - const locations: UsageLocation[] = [ - ...sharedSources.map(({ source, query }) => ({ - id: `source:${source.pluginId}`, - name: query.data?.label ?? source.displayName, - kind: "source" as const, - disabled: false, - })), - ...hosts.map((host) => ({ - id: host.id, - name: host.name, - kind: "host" as const, - disabled: host.status !== "connected", - })), - ]; - const selectedLocation = - locations.find((location) => location.id === selectedLocationId) ?? - locations.find((location) => location.kind === "source") ?? - locations.find((location) => location.id === primaryHost?.id) ?? - locations[0]; - const selectedSources = usageQuery.sources.filter(({ source, query }) => - selectedLocation?.kind === "source" - ? selectedLocation.id === `source:${source.pluginId}` - : !query.data || - query.data.resources.some( - (resource) => - resource.scope.kind === "host" && - resource.scope.hostId === selectedLocation?.id, - ), - ); - const listedResources = selectUsageResources( - selectedSources - .flatMap(({ source, query }) => - (query.data?.resources ?? []) - .filter((resource) => - selectedLocation?.kind === "source" - ? resource.scope.kind === "shared" - : resource.scope.kind === "host" && - resource.scope.hostId === selectedLocation?.id, - ) - .map((resource) => ({ - key: `${source.pluginId}:${resource.id}`, - pluginId: source.pluginId, - resource, - })), - ) - .sort((a, b) => { - const rank = (id: string) => { - const index = providers.findIndex((provider) => provider.id === id); - return index < 0 ? providers.length : index; - }; - return rank(a.resource.providerId) - rank(b.resource.providerId); - }), - (entry) => entry.resource, - ); - - const measurements = useUsageMeasurements( - listedResources - .filter( - ({ resource }) => - (selectedLocationId !== null || - selectedLocation?.kind === "source" || - usageQuery.sources.every(({ query }) => !query.isPending)) && - (resource.scope.kind === "shared" || !selectedLocation?.disabled), - ) - .map(({ pluginId, resource }) => ({ pluginId, resourceId: resource.id })), - ); - const resources = selectUsageResources( - listedResources - .map((entry, index) => ({ - ...entry, - isError: measurements.queries[index]?.isError ?? false, - resource: { ...entry.resource, ...measurements.queries[index]?.data }, - })) - .filter(({ resource }) => resource.usage?.status !== "not_installed"), - (entry) => entry.resource, - ); - - return ( - query.isError && !query.data, - ) - ? "Some usage sources couldn’t be loaded. Try reloading usage." - : selectedSources.some(({ query }) => query.isError) || - measurements.queries.some((query) => query.isError) - ? measurements.queries.some( - (query) => query.isError && !query.data, - ) - ? "Some usage couldn’t be loaded. Try reloading usage." - : "Couldn’t refresh usage. Showing the last update. Try reloading usage." - : null - } - isLoading={ - usageQuery.discovery.isPending || - selectedSources.some(({ query }) => query.isPending) || - measurements.queries.some((query) => query.isPending) - } - isError={ - usageQuery.discovery.isError || - selectedSources.some(({ query }) => query.isError) || - measurements.queries.some((query) => query.isError) - } - isProviderListLoading={providersQuery.isLoading} - isProviderListError={providersQuery.isError} - isFetching={usageQuery.isFetching || measurements.isFetching} - onRefresh={() => { - void usageQuery.refresh().then(() => measurements.refresh()); - }} - providers={providers} - locations={locations} - selectedLocationId={selectedLocation?.id ?? null} - onSelectLocation={setSelectedLocationId} - /> - ); -} diff --git a/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx b/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx deleted file mode 100644 index 0390012de3..0000000000 --- a/apps/app/src/components/settings/UsageSourcesSettingsSection.test.tsx +++ /dev/null @@ -1,374 +0,0 @@ -// @vitest-environment jsdom -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { - cleanup, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; -import { afterEach, expect, it, vi } from "vitest"; -import { TooltipProvider } from "@bb/shared-ui/tooltip"; -import { makeHost } from "@bb/test-helpers/domain-fixtures"; -import { UsageLimitsSettingsSection } from "./UsageLimitsSettingsSection"; - -const calls = vi.hoisted(() => ({ discover: vi.fn(), rpc: vi.fn() })); -vi.mock("@/lib/sdk", () => ({ - sdk: { - plugins: { experimental_discoverRpc: calls.discover, callRpc: calls.rpc }, - }, -})); -vi.mock("@/hooks/queries/system-queries", () => ({ - useSystemProviders: () => ({ data: [], isSuccess: true }), - useSystemConfig: () => ({ data: { primaryHostId: "host-a" } }), -})); - -vi.mock("@/hooks/queries/host-queries", () => ({ - useHosts: () => ({ - data: [makeHost({ id: "host-a", name: "Build machine" })], - }), - selectHosts: (hosts: unknown[] | undefined) => hosts ?? [], - selectPrimaryHost: (hosts: unknown[]) => hosts[0], -})); - -afterEach(() => { - cleanup(); - vi.clearAllMocks(); -}); - -it("selects pooled or machine usage without mixing sources, preserves cards, and refreshes through the copied contract", async () => { - calls.discover.mockResolvedValue([ - { pluginId: "pool", displayName: "Account Pooler [Experimental]" }, - { pluginId: "local", displayName: "Codex provider" }, - { pluginId: "broken", displayName: "Unavailable provider" }, - ]); - calls.rpc.mockImplementation(async ({ pluginId, method }) => { - if (pluginId === "broken") throw new Error("Unavailable"); - const snapshot = { - ...(pluginId === "pool" ? { label: "Account Pooler" } : {}), - resources: [ - { - id: "same-local-id", - providerId: "codex", - label: pluginId === "pool" ? "Pooled account" : "Local account", - scope: - pluginId === "pool" - ? { kind: "shared" } - : { kind: "host", hostId: "host-a", hostName: "Build machine" }, - observedAt: 1_700_000_000_000, - usage: { - status: "ok", - accountEmail: "person@example.com", - planLabel: null, - windows: [ - { - id: "weekly", - label: "Weekly", - usedPercent: pluginId === "pool" ? 42 : 81, - resetsAt: null, - model: null, - cost: null, - }, - ], - }, - }, - ], - }; - return method.endsWith("listResources") - ? snapshot - : { - observedAt: snapshot.resources[0]!.observedAt, - usage: snapshot.resources[0]!.usage, - }; - }); - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - try { - render( - - - - - , - ); - expect(await screen.findByText("42% used")).toBeTruthy(); - expect(screen.queryByText("81% used")).toBeNull(); - expect(screen.getAllByText("person@example.com")).toHaveLength(1); - expect(screen.queryByText(/Shared across machines/)).toBeNull(); - expect(screen.getByText("Account Pooler")).toBeTruthy(); - fireEvent.pointerDown( - screen.getByRole("button", { name: "Usage source" }), - { button: 0 }, - ); - fireEvent.click(screen.getByRole("menuitem", { name: "Build machine" })); - expect(await screen.findByText("81% used")).toBeTruthy(); - expect(screen.queryByText("42% used")).toBeNull(); - expect(screen.queryByText(/Observed/)).toBeNull(); - expect(screen.getByText("Your provider subscription usage.")).toBeTruthy(); - await waitFor(() => - expect( - screen.getByLabelText("Reload usage data").hasAttribute("disabled"), - ).toBe(false), - ); - fireEvent.click(screen.getByLabelText("Reload usage data")); - await waitFor(() => - expect(calls.rpc).toHaveBeenCalledWith( - expect.objectContaining({ - pluginId: "local", - method: "provider-usage.v1.getResource", - input: { resourceId: "same-local-id", refresh: true }, - }), - ), - ); - } finally { - client.clear(); - } -}); - -it("distinguishes an empty shared source, empty host sources, removal, and discovery failure", async () => { - calls.discover.mockResolvedValue([ - { pluginId: "pool", displayName: "Account Pooler [Experimental]" }, - { pluginId: "local", displayName: "Local provider" }, - ]); - calls.rpc.mockImplementation(async ({ pluginId }) => - pluginId === "pool" - ? { label: "Account Pooler", resources: [] } - : { resources: [] }, - ); - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - try { - render( - - - - - , - ); - expect(await screen.findByText("Account Pooler")).toBeTruthy(); - expect(screen.getByText(/No accounts report usage yet/)).toBeTruthy(); - expect(screen.queryByText("Local provider")).toBeNull(); - calls.discover.mockResolvedValue([]); - fireEvent.click(screen.getByLabelText("Reload usage data")); - expect( - await screen.findByText(/No usage sources are available/), - ).toBeTruthy(); - expect(screen.queryByText("Account Pooler")).toBeNull(); - calls.discover.mockRejectedValue(new Error("private technical details")); - fireEvent.click(screen.getByLabelText("Reload usage data")); - expect(await screen.findByRole("status")).toHaveProperty( - "textContent", - "Couldn’t discover usage sources. Try reloading usage.", - ); - expect(screen.queryByText(/private technical/)).toBeNull(); - } finally { - client.clear(); - } -}); - -it("keeps successful measurements visible after a failed refresh and recovers", async () => { - calls.discover.mockResolvedValue([ - { pluginId: "pool", displayName: "Account Pooler" }, - ]); - const snapshot = { - label: "Account Pooler", - resources: [ - { - id: "account", - providerId: "codex", - label: "Account", - scope: { kind: "shared" }, - observedAt: 123, - usage: { - status: "ok", - accountEmail: "person@example.com", - planLabel: null, - windows: [ - { - id: "weekly", - label: "Weekly", - usedPercent: 42, - resetsAt: null, - model: null, - cost: null, - }, - ], - }, - }, - ], - }; - calls.rpc.mockImplementation(async ({ method }) => - method.endsWith("listResources") - ? snapshot - : { - observedAt: snapshot.resources[0]!.observedAt, - usage: snapshot.resources[0]!.usage, - }, - ); - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - try { - render( - - - - - , - ); - expect(await screen.findByText("42% used")).toBeTruthy(); - calls.rpc.mockRejectedValue(new Error("Unexpected token b")); - fireEvent.click(screen.getByLabelText("Reload usage data")); - expect(await screen.findByText(/Showing the last update/)).toBeTruthy(); - expect(screen.getByText("42% used")).toBeTruthy(); - expect(screen.queryByText(/Unexpected token/)).toBeNull(); - calls.rpc.mockImplementation(async ({ method }) => - method.endsWith("listResources") - ? snapshot - : { - observedAt: snapshot.resources[0]!.observedAt, - usage: snapshot.resources[0]!.usage, - }, - ); - fireEvent.click(screen.getByLabelText("Reload usage data")); - await waitFor(() => - expect(screen.queryByText(/Showing the last update/)).toBeNull(), - ); - } finally { - client.clear(); - } -}); - -it("waits for the default shared inventory before fetching a fallback machine", async () => { - let release!: (value: { label: string; resources: never[] }) => void; - const pool = new Promise<{ label: string; resources: never[] }>((resolve) => { - release = resolve; - }); - calls.discover.mockResolvedValue([ - { pluginId: "pool", displayName: "Pool" }, - { pluginId: "local", displayName: "Local" }, - ]); - calls.rpc.mockImplementation(async ({ pluginId, method }) => { - if (!method.endsWith("listResources")) - throw new Error("Should not collect any quota for an empty pool"); - return pluginId === "pool" - ? pool - : { - resources: [ - { - id: "host-a", - providerId: "codex", - label: "Codex", - scope: { - kind: "host", - hostId: "host-a", - hostName: "Build machine", - }, - }, - ], - }; - }); - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - try { - render( - - - - - , - ); - await waitFor(() => - expect(calls.rpc).toHaveBeenCalledWith( - expect.objectContaining({ - pluginId: "local", - method: "provider-usage.v1.listResources", - }), - ), - ); - release({ label: "Pool", resources: [] }); - expect( - await screen.findByText(/No accounts report usage yet/), - ).toBeTruthy(); - expect( - calls.rpc.mock.calls.every(([args]) => - args.method.endsWith("listResources"), - ), - ).toBe(true); - } finally { - client.clear(); - } -}); - -it("deduplicates known inventory identities without merging unknown accounts sharing an email", async () => { - calls.discover.mockResolvedValue([ - { pluginId: "pool", displayName: "Account Pooler" }, - ]); - calls.rpc.mockImplementation(async ({ method, input }) => - method.endsWith("listResources") - ? { - label: "Account Pooler", - resources: ["first", "duplicate", "unknown", "unknown2"].map( - (id) => ({ - id, - providerId: "custom", - accountKey: id.startsWith("unknown") ? null : "issuer:account:1", - label: "person@example.com", - scope: { kind: "shared" }, - }), - ), - } - : { - accountKey: input.resourceId.startsWith("unknown") - ? null - : "issuer:account:1", - observedAt: 123, - usage: { - status: "ok", - accountEmail: "person@example.com", - planLabel: "max", - plan: { id: "max", multiplier: 20 }, - windows: [ - { - id: "week", - kind: "weekly", - label: "168 hour window", - model: null, - resetsAt: null, - cost: null, - usedPercent: 42, - }, - ], - }, - }, - ); - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - try { - render( - - - - - , - ); - await waitFor(() => - expect(screen.getAllByText("42% used")).toHaveLength(3), - ); - expect(screen.getAllByRole("heading", { level: 3 })).toHaveLength(1); - expect(screen.getAllByText("person@example.com")).toHaveLength(3); - expect(screen.getAllByText("Max (20x)")).toHaveLength(3); - expect(screen.getAllByText("Weekly limit")).toHaveLength(3); - expect( - calls.rpc.mock.calls - .filter(([args]) => args.method.endsWith("getResource")) - .map(([args]) => args.input.resourceId), - ).toEqual(["first", "unknown", "unknown2"]); - } finally { - client.clear(); - } -}); diff --git a/apps/app/src/components/settings/settings-sections.ts b/apps/app/src/components/settings/settings-sections.ts index fa9cb2537b..2614be7ad7 100644 --- a/apps/app/src/components/settings/settings-sections.ts +++ b/apps/app/src/components/settings/settings-sections.ts @@ -7,7 +7,6 @@ export const SETTINGS_NAV_SECTIONS = [ { icon: "Palette", id: "appearance", label: "Appearance" }, { icon: "SlidersHorizontal", id: "keyboard", label: "Keyboard" }, { icon: "Browser", id: "browser", label: "Browser" }, - { icon: "ChartColumn", id: "usage", label: "Usage limits" }, { icon: "File", id: "files", label: "Files" }, { icon: "FolderGit", id: "projects", label: "Projects" }, { icon: "Laptop", id: "machines", label: "Machines" }, diff --git a/apps/app/src/hooks/queries/usage-source-queries.ts b/apps/app/src/hooks/queries/usage-source-queries.ts deleted file mode 100644 index 8f2a991756..0000000000 --- a/apps/app/src/hooks/queries/usage-source-queries.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { normalizeUsageMeasurement } from "@/lib/usage-normalization"; -import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; -import { sdk } from "@/lib/sdk"; -import { - usageListMethod, - usageFetchMethod, - usageResourceListSchema, - usageMeasurementSchema, -} from "@/lib/usage-source-contract"; - -const discoveryKey = ["pluginRpcDiscovery", usageListMethod] as const; -const sourceKey = (pluginId: string) => - ["pluginUsageInventory", pluginId] as const; -const resourceKey = (pluginId: string, resourceId: string) => - ["pluginUsageMeasurement", pluginId, resourceId] as const; -let active = 0; -const waiting: Array<() => void> = []; - -async function loadResource( - pluginId: string, - resourceId: string, - refresh: boolean, - signal: AbortSignal, -) { - if (active >= 3) await new Promise((resolve) => waiting.push(resolve)); - else active++; - try { - signal.throwIfAborted(); - const value = normalizeUsageMeasurement( - await sdk.plugins.callRpc({ - pluginId, - method: usageFetchMethod, - input: { resourceId, refresh }, - outputSchema: usageMeasurementSchema, - signal: AbortSignal.any([signal, AbortSignal.timeout(45_000)]), - }), - ); - if (value.usage.status === "error") - throw new Error("Usage could not be refreshed."); - return value; - } finally { - const next = waiting.shift(); - if (next === undefined) active--; - else next(); - } -} - -export function useUsageSources() { - const discovery = useQuery({ - queryKey: discoveryKey, - queryFn: () => - sdk.plugins.experimental_discoverRpc({ method: usageListMethod }), - staleTime: 10_000, - refetchInterval: 30_000, - }); - const sources = discovery.data ?? []; - const queries = useQueries({ - queries: sources.map((source) => ({ - queryKey: sourceKey(source.pluginId), - queryFn: ({ signal }: { signal: AbortSignal }) => - sdk.plugins.callRpc({ - pluginId: source.pluginId, - method: usageListMethod, - input: {}, - outputSchema: usageResourceListSchema, - signal: AbortSignal.any([signal, AbortSignal.timeout(45_000)]), - }), - staleTime: 30_000, - refetchInterval: 30_000, - retry: false, - })), - }); - return { - discovery, - sources: sources.map((source, index) => ({ - source, - query: queries[index]!, - })), - isFetching: - discovery.isFetching || queries.some((query) => query.isFetching), - async refresh() { - await discovery.refetch(); - await Promise.allSettled(queries.map((query) => query.refetch())); - }, - }; -} - -export function useUsageMeasurements( - resources: Array<{ pluginId: string; resourceId: string }>, -) { - const client = useQueryClient(); - const queries = useQueries({ - queries: resources.map(({ pluginId, resourceId }) => ({ - queryKey: resourceKey(pluginId, resourceId), - queryFn: ({ signal }: { signal: AbortSignal }) => - loadResource(pluginId, resourceId, false, signal), - staleTime: 30_000, - retry: false, - })), - }); - return { - queries, - isFetching: queries.some((query) => query.isFetching), - async refresh() { - await Promise.allSettled( - resources.map(({ pluginId, resourceId }) => - client.fetchQuery({ - queryKey: resourceKey(pluginId, resourceId), - queryFn: ({ signal }) => - loadResource(pluginId, resourceId, true, signal), - staleTime: 0, - }), - ), - ); - }, - }; -} diff --git a/apps/app/src/lib/usage-normalization.ts b/apps/app/src/lib/usage-normalization.ts deleted file mode 100644 index 9b6f1dfc68..0000000000 --- a/apps/app/src/lib/usage-normalization.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { UsageMeasurement } from "./usage-source-contract.js"; - -export function normalizeUsageMeasurement( - measurement: UsageMeasurement, -): UsageMeasurement { - if (measurement.usage.status !== "ok") return measurement; - const usage = measurement.usage; - const labels: Record = { - free: "Free", - go: "Go", - plus: "Plus", - pro: "Pro", - max: "Max", - team: "Team", - business: "Business", - enterprise: "Enterprise", - education: "Education", - edu: "Education", - }; - const plan = usage.plan; - const planName = plan ? labels[plan.id] : undefined; - const windowLabels = { - "five-hour": "Five-hour limit", - daily: "Daily limit", - weekly: "Weekly limit", - custom: "", - }; - return { - ...measurement, - usage: { - ...usage, - planLabel: planName - ? `${planName}${plan?.multiplier == null ? "" : ` (${plan.multiplier}x)`}` - : usage.planLabel, - windows: usage.windows.map((window) => ({ - ...window, - label: - window.kind && window.kind !== "custom" - ? window.model - ? `${window.kind === "weekly" ? "Weekly" : windowLabels[window.kind]} · ${window.model.charAt(0).toUpperCase() + window.model.slice(1)}` - : windowLabels[window.kind] - : window.label, - })), - }, - }; -} - -type Identity = { - providerId: string; - accountKey?: string | null; - scope: { kind: "shared" | "host" }; -}; - -export function selectUsageResources( - resources: readonly T[], - identify: (resource: T) => Identity, -): T[] { - const result: T[] = []; - const known = new Map(); - for (const resource of resources) { - const identity = identify(resource); - if (!identity.accountKey) { - result.push(resource); - continue; - } - const key = JSON.stringify([identity.providerId, identity.accountKey]); - const index = known.get(key); - if (index === undefined) { - known.set(key, result.length); - result.push(resource); - } else if ( - identity.scope.kind === "shared" && - identify(result[index]!).scope.kind !== "shared" - ) - result[index] = resource; - } - return result; -} diff --git a/apps/app/src/lib/usage-source-contract.ts b/apps/app/src/lib/usage-source-contract.ts deleted file mode 100644 index 30f6537436..0000000000 --- a/apps/app/src/lib/usage-source-contract.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { z } from "zod"; - -export const usagePlanSchema = z.object({ - id: z.string().min(1), - multiplier: z.number().int().positive().nullable(), -}); -const accountFields = { - plan: usagePlanSchema.nullable().default(null), - accountEmail: z.string().nullable(), - planLabel: z.string().nullable(), -}; -export const usageWindowKindSchema = z.enum([ - "five-hour", - "daily", - "weekly", - "custom", -]); -const usageWindowSchema = z.object({ - kind: usageWindowKindSchema.default("custom"), - id: z.string().min(1), - label: z.string().min(1), - usedPercent: z - .number() - .nonnegative() - .describe("Percentage consumed; may exceed 100 for overage."), - resetsAt: z - .string() - .nullable() - .describe("ISO timestamp, or null when no reset is known."), - model: z - .string() - .nullable() - .describe("Applicable model family, or null for all models."), - cost: z - .object({ - usedUsdCents: z.number().nonnegative(), - limitUsdCents: z.number().positive(), - }) - .nullable(), -}); -const usageSchema = z.discriminatedUnion("status", [ - z.object({ - status: z.literal("ok"), - ...accountFields, - windows: z.array(usageWindowSchema), - }), - z.object({ status: z.literal("not_installed"), ...accountFields }), - z.object({ status: z.literal("unauthenticated"), ...accountFields }), - z.object({ status: z.literal("expired"), ...accountFields }), - z.object({ - status: z.literal("error"), - ...accountFields, - message: z.string(), - }), -]); -export const usageAccountKeySchema = z - .string() - .min(1) - .nullable() - .default(null) - .describe( - "Provider-issued quota account identity, namespaced by issuer and account/organization scope. Never use email, a display label, a source-local ID, or credentials. Null means unknown; unknown accounts must not be merged.", - ); -export const usageResourceSchema = z.object({ - accountKey: usageAccountKeySchema, - id: z - .string() - .min(1) - .describe("Stable resource ID within this source plugin."), - providerId: z.string().min(1), - label: z.string().min(1), - scope: z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("shared") }), - z.object({ - kind: z.literal("host"), - hostId: z.string().min(1), - hostName: z.string().min(1), - }), - ]), -}); -export const usageResourceListSchema = z.object({ - label: z - .string() - .min(1) - .optional() - .describe( - "Declares a shared group even when empty. Host-only sources omit it; machine groups use host names.", - ), - resources: z.array(usageResourceSchema), -}); -export const usageMeasurementSchema = z.object({ - accountKey: usageAccountKeySchema, - observedAt: z - .number() - .int() - .nonnegative() - .nullable() - .describe( - "Last successful measurement time in epoch milliseconds; null if never observed.", - ), - usage: usageSchema, -}); -export const usageListInputSchema = z.object({}); -export const usageFetchInputSchema = z.object({ - resourceId: z.string().min(1), - refresh: z - .boolean() - .describe( - "False permits a cached measurement but still returns actual usage. True requests a fresh collection attempt for this resource only.", - ), -}); -export type UsageResourceList = z.infer; -export type UsageMeasurement = z.infer; -export type UsageResource = z.infer & - UsageMeasurement; -export const usageListMethod = "provider-usage.v1.listResources"; -export const usageFetchMethod = "provider-usage.v1.getResource"; diff --git a/apps/app/src/views/SettingsView.stories.tsx b/apps/app/src/views/SettingsView.stories.tsx index a68fa826be..afbe52040f 100644 --- a/apps/app/src/views/SettingsView.stories.tsx +++ b/apps/app/src/views/SettingsView.stories.tsx @@ -5,17 +5,13 @@ import { defaultExperiments, type AppTheme, type Experiments, - type Host, defaultAppSettings, type AppSettings, } from "@bb/domain"; -import { makeHost } from "@bb/test-helpers/domain-fixtures"; import type { - ProviderUsage, WorkspaceOpenTarget, WorkspaceOpenTargetId, } from "@bb/host-daemon-contract"; -import { UsageLimitsSettingsSectionContent } from "@/components/settings/UsageLimitsSettingsSection"; import { VoiceInputSettingsSectionContent } from "@/components/settings/VoiceInputSettingsSection"; import { ArchivedThreadsSettingsSection } from "@/components/settings/ArchivedThreadsSettingsSection"; import { CommunitySettingsSection } from "@/components/settings/CommunitySettingsSection"; @@ -109,86 +105,6 @@ const connectedTargets: WorkspaceOpenTarget[] = [ defaultAppTarget, ]; -function futureIso(minutesFromNow: number): string { - return new Date(Date.now() + minutesFromNow * 60_000).toISOString(); -} - -const usageFixture: { - codex: ProviderUsage; - "claude-code": ProviderUsage; - "acp-cursor": ProviderUsage; -} = { - codex: { - status: "ok", - accountEmail: "sawyer@example.com", - planLabel: "Pro", - windows: [ - { - label: "Current session", - resetsAt: futureIso(136), - usedPercent: 35, - }, - { - label: "Weekly limit", - resetsAt: futureIso(48), - usedPercent: 74, - }, - ], - }, - "claude-code": { - status: "ok", - accountEmail: "sawyer@example.com", - planLabel: "Max (20x)", - windows: [ - { - label: "Current session", - resetsAt: futureIso(179), - usedPercent: 3, - }, - { - label: "Weekly limit", - resetsAt: futureIso(4 * 24 * 60), - usedPercent: 26, - }, - ], - }, - "acp-cursor": { - status: "ok", - accountEmail: "sawyer@example.com", - planLabel: "Pro", - windows: [ - { - label: "Plan usage", - resetsAt: futureIso(14 * 24 * 60), - usedPercent: 72, - }, - { - label: "On-demand spend", - resetsAt: futureIso(14 * 24 * 60), - usedPercent: 25, - cost: { usedUsdCents: 1_250, limitUsdCents: 5_000 }, - }, - ], - }, -}; - -const usageHosts: Host[] = [ - makeHost({ - id: "host-macbook", - name: "MacBook Pro", - lastSeenAt: Date.now(), - createdAt: 1, - updatedAt: 1, - }), - makeHost({ - id: "host-studio", - name: "Mac Studio", - lastSeenAt: Date.now(), - createdAt: 1, - updatedAt: 1, - }), -]; - function useSettingsStoryState() { const [themePreference, setThemePreference] = useState("system"); @@ -367,32 +283,6 @@ function ExperimentsStory() { ); } -function UsageLimitsStory() { - const [isFetching, setIsFetching] = useState(false); - const [selectedHostId, setSelectedHostId] = useState("host-macbook"); - - return ( - { - setIsFetching(true); - window.setTimeout(() => setIsFetching(false), 500); - }} - locations={usageHosts.map((host) => ({ - id: host.id, - name: host.name, - kind: "host", - disabled: host.status !== "connected", - }))} - selectedLocationId={selectedHostId} - onSelectLocation={setSelectedHostId} - /> - ); -} - function ProvidersSettingsStory() { const [generalSettings, setGeneralSettings] = useState(defaultAppSettings); @@ -434,8 +324,6 @@ function SettingsStoryContent({ route }: { route: SettingsStoryRoute }) { return ; case "keyboard": return ; - case "usage": - return ; case "files": return ; case "projects": diff --git a/apps/app/src/views/SettingsView.tsx b/apps/app/src/views/SettingsView.tsx index 55847424be..d9457e2e4d 100644 --- a/apps/app/src/views/SettingsView.tsx +++ b/apps/app/src/views/SettingsView.tsx @@ -50,7 +50,6 @@ import { } from "@/hooks/useTheme"; import { useHostDaemon, useLocalHostDaemonAccess } from "@/hooks/useHostDaemon"; import { useAppThemePreview } from "@/hooks/useAppThemePreview"; -import { UsageLimitsSettingsSection } from "@/components/settings/UsageLimitsSettingsSection"; import { ProvidersSettingsSection } from "@/components/settings/ProvidersSettingsSection"; import { CodeRendererSettings } from "@/components/settings/CodeRendererSettings"; import { SidebarThreadListSetting } from "@/components/settings/SidebarThreadListSetting"; @@ -1162,8 +1161,6 @@ export function SettingsView() { onThemePreferenceChange={setPreferredTheme} /> ); - } else if (activeSection === "usage") { - content = ; } else if (activeSection === "keyboard") { content = ; } else if (activeSection === "browser") { diff --git a/apps/server/src/services/plugins/builtin-registry.ts b/apps/server/src/services/plugins/builtin-registry.ts index f6146c9332..2d21e2804e 100644 --- a/apps/server/src/services/plugins/builtin-registry.ts +++ b/apps/server/src/services/plugins/builtin-registry.ts @@ -106,7 +106,7 @@ export const BUILTIN_PLUGINS = [ { name: "provider-usage", pluginId: "provider-usage", - defaultEnabled: false, + defaultEnabled: true, }, { name: "provider-acp", diff --git a/apps/server/test/services/plugins/builtin-plugins.test.ts b/apps/server/test/services/plugins/builtin-plugins.test.ts index 1490bacfe4..6886d7ac5f 100644 --- a/apps/server/test/services/plugins/builtin-plugins.test.ts +++ b/apps/server/test/services/plugins/builtin-plugins.test.ts @@ -551,11 +551,11 @@ describe("builtin plugin reconciliation", () => { ]); }); - it("ships Provider usage disabled on a fresh database", async () => { + it("ships Provider usage enabled on a fresh database", async () => { const providerUsage = BUILTIN_PLUGINS.find( (builtin) => builtin.name === "provider-usage", ); - expect(providerUsage?.defaultEnabled).toBe(false); + expect(providerUsage?.defaultEnabled).toBe(true); service = createService({ db, @@ -570,8 +570,8 @@ describe("builtin plugin reconciliation", () => { { id: "provider-usage", source: "builtin:provider-usage", - enabled: false, - status: "disabled", + enabled: true, + status: "running", }, ]); }); diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index 2a0b5c2abb..2bded9e76f 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -8,7 +8,6 @@ Before stabilization, audit schema export fidelity (especially refinements and transforms), descriptor size and reference limits, lifecycle races, and cross-plugin copied-schema compatibility. Verify `bb plugin rpc list|inspect` is sufficient to implement a consumer without a shared contract package. Method names carry optional versions; there is no negotiation. - ## `bb.http.experimental_websocket` **What it does.** Registers an exact-path WebSocket upgrade in the plugin's @@ -2143,7 +2142,11 @@ single active disclosure across all plugins. A disclosure component owns everything inside its boundary and receives only `dismiss()`. Registering an action returns nothing. Registering a disclosure returns a -controller that can request `open`, `close`, or `toggle`. Those requests go +controller that can request `open`, `close`, or `toggle`. Its +`experimental_setVisible(boolean)` hides or restores both the shortcut and +disclosure. Hiding closes the disclosure; showing leaves it closed. Hidden +items ignore open requests. Audit reactive setting changes, sibling isolation, +unload and reload, and focus behavior before stabilizing visibility. Those requests go through the host's shared active-item coordinator, so opening one plugin's disclosure replaces another and a stale scoped `close` cannot dismiss a sibling. The existing `app.slots.sidebarFooterAction` remains a compatibility surface and @@ -2916,7 +2919,6 @@ returns a credential only while that host is creating. Before stabilizing, verify creation cancellation through host removal, same-host restoration, serialized removal, plugin callers and UI/CLI parity. - ## `app.experimental_icons.register` and `experimental_Icon` Plugins register inline React artwork during app setup with `{ name, component }`. @@ -2953,7 +2955,6 @@ plugin app icons use this registration API. The manifest API is unchanged, and individual plugins can still declare their own branding SVG assets using the existing manifest fields. - ## `experimental_ProviderIcon` Shared frontend renderer for agent, machine, and environment provider artwork, diff --git a/docs/configuration.md b/docs/configuration.md index dc9f372324..e78778ae9b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1423,3 +1423,11 @@ invalid, or corrupt entries are rebuilt; development and compiler diagnostic modes bypass the cache. The cache has no user configuration and can be removed while no builds are running. See [build performance](build-performance.md) for its identity, portability, and verification contract. + +## Provider Usage + +Provider Usage is enabled by default for newly registered installations; existing +plugin enable/disable choices are preserved. Its plugin settings page contains +provider subscription usage. The server-wide boolean `showFooterCard` (default +`true`) hides or restores the sidebar shortcut and card without disabling the +settings page: `bb plugin config provider-usage set showFooterCard false`. diff --git a/docs/discoverable-rpc-and-provider-usage-plan.md b/docs/discoverable-rpc-and-provider-usage-plan.md index d52b61c3c9..f25d2f3df1 100644 --- a/docs/discoverable-rpc-and-provider-usage-plan.md +++ b/docs/discoverable-rpc-and-provider-usage-plan.md @@ -1,10 +1,12 @@ # Discoverable RPC and replaceable provider usage displays -Status: prototype implemented for discoverable RPC, Account Pooler, the Codex, Claude Code, and ACP provider plugins, and the core `/settings/usage` page. Provider Usage defines the canonical contract in `plugins/provider-usage/usage-source-contract.ts` and consumes discovered sources through it. The core settings page preserves its existing provider cards and extends the machine picker to select shared sources such as Account Pooler. Shared sources are selected by default. +The former core usage route is removed. Provider Usage is enabled by default and owns the usage settings page, plus a `showFooterCard` setting (default true). Both surfaces reuse its private aggregation RPC and measurement cache. + +Status: prototype implemented for discoverable RPC, Account Pooler, the Codex, Claude Code, and ACP provider plugins, and Provider Usage’s settings page and footer card. Provider Usage defines the canonical contract in `plugins/provider-usage/usage-source-contract.ts` and consumes discovered sources through it. The plugin settings page preserves its existing provider cards and extends the machine picker to select shared sources such as Account Pooler. Shared sources are selected by default. ## Prototype verification -- Relevant typechecks pass for the app and all four plugins. Current focused/full package suites pass: 16 settings tests, 10 Provider Usage tests, 276 Account Pooler tests, 269 Codex tests, and 349 Claude Code tests. +- Relevant app, server, SDK and plugin typechecks pass. Current migration checks pass: 21 Provider Usage tests, 8 footer-host tests, 52 SDK app-harness tests, 74 Plugin Guide tests and 31 builtin-plugin tests. Earlier source implementation checks also covered Account Pooler, Codex, Claude Code and ACP. - Source tests prove that listing does not collect quota and fetching addresses one resource, including cached reads, forced reads, offline hosts, and removed resource IDs. - Display tests prove inventory-only discovery, selected provider/account fetching, cached failure preservation, empty groups, resource removal, and tab/source changes. Settings waits for default shared-source discovery before fetching a fallback host. - Live CLI discovery advertises both methods from all three sources. Browser request traces show only pool Codex on first open, Claude on tab selection, and four selected pool resources on settings. Existing configured accounts were retained. @@ -34,18 +36,23 @@ const usageContract = defineRpcContract({ "provider-usage.v1.getResource": { input: usageFetchInputSchema, output: usageMeasurementSchema, - experimental_description: "Fetch one resource’s actual usage; refresh=false permits cache, refresh=true requests a fresh attempt for this resource only.", + experimental_description: + "Fetch one resource’s actual usage; refresh=false permits cache, refresh=true requests a fresh attempt for this resource only.", }, }); -bb.rpc.register(usageContract, { - "provider-usage.v1.listResources": listResources, - "provider-usage.v1.getResource": getResource, -}, { - experimental_discoverable: true, - experimental_description: - "Usage windows for accounts managed by Account Pooler.", -}); +bb.rpc.register( + usageContract, + { + "provider-usage.v1.listResources": listResources, + "provider-usage.v1.getResource": getResource, + }, + { + experimental_discoverable: true, + experimental_description: + "Usage windows for accounts managed by Account Pooler.", + }, +); ``` The option publishes all methods in that registration. Plugins register internal methods separately. Omitting the option preserves current behavior: methods are callable by name but are not advertised. Discovery is not an authorization boundary. @@ -83,12 +90,14 @@ Consumers retain their own expected schemas and use the existing call API: ```ts const results = await Promise.allSettled( - sources.map((source) => bb.sdk.plugins.callRpc({ - pluginId: source.pluginId, - method: "provider-usage.v1.listResources", - input: {}, - outputSchema: usageResourceListSchema, - })), + sources.map((source) => + bb.sdk.plugins.callRpc({ + pluginId: source.pluginId, + method: "provider-usage.v1.listResources", + input: {}, + outputSchema: usageResourceListSchema, + }), + ), ); ``` @@ -142,7 +151,7 @@ Use two methods defined canonically by Provider Usage and copied locally by each - `provider-usage.v1.listResources({})` returns `{ label?, resources: [{ id, providerId, accountKey, label, scope }] }`. This is cheap local inventory; it never refreshes usage or contacts providers. The optional label declares an empty shared group. Host-only sources omit it. List order is display order. - `provider-usage.v1.getResource({ resourceId, refresh })` returns `{ accountKey, observedAt, usage }` for exactly one listed resource. False permits cached measurements but still returns actual usage. True requests a fresh collection attempt for that resource only. A removed resource fails explicitly, and consumers relist. -The sidebar lists all sources to construct its source picker and provider tabs, then fetches only accounts belonging to the selected provider and source/machine. Background reconciliation lists metadata only. Unopened tabs have unknown usage rather than a fabricated healthy badge; retained measurements may still supply badges. Settings fetches the resources in its selected pool or machine. Both keep independent per-resource caches, bounded collection concurrency, stale-data notices, and graceful failures. +The sidebar lists all sources to construct its source picker and provider tabs, then fetches only accounts belonging to the selected provider and source/machine. Background reconciliation lists metadata only. Unopened tabs have unknown usage rather than a fabricated healthy badge; retained measurements may still supply badges. Settings fetches the resources in its selected pool or machine. Both reuse Provider Usage’s per-resource cache, bounded collection concurrency, stale-data notices, and graceful failures. Account Pooler lists account metadata without refreshing, then calls its existing account-specific collection for fetch. Local provider sources list hosts without collecting quota and fetch only the requested host/provider pair. No display plugin is required for source registration or collection. @@ -154,7 +163,7 @@ Account Pooler lists account metadata without refreshing, then calls its existin ### Display implementation -Provider Usage discovers sources whenever it loads or refreshes data, invokes them with bounded concurrency and bounded wait, and renders successful results even if another source fails. The core settings page groups accounts beneath one provider heading and icon, using the same email/plan/usage layout for shared pools and machines. It preserves the refresh control. Its source picker defaults to a shared source when available, or the primary machine otherwise; explicit selections win. Shared-source selections show only that source’s shared accounts, and machine selections show only that machine’s host-local observations. Account headings use email without repeating it as a subtitle. Source-group headings and observation timestamps are not added to this page. Other display plugins can choose their own presentation using the same metadata. +Provider Usage discovers sources whenever it loads or refreshes data, invokes them with bounded concurrency and bounded wait, and renders successful results even if another source fails. The plugin settings page groups accounts beneath one provider heading and icon, using the same email/plan/usage layout for shared pools and machines. It preserves the refresh control. Its source picker defaults to a shared source when available, or the primary machine otherwise; explicit selections win. Shared-source selections show only that source’s shared accounts, and machine selections show only that machine’s host-local observations. Account headings use email without repeating it as a subtitle. Source-group headings and observation timestamps are not added to this page. Other display plugins can choose their own presentation using the same metadata. Namespace resource keys by reporting plugin ID. Do not deduplicate by email or sum unrelated quota percentages. A shared pool appears once in the source picker; local and pooled observations remain separate choices even when their account emails match. Source removal evicts its current display entries on the next reconciliation. @@ -200,7 +209,6 @@ Use Turbo for relevant package typechecks and tests. Extend the plugin test harn The result is complete when discovery and inspection are generally usable, usage producers implement the public convention, and either display can consume them independently. Provider Retry and thread-specific quota attribution are not prerequisites. - Usage-state review: both consumers distinguish loading, empty shared groups, unavailable sources, uninstalled providers, per-account authentication/collection failures, plans without reported limits, and offline machines. Shared-account sign-in guidance refers to the source plugin’s settings. Failed source refreshes preserve successful cached observations with a visible notice; disabled sources disappear on discovery reconciliation. Browser fixtures exercise source selection, removal, and retry recovery without changing configured accounts. Unfiltered CLI discovery omits undefined filters instead of serializing them into literal query values. ## Explicit provider implementations and normalization diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index d48cdf6d68..f64ec310ea 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -761,6 +761,8 @@ export type ExperimentalSidebarFooterItemRegistration = /** Live controls for an experimental sidebar-footer disclosure. */ export interface ExperimentalSidebarFooterDisclosureController { + /** Hide or show this item’s shortcut and disclosure. Hiding closes it; showing does not reopen it. */ + experimental_setVisible(visible: boolean): void; /** Request that the host open this disclosure, replacing any open sibling. */ open(): void; /** Close this disclosure if it is currently open. */ diff --git a/packages/plugin-sdk/src/internal/plugin-app-collector.ts b/packages/plugin-sdk/src/internal/plugin-app-collector.ts index 9d843b428d..384de9e654 100644 --- a/packages/plugin-sdk/src/internal/plugin-app-collector.ts +++ b/packages/plugin-sdk/src/internal/plugin-app-collector.ts @@ -48,6 +48,7 @@ import { export type ExperimentalSidebarFooterCommandKind = "open" | "close" | "toggle"; export interface ExperimentalSidebarFooterRuntimeSnapshot { + visible: boolean; command: { sequence: number; kind: ExperimentalSidebarFooterCommandKind; @@ -107,6 +108,7 @@ const SIDEBAR_FOOTER_DISCLOSURE_KEYS: ReadonlySet = new Set([ class SidebarFooterItemRuntime implements ExperimentalSidebarFooterItemRuntime { private readonly listeners = new Set<() => void>(); private snapshot: ExperimentalSidebarFooterRuntimeSnapshot = { + visible: true, command: null, }; @@ -126,6 +128,12 @@ class SidebarFooterItemRuntime implements ExperimentalSidebarFooterItemRuntime { createDisclosureController(): ExperimentalSidebarFooterDisclosureController { return Object.freeze({ + experimental_setVisible: (visible: boolean) => { + if (this.snapshot.visible === visible) return; + this.snapshot = { ...this.snapshot, visible }; + if (!visible) this.request("close"); + else this.emit(); + }, open: () => this.request("open"), close: () => this.request("close"), toggle: () => this.request("toggle"), diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index c839e4ad30..50bbceab82 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -927,3 +927,10 @@ Modal image debugging: `bb modal image build [--json]` prepares the saved image; `bb plugin rpc list [--method ] [--json]` lists discoverable methods from running plugins. `bb plugin rpc inspect [--method ] [--json]` dumps registration and method descriptions plus input/output JSON Schemas. Copy the relevant schema into your consumer and call the existing plugin RPC endpoint. Discovery is opt-in advertising, not access control; method names may carry versions such as `provider-usage.v1.listResources`. `bb plugin rpc call [--input-file ] [--json]` invokes a method using server-side schema validation. Omitting the input file sends JSON null. Input files avoid putting sensitive values in command arguments. + +### Provider usage + +Provider Usage is enabled by default and shows usage in its plugin settings page +and sidebar footer. Set `bb plugin config provider-usage set showFooterCard false` +to hide the footer shortcut and card while retaining the settings page. Set it to +`true` to restore them. See the `provider-usage` skill for source discovery. diff --git a/plugins/provider-usage/README.md b/plugins/provider-usage/README.md index bfd8d8a4e6..6f60f8d919 100644 --- a/plugins/provider-usage/README.md +++ b/plugins/provider-usage/README.md @@ -10,8 +10,8 @@ Failed refreshes retain the last available measurements with a retry notice. Account authentication failures and plans without reported limits have separate states; unavailable usage is never represented as zero consumption. -Settings → Usage limits consumes the same sources independently, using its -full-size provider groups with email-labeled accounts and fetching only resources in the selected pool or machine. Neither display is required for source +Settings → Installed plugins → Provider usage contains the usage page, using its +full-size provider groups with email-labeled accounts and fetching only resources in the selected pool or machine. Both surfaces share the plugin’s aggregation and cache. Neither display is required for source plugins to publish their usage. Use `bb plugin rpc list --method provider-usage.v1.listResources --json` to find sources @@ -31,3 +31,15 @@ explicitly implement the contract to appear in these displays. Known provider-issued account identities are deduplicated within the selected location. Unknown identities are never merged by email. Structured plan and quota window metadata give both displays consistent labels. + +Provider Usage is enabled by default for newly registered installations. Existing +explicit enable/disable choices are preserved. Turn off **Show footer card** in +the plugin settings to hide its shortcut and card while keeping the usage page. +The setting applies to all clients connected to this bb server. + +```sh +bb plugin config provider-usage set showFooterCard false +bb plugin config provider-usage set showFooterCard true +``` + +SDK: `bb.sdk.plugins.updateSettings({ pluginId: "provider-usage", values: { showFooterCard: false } })`. diff --git a/plugins/provider-usage/app.tsx b/plugins/provider-usage/app.tsx index 2288544209..10ec5a4cbb 100644 --- a/plugins/provider-usage/app.tsx +++ b/plugins/provider-usage/app.tsx @@ -13,6 +13,7 @@ import { experimental_useSidebarThreads, type ExperimentalSidebarFooterDisclosureProps, useBbContext, + useSettings, } from "@get-bb/plugin-sdk/app"; import { Icon } from "@bb/shared-ui/icon"; import { Button } from "@bb/shared-ui/button"; @@ -45,6 +46,10 @@ import { type UsageWindow as UsageWindowValue, } from "./usage-schema.js"; +import { UsageSettings } from "./settings.js"; + +let footerVisible = true; + interface UsageStoreSnapshot { data: UsageSnapshot | null; error: string | null; @@ -716,13 +721,27 @@ function ProviderUsageStatus({ } export default definePluginApp((app) => { - app.experimental_sidebarFooter.register({ + app.slots.settingsSection({ id: "usage", component: UsageSettings }); + const footer = app.experimental_sidebarFooter.register({ kind: "disclosure", id: "usage", label: "Provider usage", icon: "ChartColumn", component: ProviderUsageStatus, }); + function FooterVisibility() { + const { values, isLoading } = useSettings(); + useEffect(() => { + if (isLoading) return; + footerVisible = values?.showFooterCard !== false; + footer.experimental_setVisible(footerVisible); + }, [values, isLoading]); + return null; + } + app.slots.experimental_appOverlay({ + id: "footer-visibility", + component: FooterVisibility, + }); app.contentScripts.register({ id: "refresh-usage", mount({ signal }) { @@ -736,6 +755,7 @@ export default definePluginApp((app) => { timer = window.setTimeout(runSafetyRefresh, SAFETY_REFRESH_INTERVAL_MS); }; const reconcile = (maxAgeMs: number, machineIds: string[] | null) => { + if (!footerVisible) return; void refreshUsage({ force: false, machineIds, diff --git a/plugins/provider-usage/package.json b/plugins/provider-usage/package.json index 51a7e6b741..66e8a11777 100644 --- a/plugins/provider-usage/package.json +++ b/plugins/provider-usage/package.json @@ -3,14 +3,14 @@ "version": "0.1.0", "private": true, "type": "module", - "description": "Show live agent-provider usage in the bb sidebar footer", + "description": "View provider usage in plugin settings and an optional sidebar footer card", "engines": { "bb": ">=0.0", "bbPluginSdk": ">=0.4.85" }, "bb": { "name": "Provider usage", - "description": "Show live agent-provider usage in the bb sidebar footer.", + "description": "View provider usage in plugin settings and an optional sidebar footer card.", "branding": { "icon": "ChartColumn" }, diff --git a/plugins/provider-usage/server.test.ts b/plugins/provider-usage/server.test.ts index bec9e7514e..facd7ef24b 100644 --- a/plugins/provider-usage/server.test.ts +++ b/plugins/provider-usage/server.test.ts @@ -73,6 +73,7 @@ it("lists cheaply, fetches only the selected source/provider, preserves failed m const host = createFakePluginHost({ pluginId: "provider-usage", sdk: { + system: { config: async () => ({ primaryHostId: null }) }, hosts: { list: async () => [ makeHostResponse({ @@ -228,6 +229,7 @@ it("keeps an unconfigured shared group without hosts or measurement requests", a const host = createFakePluginHost({ pluginId: "provider-usage", sdk: { + system: { config: async () => ({ primaryHostId: null }) }, hosts: { list: async () => [] }, providers: { list: async () => [] }, plugins: { @@ -267,6 +269,7 @@ it("keeps an unconfigured shared group without hosts or measurement requests", a it("collapses known account observations per machine, preserves unknown identities, and normalizes display labels", async () => { const { bb, harness } = createFakePluginHost({ sdk: { + system: { config: async () => ({ primaryHostId: null }) }, hosts: { list: async () => [ makeHostResponse({ id: "host", status: "connected" }), diff --git a/plugins/provider-usage/server.ts b/plugins/provider-usage/server.ts index 3aa79d1bdb..1f9f6356cd 100644 --- a/plugins/provider-usage/server.ts +++ b/plugins/provider-usage/server.ts @@ -160,6 +160,15 @@ function resourceProvider( } export default function providerUsagePlugin(bb: BbPluginApi): void { + bb.settings.define({ + showFooterCard: { + type: "boolean", + label: "Show footer card", + description: + "Show the usage card and shortcut in the sidebar footer. Usage remains available in this plugin’s settings.", + default: true, + }, + }); const inventories = new Map(); const measurements = new Map< string, @@ -205,11 +214,26 @@ export default function providerUsagePlugin(bb: BbPluginApi): void { return promise; }; const readUsage = async (request: UsageRequest): Promise => { - const [hosts, sources, providers] = await Promise.all([ - bb.sdk.hosts.list(), + const hostId = request.machineIds?.find((id) => !id.startsWith("source:")); + const [hosts, sources, providers, config] = await Promise.all([ + bb.sdk.hosts + .list() + .then((hosts) => hosts.filter((host) => host.type !== "ephemeral")), bb.sdk.plugins.experimental_discoverRpc({ method: usageListMethod }), - bb.sdk.providers.list({ capability: "usage" }).catch(() => []), + bb.sdk.providers + .list( + hostId === undefined + ? { capability: "usage" } + : { capability: "usage", hostId }, + ) + .catch(() => []), + bb.sdk.system.config().catch(() => null), ]); + hosts.sort( + (a, b) => + Number(b.id === config?.primaryHostId) - + Number(a.id === config?.primaryHostId), + ); for (const id of inventories.keys()) if (!sources.some((source) => source.pluginId === id)) inventories.delete(id); diff --git a/plugins/provider-usage/settings-ui.tsx b/plugins/provider-usage/settings-ui.tsx new file mode 100644 index 0000000000..a0a5ad63e9 --- /dev/null +++ b/plugins/provider-usage/settings-ui.tsx @@ -0,0 +1,77 @@ +import { type ReactNode } from "react"; +import { cn } from "@bb/shared-ui/lib/utils"; + +interface SettingsSectionProps { + action?: ReactNode; + actionPlacement?: "inline" | "responsive"; + children: ReactNode; + description?: string; + title: ReactNode; + bodyClassName?: string; +} + +export function SettingsSection({ + action, + actionPlacement = "responsive", + children, + description, + title, + bodyClassName, +}: SettingsSectionProps) { + return ( +
+
+
+
+

+ {title} +

+
+ {description ? ( +

+ {description} +

+ ) : null} +
+ {action ?
{action}
: null} +
+
+ {children} +
+
+ ); +} + +interface SettingsRowListProps { + children: ReactNode; +} + +export function SettingsRowList({ children }: SettingsRowListProps) { + return
{children}
; +} + +export function SettingsBadge({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/plugins/provider-usage/settings.test.tsx b/plugins/provider-usage/settings.test.tsx new file mode 100644 index 0000000000..fec545d584 --- /dev/null +++ b/plugins/provider-usage/settings.test.tsx @@ -0,0 +1,254 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { afterEach, expect, it } from "vitest"; +import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app"; +import type { UsageMachine, UsageProvider } from "./usage-schema.js"; + +afterEach(cleanup); + +function account(id: string, providerId = "codex"): UsageProvider { + return { + id, + providerId, + accountLabel: `${id}@example.com`, + displayName: providerId === "codex" ? "Codex" : "Claude Code", + logoUrl: null, + icon: null, + strings: { iconTint: null }, + signInHint: "Sign in again.", + expiredHint: "Session expired.", + usage: { + status: "ok", + accountEmail: `${id}@example.com`, + planLabel: "Max (20x)", + windows: [ + { + label: "Weekly limit", + usedPercent: 42, + resetsAt: new Date(Date.now() + 3600_000).toISOString(), + cost: null, + }, + ], + }, + }; +} +const machine = (id: string, providers: UsageProvider[]): UsageMachine => ({ + id, + displayName: id === "source:pool" ? "Account Pooler" : "My machine", + status: "connected", + error: null, + providers, +}); + +it("fetches all providers only in the selected source and keeps grouped accounts, icons, labels and reset times", async () => { + const app = await loadPluginApp(() => import("./app")); + const result = { + machines: [ + machine("host", [account("local")]), + machine("source:pool", [ + account("first"), + account("second"), + account("third", "claude-code"), + ]), + ], + }; + const slot = renderSlot( + app.settingsSections[0]!, + {}, + { rpc: { getUsage: () => result } }, + ); + await waitFor(() => + expect(slot.getByLabelText("Reload usage data")).toBeTruthy(), + ); + expect(slot.getAllByRole("heading", { name: "Codex" })).toHaveLength(1); + expect(slot.getByText("first@example.com")).toBeTruthy(); + expect(slot.getByText("second@example.com")).toBeTruthy(); + expect(slot.queryByText("local@example.com")).toBeNull(); + expect(slot.getAllByText(/Resets in/)).toHaveLength(3); + expect(slot.rpcCalls.map((call) => call.input)).toEqual([ + { force: false, machineIds: null, providerId: null, maxAgeMs: 60_000 }, + { + force: false, + machineIds: ["source:pool"], + providerId: "codex", + maxAgeMs: 60_000, + }, + { + force: false, + machineIds: ["source:pool"], + providerId: "claude-code", + maxAgeMs: 60_000, + }, + ]); + fireEvent.click(slot.getByLabelText("Reload usage data")); + await waitFor(() => expect(slot.rpcCalls).toHaveLength(6)); + expect(slot.rpcCalls[4]?.input).toMatchObject({ + force: true, + machineIds: ["source:pool"], + }); +}); + +it("uses machine usage when the pool is disabled", async () => { + const app = await loadPluginApp(() => import("./app")); + const slot = renderSlot( + app.settingsSections[0]!, + {}, + { + rpc: { + getUsage: () => ({ machines: [machine("host", [account("local")])] }), + }, + }, + ); + await slot.findByText("local@example.com"); + await waitFor(() => expect(slot.rpcCalls).toHaveLength(2)); + expect(slot.rpcCalls[1]?.input).toMatchObject({ + machineIds: ["host"], + providerId: "codex", + }); +}); + +it("keeps an enabled empty pool selected without fetching machine quotas", async () => { + const app = await loadPluginApp(() => import("./app")); + const slot = renderSlot( + app.settingsSections[0]!, + {}, + { + rpc: { + getUsage: () => ({ + machines: [ + machine("host", [account("local")]), + machine("source:pool", []), + ], + }), + }, + }, + ); + await slot.findByText(/No accounts report usage yet/); + expect(slot.rpcCalls).toHaveLength(1); + expect(slot.queryByText("local@example.com")).toBeNull(); +}); + +it("renders loading and a friendly transport error without exposing raw errors", async () => { + const app = await loadPluginApp(() => import("./app")); + let reject!: (error: Error) => void; + const pending = new Promise((_, fail) => { + reject = fail; + }); + const slot = renderSlot( + app.settingsSections[0]!, + {}, + { rpc: { getUsage: () => pending } }, + ); + expect(slot.getByText("Loading providers and usage…")).toBeTruthy(); + reject(new Error("Unexpected token 'b', bb connect...")); + await slot.findByText("Couldn’t load usage. Try reloading usage."); + expect(slot.queryByText(/Unexpected token/)).toBeNull(); +}); + +it("retains measured accounts when reloading fails", async () => { + const app = await loadPluginApp(() => import("./app")); + let failed = false; + const slot = renderSlot( + app.settingsSections[0]!, + {}, + { + rpc: { + getUsage: () => { + if (failed) throw new Error("private transport detail"); + return { machines: [machine("source:pool", [account("first")])] }; + }, + }, + }, + ); + await waitFor(() => + expect(slot.getByLabelText("Reload usage data")).toBeTruthy(), + ); + failed = true; + fireEvent.click(slot.getByLabelText("Reload usage data")); + await slot.findByText(/Showing the last available update/); + expect(slot.getByText("first@example.com")).toBeTruthy(); + expect(slot.getByText("42% used")).toBeTruthy(); +}); + +it("hides the entire footer item independently of the settings page", async () => { + const app = await loadPluginApp(() => import("./app")); + const visibility = app.appOverlays.find( + (item) => item.id === "footer-visibility", + ); + if (!visibility) throw new Error("Missing visibility overlay"); + renderSlot(visibility, {}, { settings: { showFooterCard: false } }); + expect( + app.experimentalSidebarFooterItems[0]?.runtime.getSnapshot().visible, + ).toBe(false); + expect(app.settingsSections).toHaveLength(1); +}); + +it("shows pending measurements without inventing usage, then reports an unavailable account gracefully", async () => { + const app = await loadPluginApp(() => import("./app")); + const resource = { ...account("pending"), usage: null }; + let finish!: () => void; + let calls = 0; + const slot = renderSlot( + app.settingsSections[0]!, + {}, + { + rpc: { + getUsage: async () => { + if (calls++ === 0) + return { machines: [machine("source:pool", [resource])] }; + await new Promise((resolve) => { + finish = resolve; + }); + return { + machines: [ + { + ...machine("source:pool", [resource]), + error: "Some usage could not be refreshed.", + }, + ], + }; + }, + }, + }, + ); + await slot.findByText("Loading usage…"); + expect(slot.queryByText("0% used")).toBeNull(); + finish(); + await slot.findByText("Couldn’t load usage. Try reloading usage."); + expect( + slot.getByText("Couldn't load usage right now. Try reloading usage."), + ).toBeTruthy(); + expect(slot.queryByText(/Showing the last/)).toBeNull(); +}); + +it("keeps authentication and plans without limits distinct from loading and errors", async () => { + const app = await loadPluginApp(() => import("./app")); + const first = account("signed-out"); + first.usage = { status: "unauthenticated" }; + const second = account("expired"); + second.usage = { status: "expired" }; + const third = account("unlimited"); + third.usage = { + status: "ok", + accountEmail: "unlimited@example.com", + planLabel: null, + windows: [], + }; + const slot = renderSlot( + app.settingsSections[0]!, + {}, + { + rpc: { + getUsage: () => ({ + machines: [machine("source:pool", [first, second, third])], + }), + }, + }, + ); + await slot.findByText("Sign in again."); + expect(slot.getByText("Session expired.")).toBeTruthy(); + expect( + slot.getByText("No usage limits reported for this plan."), + ).toBeTruthy(); + expect(slot.queryByText("0% used")).toBeNull(); +}); diff --git a/plugins/provider-usage/settings.tsx b/plugins/provider-usage/settings.tsx new file mode 100644 index 0000000000..bafac4e42f --- /dev/null +++ b/plugins/provider-usage/settings.tsx @@ -0,0 +1,538 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { + useRpc, + experimental_ProviderIcon as ProviderIcon, +} from "@get-bb/plugin-sdk/app"; +import { Button } from "@bb/shared-ui/button"; +import { Icon } from "@bb/shared-ui/icon"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@bb/shared-ui/dropdown-menu"; +import { cn } from "@bb/shared-ui/lib/utils"; +import type { providerUsageRpcContract } from "./server.js"; +import { + selectUsageMachine, + type UsageMachine, + type UsageProvider, + type ProviderUsage, + type UsageWindow, +} from "./usage-schema.js"; +import { + SettingsSection, + SettingsBadge, + SettingsRowList, +} from "./settings-ui.js"; + +interface ProviderConfig { + name: string; + providerId: string; + signInHint: string; + expiredHint: string; + provider: UsageProvider; +} + +function barColorClass(usedPercent: number): string { + if (usedPercent >= 95) { + return "bg-destructive"; + } + if (usedPercent >= 80) { + return "bg-warning"; + } + return "bg-primary"; +} + +function formatReset(resetsAt: string | null): string | null { + if (!resetsAt) { + return null; + } + const reset = new Date(resetsAt); + if (Number.isNaN(reset.getTime())) { + return null; + } + const diffMs = reset.getTime() - Date.now(); + if (diffMs <= 0) { + return "Resetting now"; + } + + const diffMinutes = Math.round(diffMs / 60_000); + if (diffMinutes < 60) { + return `Resets in ${diffMinutes} min`; + } + + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) { + const minutes = diffMinutes % 60; + return minutes > 0 + ? `Resets in ${diffHours} hr ${minutes} min` + : `Resets in ${diffHours} hr`; + } + + const withinWeek = diffMs < 7 * 24 * 60 * 60_000; + const formatted = reset.toLocaleString(undefined, { + weekday: withinWeek ? "short" : undefined, + month: withinWeek ? undefined : "short", + day: withinWeek ? undefined : "numeric", + hour: "numeric", + minute: "2-digit", + }); + return `Resets ${formatted}`; +} + +function formatUsdCents(cents: number, alwaysShowCents: boolean): string { + const hasFractionalDollar = cents % 100 !== 0; + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: "USD", + minimumFractionDigits: alwaysShowCents || hasFractionalDollar ? 2 : 0, + maximumFractionDigits: 2, + }).format(cents / 100); +} + +function usageWindowValue(window: UsageWindow): string { + if (!window.cost) { + return `${window.usedPercent}% used`; + } + return `${formatUsdCents(window.cost.usedUsdCents, true)} / ${formatUsdCents(window.cost.limitUsdCents, false)}`; +} + +function UsageWindowRow({ window }: { window: UsageWindow }) { + const reset = formatReset(window.resetsAt); + return ( +
+
+ {window.label} + + {usageWindowValue(window)} + +
+
+
+
+ {reset ?

{reset}

: null} +
+ ); +} + +interface ProviderUsageBlockProps { + accountLabel?: string; + config: ProviderConfig; + usage: ProviderUsage | undefined; + isLoading: boolean; + isError: boolean; +} + +interface UsageLocation { + id: string; + name: string; + kind: "host" | "source"; + disabled: boolean; +} + +function UsageLocationPicker({ + locations, + selectedLocationId, + onSelectLocation, +}: { + locations: readonly UsageLocation[]; + selectedLocationId: string | null; + onSelectLocation: (locationId: string) => void; +}) { + const selectedLocation = + locations.find((location) => location.id === selectedLocationId) ?? + locations[0]; + + return ( + + + + + + {locations.map((location) => { + const connected = !location.disabled; + return ( + onSelectLocation(location.id)} + className="flex items-center gap-2" + > + + {location.name} + {location.id === selectedLocation?.id ? ( + + ) : null} + + ); + })} + + + ); +} + +function ProviderUsageBlock({ + accountLabel, + config, + usage, + isLoading, + isError, +}: ProviderUsageBlockProps) { + const planLabel = usage?.status === "ok" ? usage.planLabel : null; + const accountEmail = + accountLabel ?? (usage?.status === "ok" ? usage.accountEmail : null); + const headingId = useId(); + const showsUsageWindows = + !isError && usage?.status === "ok" && usage.windows.length > 0; + + return ( +
+
+
+ +
+

+ {config.name} +

+ {accountEmail && accountEmail !== config.name ? ( +

+ {accountEmail} +

+ ) : null} + {!showsUsageWindows ? ( +
+ +
+ ) : null} +
+
+ {planLabel ? {planLabel} : null} +
+ {showsUsageWindows ? ( +
+ +
+ ) : null} +
+ ); +} + +function UsageResourceGroup({ + config, + resources, + isLoading, + isError, +}: { + config: ProviderConfig; + resources: UsageProvider[]; + isLoading: boolean; + isError: boolean; +}) { + return ( +
+ {resources.map((resource, index) => { + const key = resource.id; + const email = + resource.usage?.status === "ok" + ? (resource.usage.accountEmail ?? resource.accountLabel) + : resource.accountLabel; + const usage = resource.usage ?? undefined; + + if (index === 0) + return ( + + ); + return ( +
+
+

+ {email} +

+ {usage?.status === "ok" && usage.planLabel ? ( + {usage.planLabel} + ) : null} +
+ +
+ ); + })} +
+ ); +} + +function ProviderUsageBody({ + config, + usage, + isLoading, + isError, +}: ProviderUsageBlockProps) { + if (isError) { + return ( +

+ Couldn't load usage right now. Try reloading usage. +

+ ); + } + if (!usage) { + return ( +

+ {isLoading ? "Loading usage…" : "Usage not provided."} +

+ ); + } + switch (usage.status) { + case "ok": + if (usage.windows.length === 0) { + return ( +

+ No usage limits reported for this plan. +

+ ); + } + return ( +
+ {usage.windows.map((window) => ( + + ))} +
+ ); + case "not_installed": + return ( +

+ Not installed on this machine. +

+ ); + case "unauthenticated": + return ( +

{config.signInHint}

+ ); + case "expired": + return ( +

{config.expiredHint}

+ ); + case "error": + return

{usage.message}

; + default: + return null; + } +} + +export function UsageSettings() { + const rpc = useRpc(); + const [machines, setMachines] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [refresh, setRefresh] = useState(0); + const forceGeneration = useRef(0); + useEffect(() => { + let disposed = false; + let running = false; + const force = refresh > forceGeneration.current; + forceGeneration.current = refresh; + async function load(force: boolean) { + if (running) return; + running = true; + setLoading(true); + setError(false); + try { + const inventory = await rpc.call("getUsage", { + force: false, + machineIds: null, + providerId: null, + maxAgeMs: 60_000, + }); + if (disposed) return; + setMachines(inventory.machines); + const selected = selectUsageMachine( + inventory.machines, + selectedId, + null, + ); + if (selected && selected.status === "connected") { + for (const providerId of new Set( + selected.providers.map((provider) => provider.providerId), + )) { + const result = await rpc.call("getUsage", { + force, + machineIds: [selected.id], + providerId, + maxAgeMs: 60_000, + }); + if (disposed) return; + setMachines(result.machines); + } + } + } catch { + if (!disposed) setError(true); + } finally { + running = false; + if (!disposed) setLoading(false); + } + } + void load(force); + const timer = window.setInterval(() => { + if (document.visibilityState === "visible") void load(false); + }, 60_000); + return () => { + disposed = true; + window.clearInterval(timer); + }; + }, [rpc, selectedId, refresh]); + const selected = selectUsageMachine(machines, selectedId, null); + const groups = new Map(); + for (const provider of selected?.providers ?? []) { + if (provider.usage?.status === "not_installed") continue; + const group = groups.get(provider.providerId); + if (group) group.push(provider); + else groups.set(provider.providerId, [provider]); + } + const notice = + selected?.status === "disconnected" + ? `${selected.displayName} is offline. Usage will refresh when it reconnects.` + : error || selected?.error + ? [...groups.values()].some((accounts) => + accounts.some((account) => account.usage !== null), + ) + ? "Couldn’t refresh usage. Showing the last available update. Try reloading usage." + : "Couldn’t load usage. Try reloading usage." + : null; + return ( + + {machines.length > 1 ? ( + ({ + id: machine.id, + name: machine.displayName, + kind: machine.id.startsWith("source:") ? "source" : "host", + disabled: machine.status !== "connected", + }))} + selectedLocationId={selected?.id ?? null} + onSelectLocation={setSelectedId} + /> + ) : null} + +
+ } + > + {notice ? ( +

+ {notice} +

+ ) : null} + + {[...groups].map(([id, resources]) => { + const provider = resources[0]!; + return ( + + ); + })} + {groups.size === 0 && !notice ? ( +

+ {loading + ? "Loading providers and usage…" + : selected?.id.startsWith("source:") + ? "No accounts report usage yet. Configure accounts in the source plugin’s settings, or choose a machine." + : "No providers report usage limits on this machine."} +

+ ) : null} +
+ + ); +} diff --git a/plugins/provider-usage/skills/provider-usage/SKILL.md b/plugins/provider-usage/skills/provider-usage/SKILL.md new file mode 100644 index 0000000000..f968cd4337 --- /dev/null +++ b/plugins/provider-usage/skills/provider-usage/SKILL.md @@ -0,0 +1,30 @@ +--- +name: provider-usage +description: Inspect provider subscription usage sources and configure the Provider Usage footer card in BB. +--- + +# Provider Usage + +Open Settings → Installed plugins → Provider usage to inspect subscription usage +for an account pool or machine. Provider Usage is enabled by default. An enabled, +unconfigured account pool shows setup guidance; choose a machine to inspect its +local accounts instead. + +Hide the sidebar shortcut and card without disabling the usage settings page: + +```sh +bb plugin config provider-usage set showFooterCard false +``` + +Set `showFooterCard` to `true` to restore them. This setting applies to all clients +connected to this server. Inspect effective values with `bb plugin config provider-usage`. + +Discover usage-source plugins with +`bb plugin rpc list --method provider-usage.v1.listResources --json`. +Inspect schemas with `bb plugin rpc inspect --method provider-usage.v1.listResources --json`. +Listing resources is cheap; `provider-usage.v1.getResource` fetches one resource’s +actual usage, even when `refresh` is false. RPC calls take JSON via `--input-file`. +The Plugin Guide documents the public RPC APIs. + +`bb settings usage --json` remains the host-local maintenance view, without pooled +accounts. Hiding or disabling Provider Usage does not disable usage sources. diff --git a/plugins/provider-usage/vitest.config.ts b/plugins/provider-usage/vitest.config.ts index 99a376295c..8183e3402b 100644 --- a/plugins/provider-usage/vitest.config.ts +++ b/plugins/provider-usage/vitest.config.ts @@ -1,10 +1,15 @@ -import { defineWorkspaceTestConfig } from "../../vitest.shared.js"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; export default defineWorkspaceTestConfig({ test: { silent: "passed-only", - name: "bb-plugin-provider-usage", - include: ["**/*.test.{ts,tsx}"], - exclude: ["dist/**", "node_modules/**"], + projects: sharedWorkerProjects({ + pkgDir: __dirname, + name: "bb-plugin-provider-usage", + include: ["**/*.test.{ts,tsx}"], + }), }, }); From fb580214379215ab67a9f1371c1f590bfa37cac7 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 11 Sep 2026 15:24:27 -0700 Subject: [PATCH 26/53] Keep a temporary redirect for usage settings --- apps/app/src/App.legacy-skill-route.test.tsx | 1 + apps/app/src/App.tsx | 12 ++++++++++++ docs/discoverable-rpc-and-provider-usage-plan.md | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/app/src/App.legacy-skill-route.test.tsx b/apps/app/src/App.legacy-skill-route.test.tsx index 770abde919..31edd4f108 100644 --- a/apps/app/src/App.legacy-skill-route.test.tsx +++ b/apps/app/src/App.legacy-skill-route.test.tsx @@ -77,6 +77,7 @@ describe("legacy resource redirects", () => { ); it.each([ + ["/settings/usage", "/settings/plugins/provider-usage"], ["/settings/plugins", "/settings/plugins"], ["/extensions?view=installed#catalog", "/plugins?view=installed#catalog"], ["/extensions/plugins", "/plugins"], diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index a06b8ec2b1..491300bb1f 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -60,6 +60,7 @@ import { getAutomationDetailRoutePath, getAutomationEditRoutePath, getAutomationsRoutePath, + getPluginConfigurationRoutePath, getSettingsRoutePath, getSettingsProjectRoutePath, } from "./lib/route-paths"; @@ -264,6 +265,17 @@ export function AppRoutes() { + + } + /> } /> Date: Fri, 11 Sep 2026 16:12:12 -0700 Subject: [PATCH 27/53] Let users reorder and hide sidebar footer actions --- docs/api_to_audit.md | 10 ++++------ docs/configuration.md | 19 ++++++++++++++++--- ...iscoverable-rpc-and-provider-usage-plan.md | 2 +- packages/plugin-sdk/src/app-contract.ts | 2 -- .../src/internal/plugin-app-collector.ts | 8 -------- .../src/templates/bb-guide-plugins.md | 6 +++--- plugins/provider-usage/README.md | 14 ++++---------- plugins/provider-usage/app.tsx | 19 +------------------ plugins/provider-usage/server.ts | 9 --------- plugins/provider-usage/settings.test.tsx | 13 ------------- .../skills/provider-usage/SKILL.md | 12 ++++-------- 11 files changed, 33 insertions(+), 81 deletions(-) diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index 2bded9e76f..efbd9595f6 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -2142,15 +2142,13 @@ single active disclosure across all plugins. A disclosure component owns everything inside its boundary and receives only `dismiss()`. Registering an action returns nothing. Registering a disclosure returns a -controller that can request `open`, `close`, or `toggle`. Its -`experimental_setVisible(boolean)` hides or restores both the shortcut and -disclosure. Hiding closes the disclosure; showing leaves it closed. Hidden -items ignore open requests. Audit reactive setting changes, sibling isolation, -unload and reload, and focus behavior before stabilizing visibility. Those requests go +controller that can request `open`, `close`, or `toggle`. Those requests go through the host's shared active-item coordinator, so opening one plugin's disclosure replaces another and a stale scoped `close` cannot dismiss a sibling. The existing `app.slots.sidebarFooterAction` remains a compatibility surface and -renders in the same footer row. +renders in the same footer row. User appearance preferences order the items and +move hidden shortcuts into More; hiding does not disable plugin callbacks or +programmatic disclosure controls. **Audit before stabilizing.** diff --git a/docs/configuration.md b/docs/configuration.md index e78778ae9b..3dc71d5e1e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1428,6 +1428,19 @@ its identity, portability, and verification contract. Provider Usage is enabled by default for newly registered installations; existing plugin enable/disable choices are preserved. Its plugin settings page contains -provider subscription usage. The server-wide boolean `showFooterCard` (default -`true`) hides or restores the sidebar shortcut and card without disabling the -settings page: `bb plugin config provider-usage set showFooterCard false`. +provider subscription usage. Sidebar footer order and visibility are BB UI +preferences, configured in Settings → Appearance → Sidebar footer. Right-click +an action and choose Hide to move it into More without disabling the action. + +Footer keys are `builtin:settings`, `builtin:report-bug`, or +`plugin:/`. Plugin keys omit the load +generation so preferences survive reloads; unavailable entries retain their +preferences and new actions appear by default. For example: + +```sh +bb settings ui set sidebar.hiddenFooterItems '["plugin:provider-usage/usage"]' +bb settings ui reset sidebar.hiddenFooterItems +bb settings ui set sidebar.footerOrder '["plugin:provider-usage/usage","builtin:settings"]' +``` + +Hiding an open disclosure closes it. Selecting it from More can open it again. diff --git a/docs/discoverable-rpc-and-provider-usage-plan.md b/docs/discoverable-rpc-and-provider-usage-plan.md index 651d175e43..265480841c 100644 --- a/docs/discoverable-rpc-and-provider-usage-plan.md +++ b/docs/discoverable-rpc-and-provider-usage-plan.md @@ -1,6 +1,6 @@ # Discoverable RPC and replaceable provider usage displays -The former core usage page is removed; `/settings/usage` remains a temporary redirect to `/settings/plugins/provider-usage`. Provider Usage is enabled by default and owns the usage settings page, plus a `showFooterCard` setting (default true). Both surfaces reuse its private aggregation RPC and measurement cache. +The former core usage page is removed; `/settings/usage` remains a temporary redirect to `/settings/plugins/provider-usage`. Provider Usage is enabled by default and owns the usage settings page, with footer order and visibility controlled by BB’s appearance preferences. Both surfaces reuse its private aggregation RPC and measurement cache. Status: prototype implemented for discoverable RPC, Account Pooler, the Codex, Claude Code, and ACP provider plugins, and Provider Usage’s settings page and footer card. Provider Usage defines the canonical contract in `plugins/provider-usage/usage-source-contract.ts` and consumes discovered sources through it. The plugin settings page preserves its existing provider cards and extends the machine picker to select shared sources such as Account Pooler. Shared sources are selected by default. diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index f64ec310ea..d48cdf6d68 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -761,8 +761,6 @@ export type ExperimentalSidebarFooterItemRegistration = /** Live controls for an experimental sidebar-footer disclosure. */ export interface ExperimentalSidebarFooterDisclosureController { - /** Hide or show this item’s shortcut and disclosure. Hiding closes it; showing does not reopen it. */ - experimental_setVisible(visible: boolean): void; /** Request that the host open this disclosure, replacing any open sibling. */ open(): void; /** Close this disclosure if it is currently open. */ diff --git a/packages/plugin-sdk/src/internal/plugin-app-collector.ts b/packages/plugin-sdk/src/internal/plugin-app-collector.ts index 384de9e654..9d843b428d 100644 --- a/packages/plugin-sdk/src/internal/plugin-app-collector.ts +++ b/packages/plugin-sdk/src/internal/plugin-app-collector.ts @@ -48,7 +48,6 @@ import { export type ExperimentalSidebarFooterCommandKind = "open" | "close" | "toggle"; export interface ExperimentalSidebarFooterRuntimeSnapshot { - visible: boolean; command: { sequence: number; kind: ExperimentalSidebarFooterCommandKind; @@ -108,7 +107,6 @@ const SIDEBAR_FOOTER_DISCLOSURE_KEYS: ReadonlySet = new Set([ class SidebarFooterItemRuntime implements ExperimentalSidebarFooterItemRuntime { private readonly listeners = new Set<() => void>(); private snapshot: ExperimentalSidebarFooterRuntimeSnapshot = { - visible: true, command: null, }; @@ -128,12 +126,6 @@ class SidebarFooterItemRuntime implements ExperimentalSidebarFooterItemRuntime { createDisclosureController(): ExperimentalSidebarFooterDisclosureController { return Object.freeze({ - experimental_setVisible: (visible: boolean) => { - if (this.snapshot.visible === visible) return; - this.snapshot = { ...this.snapshot, visible }; - if (!visible) this.request("close"); - else this.emit(); - }, open: () => this.request("open"), close: () => this.request("close"), toggle: () => this.request("toggle"), diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index 50bbceab82..16efaf7d02 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -931,6 +931,6 @@ Modal image debugging: `bb modal image build [--json]` prepares the saved image; ### Provider usage Provider Usage is enabled by default and shows usage in its plugin settings page -and sidebar footer. Set `bb plugin config provider-usage set showFooterCard false` -to hide the footer shortcut and card while retaining the settings page. Set it to -`true` to restore them. See the `provider-usage` skill for source discovery. +and sidebar footer. Right-click its footer shortcut and choose Hide to move it +into More. Settings → Appearance → Sidebar footer controls order and visibility. +See the `provider-usage` skill for source discovery. diff --git a/plugins/provider-usage/README.md b/plugins/provider-usage/README.md index 6f60f8d919..c53008f3a0 100644 --- a/plugins/provider-usage/README.md +++ b/plugins/provider-usage/README.md @@ -33,13 +33,7 @@ location. Unknown identities are never merged by email. Structured plan and quot window metadata give both displays consistent labels. Provider Usage is enabled by default for newly registered installations. Existing -explicit enable/disable choices are preserved. Turn off **Show footer card** in -the plugin settings to hide its shortcut and card while keeping the usage page. -The setting applies to all clients connected to this bb server. - -```sh -bb plugin config provider-usage set showFooterCard false -bb plugin config provider-usage set showFooterCard true -``` - -SDK: `bb.sdk.plugins.updateSettings({ pluginId: "provider-usage", values: { showFooterCard: false } })`. +explicit enable/disable choices are preserved. Right-click the footer shortcut and +choose **Hide** to move it into **More**. Settings → Appearance → Sidebar footer +controls order and visibility for every footer action. The usage settings page +remains available. These preferences belong to BB, not the plugin. diff --git a/plugins/provider-usage/app.tsx b/plugins/provider-usage/app.tsx index 10ec5a4cbb..8481e030ba 100644 --- a/plugins/provider-usage/app.tsx +++ b/plugins/provider-usage/app.tsx @@ -13,7 +13,6 @@ import { experimental_useSidebarThreads, type ExperimentalSidebarFooterDisclosureProps, useBbContext, - useSettings, } from "@get-bb/plugin-sdk/app"; import { Icon } from "@bb/shared-ui/icon"; import { Button } from "@bb/shared-ui/button"; @@ -48,8 +47,6 @@ import { import { UsageSettings } from "./settings.js"; -let footerVisible = true; - interface UsageStoreSnapshot { data: UsageSnapshot | null; error: string | null; @@ -722,26 +719,13 @@ function ProviderUsageStatus({ export default definePluginApp((app) => { app.slots.settingsSection({ id: "usage", component: UsageSettings }); - const footer = app.experimental_sidebarFooter.register({ + app.experimental_sidebarFooter.register({ kind: "disclosure", id: "usage", label: "Provider usage", icon: "ChartColumn", component: ProviderUsageStatus, }); - function FooterVisibility() { - const { values, isLoading } = useSettings(); - useEffect(() => { - if (isLoading) return; - footerVisible = values?.showFooterCard !== false; - footer.experimental_setVisible(footerVisible); - }, [values, isLoading]); - return null; - } - app.slots.experimental_appOverlay({ - id: "footer-visibility", - component: FooterVisibility, - }); app.contentScripts.register({ id: "refresh-usage", mount({ signal }) { @@ -755,7 +739,6 @@ export default definePluginApp((app) => { timer = window.setTimeout(runSafetyRefresh, SAFETY_REFRESH_INTERVAL_MS); }; const reconcile = (maxAgeMs: number, machineIds: string[] | null) => { - if (!footerVisible) return; void refreshUsage({ force: false, machineIds, diff --git a/plugins/provider-usage/server.ts b/plugins/provider-usage/server.ts index 1f9f6356cd..5aef35ce45 100644 --- a/plugins/provider-usage/server.ts +++ b/plugins/provider-usage/server.ts @@ -160,15 +160,6 @@ function resourceProvider( } export default function providerUsagePlugin(bb: BbPluginApi): void { - bb.settings.define({ - showFooterCard: { - type: "boolean", - label: "Show footer card", - description: - "Show the usage card and shortcut in the sidebar footer. Usage remains available in this plugin’s settings.", - default: true, - }, - }); const inventories = new Map(); const measurements = new Map< string, diff --git a/plugins/provider-usage/settings.test.tsx b/plugins/provider-usage/settings.test.tsx index fec545d584..ca0bde3451 100644 --- a/plugins/provider-usage/settings.test.tsx +++ b/plugins/provider-usage/settings.test.tsx @@ -170,19 +170,6 @@ it("retains measured accounts when reloading fails", async () => { expect(slot.getByText("42% used")).toBeTruthy(); }); -it("hides the entire footer item independently of the settings page", async () => { - const app = await loadPluginApp(() => import("./app")); - const visibility = app.appOverlays.find( - (item) => item.id === "footer-visibility", - ); - if (!visibility) throw new Error("Missing visibility overlay"); - renderSlot(visibility, {}, { settings: { showFooterCard: false } }); - expect( - app.experimentalSidebarFooterItems[0]?.runtime.getSnapshot().visible, - ).toBe(false); - expect(app.settingsSections).toHaveLength(1); -}); - it("shows pending measurements without inventing usage, then reports an unavailable account gracefully", async () => { const app = await loadPluginApp(() => import("./app")); const resource = { ...account("pending"), usage: null }; diff --git a/plugins/provider-usage/skills/provider-usage/SKILL.md b/plugins/provider-usage/skills/provider-usage/SKILL.md index f968cd4337..f004368ee1 100644 --- a/plugins/provider-usage/skills/provider-usage/SKILL.md +++ b/plugins/provider-usage/skills/provider-usage/SKILL.md @@ -10,14 +10,10 @@ for an account pool or machine. Provider Usage is enabled by default. An enabled unconfigured account pool shows setup guidance; choose a machine to inspect its local accounts instead. -Hide the sidebar shortcut and card without disabling the usage settings page: - -```sh -bb plugin config provider-usage set showFooterCard false -``` - -Set `showFooterCard` to `true` to restore them. This setting applies to all clients -connected to this server. Inspect effective values with `bb plugin config provider-usage`. +Right-click the sidebar shortcut and choose Hide to move it into More. Restore or +reorder footer actions in Settings → Appearance → Sidebar footer. These are BB +UI preferences shared across clients; they do not disable the usage source or its +settings page. The `bb settings ui` commands expose the same preferences. Discover usage-source plugins with `bb plugin rpc list --method provider-usage.v1.listResources --json`. From 54d9281b2894538551c68d6a7d67500368d281ea Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 11 Sep 2026 21:18:11 -0700 Subject: [PATCH 28/53] Fix SDK version and test regressions after rebase --- apps/app/src/components/commands/CommandPalette.test.tsx | 2 +- apps/server/test/services/plugins/plugin-service.test.ts | 8 ++++++-- plugins/account-pool/package.json | 2 +- plugins/provider-acp/package.json | 2 +- plugins/provider-claude-code/package.json | 2 +- plugins/provider-codex/package.json | 2 +- plugins/provider-usage/package.json | 2 +- 7 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index 45d96e6ee6..73eb1879c1 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -263,7 +263,7 @@ describe("CommandPalette", () => { expect((searchField() as HTMLInputElement).value).toBe(">"); const titles = optionTitles(); expect(titles?.[0]).toContain("New thread"); - expect(titles).toHaveLength(19); + expect(titles).toHaveLength(18); }); it("filters as the user types and keeps the selection on a live row", async () => { diff --git a/apps/server/test/services/plugins/plugin-service.test.ts b/apps/server/test/services/plugins/plugin-service.test.ts index 399bef0924..3eeb4b84ba 100644 --- a/apps/server/test/services/plugins/plugin-service.test.ts +++ b/apps/server/test/services/plugins/plugin-service.test.ts @@ -154,8 +154,12 @@ describe("plugin service", () => { } afterEach(async () => { - await service.stop(); - await rm(workDir, { recursive: true, force: true }); + try { + await service.stop(); + await rm(workDir, { recursive: true, force: true }); + } finally { + vi.restoreAllMocks(); + } }); it("installs a path plugin, runs its factory, and reports running", async () => { diff --git a/plugins/account-pool/package.json b/plugins/account-pool/package.json index 6f632c2309..55a0172d4c 100644 --- a/plugins/account-pool/package.json +++ b/plugins/account-pool/package.json @@ -6,7 +6,7 @@ "description": "Routes Claude and Codex API traffic across provider account pools.", "engines": { "bb": ">=0.0", - "bbPluginSdk": ">=0.4.85" + "bbPluginSdk": ">=0.4.87" }, "bb": { "name": "Account Pooler [Experimental]", diff --git a/plugins/provider-acp/package.json b/plugins/provider-acp/package.json index 7f536db558..84135ed764 100644 --- a/plugins/provider-acp/package.json +++ b/plugins/provider-acp/package.json @@ -6,7 +6,7 @@ "description": "Run bb threads with ACP agents (supports Cursor, opencode, omp and more).", "engines": { "bb": ">=0.0", - "bbPluginSdk": ">=0.4.85" + "bbPluginSdk": ">=0.4.87" }, "bb": { "name": "ACP providers", diff --git a/plugins/provider-claude-code/package.json b/plugins/provider-claude-code/package.json index 632a839c5f..241fda037f 100644 --- a/plugins/provider-claude-code/package.json +++ b/plugins/provider-claude-code/package.json @@ -6,7 +6,7 @@ "description": "Run bb threads with Claude Code.", "engines": { "bb": ">=0.0", - "bbPluginSdk": ">=0.4.85" + "bbPluginSdk": ">=0.4.87" }, "bb": { "name": "Claude Code provider", diff --git a/plugins/provider-codex/package.json b/plugins/provider-codex/package.json index d0435738ab..38e06aea11 100644 --- a/plugins/provider-codex/package.json +++ b/plugins/provider-codex/package.json @@ -6,7 +6,7 @@ "description": "Run bb threads with Codex.", "engines": { "bb": ">=0.0", - "bbPluginSdk": ">=0.4.85" + "bbPluginSdk": ">=0.4.87" }, "bb": { "name": "Codex provider", diff --git a/plugins/provider-usage/package.json b/plugins/provider-usage/package.json index 66e8a11777..b7a347af83 100644 --- a/plugins/provider-usage/package.json +++ b/plugins/provider-usage/package.json @@ -6,7 +6,7 @@ "description": "View provider usage in plugin settings and an optional sidebar footer card", "engines": { "bb": ">=0.0", - "bbPluginSdk": ">=0.4.85" + "bbPluginSdk": ">=0.4.87" }, "bb": { "name": "Provider usage", From 64879edaadb1398909b3cac2bf6a3fbfed7fa55e Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 11 Sep 2026 21:20:14 -0700 Subject: [PATCH 29/53] Isolate loggers between server test harnesses --- apps/server/test/helpers/test-app.ts | 29 ++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/apps/server/test/helpers/test-app.ts b/apps/server/test/helpers/test-app.ts index 1964d12f3a..197eec91c9 100644 --- a/apps/server/test/helpers/test-app.ts +++ b/apps/server/test/helpers/test-app.ts @@ -80,12 +80,16 @@ export type TestAppHarnessConfigOverrides = Partial & { }[]; }; -export const testLogger = { - debug(): void {}, - error(): void {}, - info(): void {}, - warn(): void {}, -}; +function createTestLogger() { + return { + debug(): void {}, + error(): void {}, + info(): void {}, + warn(): void {}, + }; +} + +export const testLogger = createTestLogger(); interface TestDaemonKeyParts { hostId: string; @@ -136,6 +140,7 @@ export async function createTestAppHarness( seedFirstPartyProviders = true, ...configOverrides } = overrides; + const logger = createTestLogger(); const dataDir = await mkdtemp(join(tmpdir(), "bb-server-test-")); const db = createTestDb(); const hub = new NotificationHubImpl(); @@ -177,7 +182,7 @@ export async function createTestAppHarness( const machineAuth = await createMachineAuthService({ dataDir, db, - logger: testLogger, + logger, }); await machineAuth.ensureReady(); const testMachineAuth = { @@ -220,13 +225,13 @@ export async function createTestAppHarness( config, db, hub, - logger: testLogger, + logger, openTimeoutMs: 50, }); const bbAppManagedConfig = await createBbAppManagedConfigReloader({ config, hub, - logger: testLogger, + logger, }); const telemetry = createNoopTelemetryService(); const skillTreeRegistry = new SkillTreeRegistry(); @@ -236,7 +241,7 @@ export async function createTestAppHarness( db, hub, lifecycleDedupers, - logger: testLogger, + logger, machineAuth: testMachineAuth, providerRegistry, pluginHostArtifacts, @@ -250,7 +255,7 @@ export async function createTestAppHarness( appVersionService ?? createAppVersionService({ config, - logger: testLogger, + logger, }); const deps: ServerAppDeps = { appVersion, @@ -259,7 +264,7 @@ export async function createTestAppHarness( db, hub, lifecycleDedupers, - logger: testLogger, + logger, machineAuth: testMachineAuth, pendingInteractions, providerRegistry, From 8ec3e76240082b93bf493f25dc6eccbd56c4d729 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 11 Sep 2026 21:32:48 -0700 Subject: [PATCH 30/53] Preserve pooled Codex plan metadata from usage refreshes --- plugins/account-pool/src/codex-adapter.ts | 13 +++++++++- plugins/account-pool/src/server.test.ts | 31 ++++++++++++++++++++++- plugins/account-pool/src/store.ts | 7 +++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/plugins/account-pool/src/codex-adapter.ts b/plugins/account-pool/src/codex-adapter.ts index 6db29f2cb7..0e1c1a851a 100644 --- a/plugins/account-pool/src/codex-adapter.ts +++ b/plugins/account-pool/src/codex-adapter.ts @@ -184,6 +184,7 @@ const usageWindowSchema = z const usageResponseSchema = z .object({ + plan_type: z.string().trim().min(1).nullish().catch(null), rate_limit: z .object({ primary_window: usageWindowSchema.nullish(), @@ -354,9 +355,19 @@ export function createCodexAdapter(options: { await response.body?.cancel(); return; } + const parsed = usageResponseSchema.safeParse( + await response.json().catch(() => null), + ); + if (!parsed.success) return; + if (parsed.data.plan_type != null) { + await context.accounts.setSubscriptionType( + context.account.id, + parsed.data.plan_type, + ); + } const quota = codexQuotaFromUsage( context.account.id, - await response.json().catch(() => null), + parsed.data, context.quotas.get(context.account.id), context.now(), ); diff --git a/plugins/account-pool/src/server.test.ts b/plugins/account-pool/src/server.test.ts index 20c6741c01..3e051e6222 100644 --- a/plugins/account-pool/src/server.test.ts +++ b/plugins/account-pool/src/server.test.ts @@ -503,6 +503,7 @@ describe("Account Pool plugin", () => { }> = []; const modelRequests: string[] = []; let responseNumber = 0; + let planType: unknown = "pro"; const futureToken = `header.${Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1_000) + 3_600 })).toString("base64url")}.signature`; const upstream = await startUpstream(async (request, response) => { const body = (await readRequestBody(request)).toString("utf8"); @@ -528,7 +529,7 @@ describe("Account Pool plugin", () => { response.writeHead(200, { "content-type": "application/json" }); response.end( JSON.stringify({ - plan_type: "pro", + plan_type: planType, rate_limit: { allowed: true, limit_reached: false, @@ -674,6 +675,34 @@ describe("Account Pool plugin", () => { expect(accountTable.stdout).toContain("codex"); expect(accountTable.stdout).toContain("7d=48% 2100-01-01T02:00:00.000Z"); expect(accountTable.stdout).not.toContain("5h="); + const codexAccount = statusSchema + .parse(await host.harness.behavior.callRpc("status.get", null)) + .accounts.find((account) => account.provider === "codex")!; + expect(codexAccount.subscriptionType).toBe("pro"); + for (const [reportedPlan, expectedPlan] of [ + ["pro", "pro"], + ["plus", "plus"], + [undefined, "plus"], + [null, "plus"], + [123, "plus"], + ["", "plus"], + ]) { + planType = reportedPlan; + expect( + await host.harness.behavior.callRpc("provider-usage.v1.getResource", { + resourceId: codexAccount.id, + refresh: true, + }), + ).toMatchObject({ + usage: { + status: "ok", + plan: { id: expectedPlan, multiplier: null }, + planLabel: expectedPlan === "pro" ? "Pro" : "Plus", + windows: [expect.objectContaining({ usedPercent: 48 })], + }, + }); + } + const routed = await resolveCodexToken(host); expect(routed.baseUrl).toBe("/api/v1/plugins/account-pool/http/v1"); await expect( diff --git a/plugins/account-pool/src/store.ts b/plugins/account-pool/src/store.ts index 3a30a42324..2e3d644b6e 100644 --- a/plugins/account-pool/src/store.ts +++ b/plugins/account-pool/src/store.ts @@ -120,6 +120,13 @@ export class AccountStore { return this.update(id, (account) => ({ ...account, priority })); } + async setSubscriptionType( + id: string, + subscriptionType: string, + ): Promise { + return this.update(id, (account) => ({ ...account, subscriptionType })); + } + async reorder(provider: PoolProvider, accountIds: string[]): Promise { return this.serialized(async () => { const accounts = await this.list(); From aa600b94959ca3db1d8766c6eaba82e10e657333 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 11 Sep 2026 21:38:29 -0700 Subject: [PATCH 31/53] Use consistent provider headers for every usage account --- plugins/provider-usage/settings.test.tsx | 2 +- plugins/provider-usage/settings.tsx | 48 +++++------------------- 2 files changed, 11 insertions(+), 39 deletions(-) diff --git a/plugins/provider-usage/settings.test.tsx b/plugins/provider-usage/settings.test.tsx index ca0bde3451..f88b5ed9de 100644 --- a/plugins/provider-usage/settings.test.tsx +++ b/plugins/provider-usage/settings.test.tsx @@ -60,7 +60,7 @@ it("fetches all providers only in the selected source and keeps grouped accounts await waitFor(() => expect(slot.getByLabelText("Reload usage data")).toBeTruthy(), ); - expect(slot.getAllByRole("heading", { name: "Codex" })).toHaveLength(1); + expect(slot.getAllByRole("heading", { name: "Codex" })).toHaveLength(2); expect(slot.getByText("first@example.com")).toBeTruthy(); expect(slot.getByText("second@example.com")).toBeTruthy(); expect(slot.queryByText("local@example.com")).toBeNull(); diff --git a/plugins/provider-usage/settings.tsx b/plugins/provider-usage/settings.tsx index bafac4e42f..ceeb14ab64 100644 --- a/plugins/provider-usage/settings.tsx +++ b/plugins/provider-usage/settings.tsx @@ -215,7 +215,7 @@ function ProviderUsageBlock({ return (
@@ -279,49 +279,21 @@ function UsageResourceGroup({ }) { return (
- {resources.map((resource, index) => { - const key = resource.id; + {resources.map((resource) => { const email = resource.usage?.status === "ok" ? (resource.usage.accountEmail ?? resource.accountLabel) : resource.accountLabel; const usage = resource.usage ?? undefined; - - if (index === 0) - return ( - - ); return ( -
-
-

- {email} -

- {usage?.status === "ok" && usage.planLabel ? ( - {usage.planLabel} - ) : null} -
- -
+ ); })}
From 63d79b448431bde0409d24a3a0acd564070b3322 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 11 Sep 2026 21:43:08 -0700 Subject: [PATCH 32/53] Add positional filters to plugin RPC discovery commands --- apps/cli/src/commands/plugin.ts | 43 +++++++++++-------- ...iscoverable-rpc-and-provider-usage-plan.md | 2 +- .../src/templates/bb-guide-plugins.md | 2 +- .../references/accounts-and-routing.md | 2 +- .../skills/bb-cli/references/command-index.md | 4 +- .../skills/bb-cli/references/plugins.md | 2 +- .../skills/claude-code-provider/SKILL.md | 2 +- .../skills/codex-provider/SKILL.md | 2 +- plugins/provider-usage/README.md | 2 +- .../skills/provider-usage/SKILL.md | 2 +- 10 files changed, 34 insertions(+), 29 deletions(-) diff --git a/apps/cli/src/commands/plugin.ts b/apps/cli/src/commands/plugin.ts index f8a6783115..fc6c563aa5 100644 --- a/apps/cli/src/commands/plugin.ts +++ b/apps/cli/src/commands/plugin.ts @@ -790,24 +790,29 @@ export function registerPluginCommands( .command("rpc") .description("Inspect discoverable plugin RPC methods"); rpc - .command("list") + .command("list [plugin-id]") .option("--method ", "Filter by exact method name") .option("--json", "Output JSON") .action( - action(async (opts: JsonOutputOptions & { method?: string }) => { - const methods = await createCliBbSdk( - getUrl(), - ).plugins.experimental_discoverRpc({ method: opts.method }); - if (opts.json) { - outputJson(opts, methods); - return; - } - if (methods.length === 0) console.log("No discoverable RPC methods."); - for (const method of methods) - console.log( - `${method.pluginId} ${method.method} ${method.methodDescription ?? method.registrationDescription ?? ""}`, - ); - }), + action( + async ( + pluginId: string | undefined, + opts: JsonOutputOptions & { method?: string }, + ) => { + const methods = await createCliBbSdk( + getUrl(), + ).plugins.experimental_discoverRpc({ pluginId, method: opts.method }); + if (opts.json) { + outputJson(opts, methods); + return; + } + if (methods.length === 0) console.log("No discoverable RPC methods."); + for (const method of methods) + console.log( + `${method.pluginId} ${method.method} ${method.methodDescription ?? method.registrationDescription ?? ""}`, + ); + }, + ), ); rpc .command("call ") @@ -846,18 +851,18 @@ export function registerPluginCommands( ); rpc - .command("inspect ") - .option("--method ", "Filter by exact method name") + .command("inspect [method]") .option("--json", "Output JSON") .action( action( async ( pluginId: string, - opts: JsonOutputOptions & { method?: string }, + method: string | undefined, + opts: JsonOutputOptions, ) => { const methods = await createCliBbSdk( getUrl(), - ).plugins.experimental_discoverRpc({ pluginId, method: opts.method }); + ).plugins.experimental_discoverRpc({ pluginId, method }); if (opts.json) { outputJson(opts, methods); return; diff --git a/docs/discoverable-rpc-and-provider-usage-plan.md b/docs/discoverable-rpc-and-provider-usage-plan.md index 265480841c..4384523741 100644 --- a/docs/discoverable-rpc-and-provider-usage-plan.md +++ b/docs/discoverable-rpc-and-provider-usage-plan.md @@ -137,7 +137,7 @@ Add discoverable CLI surfaces backed by the same SDK query: bb plugin rpc list --json bb plugin rpc list --method provider-usage.v1.listResources --json bb plugin rpc inspect account-pool --json -bb plugin rpc inspect account-pool --method provider-usage.v1.listResources --json +bb plugin rpc inspect account-pool provider-usage.v1.listResources --json ``` Listing presents identities and method names; inspection includes registration descriptions, method descriptions, and published schemas with field descriptions preserved. JSON output is sufficient for copying or generating local schema definitions. TypeScript generation is outside the initial scope because JSON Schema cannot reconstruct arbitrary validator source. diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index 16efaf7d02..958b15e1b2 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -924,7 +924,7 @@ Modal image debugging: `bb modal image build [--json]` prepares the saved image; ## Inspect plugin RPC -`bb plugin rpc list [--method ] [--json]` lists discoverable methods from running plugins. `bb plugin rpc inspect [--method ] [--json]` dumps registration and method descriptions plus input/output JSON Schemas. Copy the relevant schema into your consumer and call the existing plugin RPC endpoint. Discovery is opt-in advertising, not access control; method names may carry versions such as `provider-usage.v1.listResources`. +`bb plugin rpc list [plugin-id] [--method ] [--json]` lists discoverable methods from running plugins, optionally restricted to one plugin. `bb plugin rpc inspect [method] [--json]` dumps registration and method descriptions plus input/output JSON Schemas. Copy the relevant schema into your consumer and call the existing plugin RPC endpoint. Discovery is opt-in advertising, not access control; method names may carry versions such as `provider-usage.v1.listResources`. `bb plugin rpc call [--input-file ] [--json]` invokes a method using server-side schema validation. Omitting the input file sends JSON null. Input files avoid putting sensitive values in command arguments. diff --git a/plugins/account-pool/skills/account-pool/references/accounts-and-routing.md b/plugins/account-pool/skills/account-pool/references/accounts-and-routing.md index 0c5c6028be..a0845ffe3b 100644 --- a/plugins/account-pool/skills/account-pool/references/accounts-and-routing.md +++ b/plugins/account-pool/skills/account-pool/references/accounts-and-routing.md @@ -81,4 +81,4 @@ sets an individual priority; the same operations are available through the ## Discoverable usage -This plugin exposes cheap `provider-usage.v1.listResources` inventory and `provider-usage.v1.getResource` measurements for independent usage displays. Fetch takes `{ resourceId, refresh }` and returns actual usage for that resource even when refresh is false; listing never collects quota. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect account-pool --method provider-usage.v1.listResources --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. +This plugin exposes cheap `provider-usage.v1.listResources` inventory and `provider-usage.v1.getResource` measurements for independent usage displays. Fetch takes `{ resourceId, refresh }` and returns actual usage for that resource even when refresh is false; listing never collects quota. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect account-pool provider-usage.v1.listResources --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. diff --git a/plugins/bb-guide/skills/bb-cli/references/command-index.md b/plugins/bb-guide/skills/bb-cli/references/command-index.md index 71c1ea8e7a..61984c73d9 100644 --- a/plugins/bb-guide/skills/bb-cli/references/command-index.md +++ b/plugins/bb-guide/skills/bb-cli/references/command-index.md @@ -238,8 +238,8 @@ configures the machine with optional configured `preset` and `image` names; - `bb plugin dev` - `bb plugin reload` - `bb plugin rpc` -- `bb plugin rpc list` -- `bb plugin rpc inspect` +- `bb plugin rpc list [plugin-id]` +- `bb plugin rpc inspect [method]` - `bb plugin rpc call` - `bb plugin enable` - `bb plugin disable` diff --git a/plugins/bb-guide/skills/bb-cli/references/plugins.md b/plugins/bb-guide/skills/bb-cli/references/plugins.md index 619a069b84..cc618585b4 100644 --- a/plugins/bb-guide/skills/bb-cli/references/plugins.md +++ b/plugins/bb-guide/skills/bb-cli/references/plugins.md @@ -219,6 +219,6 @@ ## Inspect plugin RPC -`bb plugin rpc list [--method ] [--json]` lists discoverable methods from running plugins. `bb plugin rpc inspect [--method ] [--json]` dumps registration and method descriptions plus input/output JSON Schemas. Copy the relevant schema into your consumer and call the existing plugin RPC endpoint. Discovery is opt-in advertising, not access control; method names may carry versions such as `provider-usage.v1.listResources`. +`bb plugin rpc list [plugin-id] [--method ] [--json]` lists discoverable methods from running plugins, optionally restricted to one plugin. `bb plugin rpc inspect [method] [--json]` dumps registration and method descriptions plus input/output JSON Schemas. Copy the relevant schema into your consumer and call the existing plugin RPC endpoint. Discovery is opt-in advertising, not access control; method names may carry versions such as `provider-usage.v1.listResources`. `bb plugin rpc call [--input-file ] [--json]` invokes a method using server-side schema validation. Omitting the input file sends JSON null. Input files avoid putting sensitive values in command arguments. diff --git a/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md b/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md index a216f76b38..5602cabf76 100644 --- a/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md +++ b/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md @@ -21,4 +21,4 @@ threads or change settings merely to answer a question. ## Discoverable usage -This plugin directly publishes cheap `provider-usage.v1.listResources` inventory and `provider-usage.v1.getResource` measurements for independent usage displays. Fetch takes `{ resourceId, refresh }` and returns actual usage for that resource even when refresh is false; listing never collects quota. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect provider-claude-code --method provider-usage.v1.listResources --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. +This plugin directly publishes cheap `provider-usage.v1.listResources` inventory and `provider-usage.v1.getResource` measurements for independent usage displays. Fetch takes `{ resourceId, refresh }` and returns actual usage for that resource even when refresh is false; listing never collects quota. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect provider-claude-code provider-usage.v1.listResources --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. diff --git a/plugins/provider-codex/skills/codex-provider/SKILL.md b/plugins/provider-codex/skills/codex-provider/SKILL.md index 53fdc5c723..6d0015109d 100644 --- a/plugins/provider-codex/skills/codex-provider/SKILL.md +++ b/plugins/provider-codex/skills/codex-provider/SKILL.md @@ -19,4 +19,4 @@ upstream product behavior. ## Discoverable usage -This plugin directly publishes cheap `provider-usage.v1.listResources` inventory and `provider-usage.v1.getResource` measurements for independent usage displays. Fetch takes `{ resourceId, refresh }` and returns actual usage for that resource even when refresh is false; listing never collects quota. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect provider-codex --method provider-usage.v1.listResources --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. +This plugin directly publishes cheap `provider-usage.v1.listResources` inventory and `provider-usage.v1.getResource` measurements for independent usage displays. Fetch takes `{ resourceId, refresh }` and returns actual usage for that resource even when refresh is false; listing never collects quota. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect provider-codex provider-usage.v1.listResources --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. diff --git a/plugins/provider-usage/README.md b/plugins/provider-usage/README.md index c53008f3a0..c398d9ff02 100644 --- a/plugins/provider-usage/README.md +++ b/plugins/provider-usage/README.md @@ -15,7 +15,7 @@ full-size provider groups with email-labeled accounts and fetching only resource plugins to publish their usage. Use `bb plugin rpc list --method provider-usage.v1.listResources --json` to find sources -and `bb plugin rpc inspect --method provider-usage.v1.listResources --json` +and `bb plugin rpc inspect provider-usage.v1.listResources --json` to inspect their published contracts. RPC calls accept JSON through `--input-file`. See the Plugin Guide for the contract API. diff --git a/plugins/provider-usage/skills/provider-usage/SKILL.md b/plugins/provider-usage/skills/provider-usage/SKILL.md index f004368ee1..0e6f6dff55 100644 --- a/plugins/provider-usage/skills/provider-usage/SKILL.md +++ b/plugins/provider-usage/skills/provider-usage/SKILL.md @@ -17,7 +17,7 @@ settings page. The `bb settings ui` commands expose the same preferences. Discover usage-source plugins with `bb plugin rpc list --method provider-usage.v1.listResources --json`. -Inspect schemas with `bb plugin rpc inspect --method provider-usage.v1.listResources --json`. +Inspect schemas with `bb plugin rpc inspect provider-usage.v1.listResources --json`. Listing resources is cheap; `provider-usage.v1.getResource` fetches one resource’s actual usage, even when `refresh` is false. RPC calls take JSON via `--input-file`. The Plugin Guide documents the public RPC APIs. From 3b58c9eca13f1f5c2e0ac02e9d989eb5d1f23e84 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 11 Sep 2026 21:48:04 -0700 Subject: [PATCH 33/53] Keep RPC command index entries as bare command paths --- plugins/bb-guide/skills/bb-cli/references/command-index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/bb-guide/skills/bb-cli/references/command-index.md b/plugins/bb-guide/skills/bb-cli/references/command-index.md index 61984c73d9..71c1ea8e7a 100644 --- a/plugins/bb-guide/skills/bb-cli/references/command-index.md +++ b/plugins/bb-guide/skills/bb-cli/references/command-index.md @@ -238,8 +238,8 @@ configures the machine with optional configured `preset` and `image` names; - `bb plugin dev` - `bb plugin reload` - `bb plugin rpc` -- `bb plugin rpc list [plugin-id]` -- `bb plugin rpc inspect [method]` +- `bb plugin rpc list` +- `bb plugin rpc inspect` - `bb plugin rpc call` - `bb plugin enable` - `bb plugin disable` From 2ffa26b5074d7fe0f83d1bf12deef3b5198bc859 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 11 Sep 2026 22:19:12 -0700 Subject: [PATCH 34/53] Remove redundant Provider Usage skill --- .../skills/provider-usage/SKILL.md | 26 ------------------- 1 file changed, 26 deletions(-) delete mode 100644 plugins/provider-usage/skills/provider-usage/SKILL.md diff --git a/plugins/provider-usage/skills/provider-usage/SKILL.md b/plugins/provider-usage/skills/provider-usage/SKILL.md deleted file mode 100644 index 0e6f6dff55..0000000000 --- a/plugins/provider-usage/skills/provider-usage/SKILL.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: provider-usage -description: Inspect provider subscription usage sources and configure the Provider Usage footer card in BB. ---- - -# Provider Usage - -Open Settings → Installed plugins → Provider usage to inspect subscription usage -for an account pool or machine. Provider Usage is enabled by default. An enabled, -unconfigured account pool shows setup guidance; choose a machine to inspect its -local accounts instead. - -Right-click the sidebar shortcut and choose Hide to move it into More. Restore or -reorder footer actions in Settings → Appearance → Sidebar footer. These are BB -UI preferences shared across clients; they do not disable the usage source or its -settings page. The `bb settings ui` commands expose the same preferences. - -Discover usage-source plugins with -`bb plugin rpc list --method provider-usage.v1.listResources --json`. -Inspect schemas with `bb plugin rpc inspect provider-usage.v1.listResources --json`. -Listing resources is cheap; `provider-usage.v1.getResource` fetches one resource’s -actual usage, even when `refresh` is false. RPC calls take JSON via `--input-file`. -The Plugin Guide documents the public RPC APIs. - -`bb settings usage --json` remains the host-local maintenance view, without pooled -accounts. Hiding or disabling Provider Usage does not disable usage sources. From 09719a06d2eab904f6784cb45aff116ca2dbee5b Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 11 Sep 2026 22:21:18 -0700 Subject: [PATCH 35/53] Remove stale usage plan and simplify settings layout --- docs/configuration.md | 18 +- ...iscoverable-rpc-and-provider-usage-plan.md | 248 ------------------ plugins/provider-usage/settings-ui.tsx | 77 ------ plugins/provider-usage/settings.tsx | 157 ++++++----- 4 files changed, 88 insertions(+), 412 deletions(-) delete mode 100644 docs/discoverable-rpc-and-provider-usage-plan.md delete mode 100644 plugins/provider-usage/settings-ui.tsx diff --git a/docs/configuration.md b/docs/configuration.md index 3dc71d5e1e..3cb93a644e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1428,19 +1428,5 @@ its identity, portability, and verification contract. Provider Usage is enabled by default for newly registered installations; existing plugin enable/disable choices are preserved. Its plugin settings page contains -provider subscription usage. Sidebar footer order and visibility are BB UI -preferences, configured in Settings → Appearance → Sidebar footer. Right-click -an action and choose Hide to move it into More without disabling the action. - -Footer keys are `builtin:settings`, `builtin:report-bug`, or -`plugin:/`. Plugin keys omit the load -generation so preferences survive reloads; unavailable entries retain their -preferences and new actions appear by default. For example: - -```sh -bb settings ui set sidebar.hiddenFooterItems '["plugin:provider-usage/usage"]' -bb settings ui reset sidebar.hiddenFooterItems -bb settings ui set sidebar.footerOrder '["plugin:provider-usage/usage","builtin:settings"]' -``` - -Hiding an open disclosure closes it. Selecting it from More can open it again. +provider subscription usage. See [Sidebar footer](#sidebar-footer) for ordering +and hiding its footer shortcut. diff --git a/docs/discoverable-rpc-and-provider-usage-plan.md b/docs/discoverable-rpc-and-provider-usage-plan.md deleted file mode 100644 index 4384523741..0000000000 --- a/docs/discoverable-rpc-and-provider-usage-plan.md +++ /dev/null @@ -1,248 +0,0 @@ -# Discoverable RPC and replaceable provider usage displays - -The former core usage page is removed; `/settings/usage` remains a temporary redirect to `/settings/plugins/provider-usage`. Provider Usage is enabled by default and owns the usage settings page, with footer order and visibility controlled by BB’s appearance preferences. Both surfaces reuse its private aggregation RPC and measurement cache. - -Status: prototype implemented for discoverable RPC, Account Pooler, the Codex, Claude Code, and ACP provider plugins, and Provider Usage’s settings page and footer card. Provider Usage defines the canonical contract in `plugins/provider-usage/usage-source-contract.ts` and consumes discovered sources through it. The plugin settings page preserves its existing provider cards and extends the machine picker to select shared sources such as Account Pooler. Shared sources are selected by default. - -## Prototype verification - -- Relevant app, server, SDK and plugin typechecks pass. Current migration checks pass: 21 Provider Usage tests, 8 footer-host tests, 52 SDK app-harness tests, 74 Plugin Guide tests and 31 builtin-plugin tests. Earlier source implementation checks also covered Account Pooler, Codex, Claude Code and ACP. -- Source tests prove that listing does not collect quota and fetching addresses one resource, including cached reads, forced reads, offline hosts, and removed resource IDs. -- Display tests prove inventory-only discovery, selected provider/account fetching, cached failure preservation, empty groups, resource removal, and tab/source changes. Settings waits for default shared-source discovery before fetching a fallback host. -- Live CLI discovery advertises both methods from all three sources. Browser request traces show only pool Codex on first open, Claude on tab selection, and four selected pool resources on settings. Existing configured accounts were retained. -- `pnpm start:worktree` serves the review instance. Provider Usage background reconciliation lists metadata only; its open card refreshes only the active provider’s resources. -- JSON Schema exporter fidelity remains an experimental stabilization audit. The unrelated verification inventory still reports an unmapped `browser` CLI family. - -## Outcome - -Plugins can opt into publishing their RPC methods for discovery and inspection. Developers and agents can inspect a plugin repository or use the BB CLI to obtain its published contract, then copy the relevant schemas into their own source. - -Provider Usage establishes a usage method convention. Account Pooler and individual provider plugins implement it. Provider Usage and an alternative display such as Provider Usage Plus Plush discover and consume the same implementations. Disabling either display does not affect the sources. - -The first implementation covers usage information. Thread routing attribution, availability decisions, and Provider Retry integration remain separate follow-up work. - -## API - -Keep the existing `defineRpcContract` shape, method addressing, and calls. Add optional descriptions to method definitions and an optional registration argument: - -```ts -const usageContract = defineRpcContract({ - "provider-usage.v1.listResources": { - experimental_description: - "Cheap ordered resource inventory; reads local metadata only and never collects quota.", - input: usageListInputSchema, - output: usageResourceListSchema, - }, - "provider-usage.v1.getResource": { - input: usageFetchInputSchema, - output: usageMeasurementSchema, - experimental_description: - "Fetch one resource’s actual usage; refresh=false permits cache, refresh=true requests a fresh attempt for this resource only.", - }, -}); - -bb.rpc.register( - usageContract, - { - "provider-usage.v1.listResources": listResources, - "provider-usage.v1.getResource": getResource, - }, - { - experimental_discoverable: true, - experimental_description: - "Usage windows for accounts managed by Account Pooler.", - }, -); -``` - -The option publishes all methods in that registration. Plugins register internal methods separately. Omitting the option preserves current behavior: methods are callable by name but are not advertised. Discovery is not an authorization boundary. - -Registration-level `experimental_description` explains the purpose and scope of that implementation. Method-level `experimental_description` explains how to call an individual method and interpret its result. Both are optional; descriptions alone never make a registration discoverable. Existing definitions without descriptions continue to work. - -Add a proposed SDK query: - -```ts -const sources = await bb.sdk.plugins.experimental_discoverRpc({ - method: "provider-usage.v1.listResources", -}); -``` - -Support optional `pluginId` and exact `method` filters; omitting both lists published methods. Return one serializable descriptor per matching method: - -```ts -{ - pluginId: "account-pool", - displayName: "Account Pooler", - method: "provider-usage.v1.listResources", - registrationDescription: "Usage windows for accounts managed by Account Pooler.", - methodDescription: - "Cheap ordered resource inventory; reads local metadata only and never collects quota.", - inputSchema: publishedInputJsonSchema, - outputSchema: publishedOutputJsonSchema, -} -``` - -Publish both descriptions separately, without merging them or using one as a fallback for the other. Normalize omitted descriptions to `null` at the server boundary. Validate supplied descriptions as nonempty, bounded strings and include them in descriptor size limits. - -Field descriptions embedded in schemas must survive JSON Schema export. For example, `usedPercent` can explain its units and range. Method descriptions must explain behavior that field types cannot express, such as refresh and caching semantics, side effects, and partial failures. Registration descriptions explain implementation-specific scope. The published descriptions and schemas should provide enough information to call the method using CLI inspection alone. Source and Plugin Guide examples can add detail, but must not be the only place essential calling semantics are documented. Descriptions document behavior; they do not replace schema validation or make an otherwise unsupported schema export valid. - -Consumers retain their own expected schemas and use the existing call API: - -```ts -const results = await Promise.allSettled( - sources.map((source) => - bb.sdk.plugins.callRpc({ - pluginId: source.pluginId, - method: "provider-usage.v1.listResources", - input: {}, - outputSchema: usageResourceListSchema, - }), - ), -); -``` - -Discovery does not replace the consumer's expected schema with the producer's schema. It advertises implementations; normal server and caller validation still applies. - -## Publishing schemas - -Current RPC contracts accept Standard Schema validators. Validation support alone does not guarantee JSON Schema export. Resolve this before exposing the discovery flag: - -1. Add a publication adapter for the installed Zod version and support a validator-neutral JSON Schema export capability where available. -2. Publish portable input and output JSON Schemas with a declared dialect and locally resolvable references. Do not publish executable validators or fetch remote references during inspection. -3. Describe wire values: request JSON before handler-side parsing and response JSON after server-side output parsing. Do not silently treat transformed handler types as wire schemas. -4. Fail discoverable registration with a method-specific error when export is unsupported or lossy. Preserve anonymous registration for all currently supported validators. -5. If supporting another validator requires explicit publication schemas, design that escape hatch separately rather than guessing schemas or advertising an unrestricted object. - -JSON Schema does not encode every semantic restriction. Custom refinements and transformations need deliberate handling; unsupported cases must not silently disappear from the published contract. The initial usage contract should use schemas that can be exported faithfully. - -Runtime descriptors have size limits and are generated at registration, not on every discovery request. Schema inspection never invokes plugin handlers. No schema hashes, shared schema packages, dynamic schema imports, or version negotiation are required. - -## Lifecycle and compatibility - -- Publish methods only after successful plugin load. A failed registration or failed load publishes nothing from that candidate load. -- Remove descriptors on unload and replace them consistently with handlers on reload. Avoid mixing descriptors and handlers from different generations. -- Discover only currently callable implementations. Return deterministic ordering by plugin ID and method. No matches returns an empty list. -- Keep duplicate method rejection within a plugin. Different plugins may publish the same method name. -- Preserve existing authentication for inspection and calls. Never include credentials, settings values, or handler results in descriptors. -- Discovery is a snapshot. A source may unload before invocation; callers handle individual failures. Do not retry arbitrary RPC calls automatically. -- Preserve plugin-and-method addressing. Optional versioning uses names such as `provider-usage.v1.listResources` and `provider-usage.v2.listResources`. Both can coexist. -- A method-name match does not prove compatibility. Breaking schema or behavioral changes require a new method name by convention; compatible changes retain the name. -- No runtime dependency on the plugin that originally authored the convention is introduced. - -## CLI and sharing workflow - -Add discoverable CLI surfaces backed by the same SDK query: - -```sh -bb plugin rpc list --json -bb plugin rpc list --method provider-usage.v1.listResources --json -bb plugin rpc inspect account-pool --json -bb plugin rpc inspect account-pool provider-usage.v1.listResources --json -``` - -Listing presents identities and method names; inspection includes registration descriptions, method descriptions, and published schemas with field descriptions preserved. JSON output is sufficient for copying or generating local schema definitions. TypeScript generation is outside the initial scope because JSON Schema cannot reconstruct arbitrary validator source. - -A consumer author inspects a producer's source or CLI output, copies the relevant contract into their plugin, and calls the known method. Updates remain explicit source changes reviewed and tested by that consumer. The contract author's plugin package does not become a dependency. - -## Usage pilot - -Use two methods defined canonically by Provider Usage and copied locally by each producer and independent consumer: - -- `provider-usage.v1.listResources({})` returns `{ label?, resources: [{ id, providerId, accountKey, label, scope }] }`. This is cheap local inventory; it never refreshes usage or contacts providers. The optional label declares an empty shared group. Host-only sources omit it. List order is display order. -- `provider-usage.v1.getResource({ resourceId, refresh })` returns `{ accountKey, observedAt, usage }` for exactly one listed resource. False permits cached measurements but still returns actual usage. True requests a fresh collection attempt for that resource only. A removed resource fails explicitly, and consumers relist. - -The sidebar lists all sources to construct its source picker and provider tabs, then fetches only accounts belonging to the selected provider and source/machine. Background reconciliation lists metadata only. Unopened tabs have unknown usage rather than a fabricated healthy badge; retained measurements may still supply badges. Settings fetches the resources in its selected pool or machine. Both reuse Provider Usage’s per-resource cache, bounded collection concurrency, stale-data notices, and graceful failures. - -Account Pooler lists account metadata without refreshing, then calls its existing account-specific collection for fetch. Local provider sources list hosts without collecting quota and fetch only the requested host/provider pair. No display plugin is required for source registration or collection. - -### Source implementations - -- Account Pooler exposes its accounts and existing quota state with shared scope. Refresh delegates to its existing collection logic; it does not alter routing. -- Codex, Claude Code, and ACP explicitly implement the contract for their own usage-capable providers, using the existing SDK maintenance API. A disconnected host or failed account collection becomes an individual resource outcome and does not discard other results. -- All source plugins register the discoverable methods regardless of whether Provider Usage is installed or enabled. - -### Display implementation - -Provider Usage discovers sources whenever it loads or refreshes data, invokes them with bounded concurrency and bounded wait, and renders successful results even if another source fails. The plugin settings page groups accounts beneath one provider heading and icon, using the same email/plan/usage layout for shared pools and machines. It preserves the refresh control. Its source picker defaults to a shared source when available, or the primary machine otherwise; explicit selections win. Shared-source selections show only that source’s shared accounts, and machine selections show only that machine’s host-local observations. Account headings use email without repeating it as a subtitle. Source-group headings and observation timestamps are not added to this page. Other display plugins can choose their own presentation using the same metadata. - -Namespace resource keys by reporting plugin ID. Do not deduplicate by email or sum unrelated quota percentages. A shared pool appears once in the source picker; local and pooled observations remain separate choices even when their account emails match. Source removal evicts its current display entries on the next reconciliation. - -An alternative display uses the same discovery query and its own copied response schema. It may render richer UI without changing any producer. Both displays can run at once; producer refresh coalescing limits duplicate work. - -### Existing SDK and CLI usage surfaces - -Preserve `bb.sdk.system.usageLimits()` and `bb settings usage --json` as existing host-provider maintenance views during the first rollout. Do not silently change their response shape or use them as the unified view. - -Provider Usage exposes its display snapshot through `bb plugin rpc call provider-usage getUsage --input-file --json`. The private display request includes `force`, nullable `machineIds`, nullable `providerId`, and `maxAgeMs`; null providerId lists metadata without collecting usage. Source fetch requests use a JSON file containing `resourceId` and `refresh`. Consumers needing replacement-independent data can discover and call the source methods directly. Provider Usage must stop collecting the same host usage independently once its provider plugin supplies it through discovery. - -## Delivery sequence - -1. **Schema publication:** implement export support and registration validation. Verify portable wire schemas for real usage types and unchanged anonymous RPC behavior. -2. **Registry and inspection:** add opt-in publication, lifecycle-safe descriptors, targeted discovery route, SDK query, and CLI listing/inspection. -3. **Contracts and documentation:** publish copyable examples, method naming guidance, schema limitations, and lifecycle semantics in Plugin Guide. Add SDK surfaces to `packages/plugin-api-map/src/surfaces.ts` and audit entries to `docs/api_to_audit.md`; update CLI guide templates and skills. -4. **Usage sources:** implement the convention in Account Pooler and the provider plugins using existing collection primitives. -5. **Usage display:** migrate Provider Usage to discovery, expose the unified snapshot through its RPC/CLI, and verify a second display consumer against the same sources. - -Keep the work server-side unless inspection proves host wire changes are necessary. Existing host usage maintenance remains a primitive. If any server/daemon wire fields change, increment `HOST_DAEMON_PROTOCOL_VERSION` unless previous-daemon compatibility is deliberately preserved and tested. - -## Verification and completion criteria - -Use Turbo for relevant package typechecks and tests. Extend the plugin test harness to inspect published descriptors and exercise discovery across plugins. - -- Existing unnamed registrations and calls behave unchanged. -- Unadvertised methods remain callable but absent from discovery and inspection. -- Unsupported schema export fails clearly and publishes no partial registration. -- Exported schemas accurately describe representative input/output wire values. -- Registration, method, and field descriptions survive publication and CLI inspection independently; omitted descriptions are explicit `null` values in descriptors. -- Descriptions alone do not advertise methods. Existing contracts without descriptions remain valid, and invalid or oversized descriptions fail clearly. -- Usage inspection explains refresh behavior, resource scope, and partial failures without requiring repository access. -- Duplicate methods, failed load, unload, and reload preserve registry consistency. -- Two implementations of one method and v1/v2 methods coexist without new routing rules. -- A consumer with a copied incompatible schema gets a validation failure rather than trusted data. -- CLI and SDK inspection agree and do not execute handlers. -- An externally built fixture with copied schemas works without a shared contract package or a display plugin dependency. -- Account Pooler and local providers appear together with correct scope, errors, and observation times. -- One source failing, disconnecting, or unloading does not hide successful sources. -- Refresh reaches sources and overlapping refreshes coalesce. -- Disabling Provider Usage leaves source discovery and calls functional. A replacement display produces the same underlying observations. -- Relevant Provider Usage UI journeys and plugin CLI flows pass the repository's verification workflow. - -The result is complete when discovery and inspection are generally usable, usage producers implement the public convention, and either display can consume them independently. Provider Retry and thread-specific quota attribution are not prerequisites. - -Usage-state review: both consumers distinguish loading, empty shared groups, unavailable sources, uninstalled providers, per-account authentication/collection failures, plans without reported limits, and offline machines. Shared-account sign-in guidance refers to the source plugin’s settings. Failed source refreshes preserve successful cached observations with a visible notice; disabled sources disappear on discovery reconciliation. Browser fixtures exercise source selection, removal, and retry recovery without changing configured accounts. Unfiltered CLI discovery omits undefined filters instead of serializing them into literal query values. - -## Explicit provider implementations and normalization - -Provider Usage owns the canonical contract. Codex, Claude Code, and ACP copy it -into their own source and explicitly register the two methods. Each implementation -filters inventory and fetches by its own plugin ownership; ACP includes its dynamically -configured usage-capable agents. Collection uses the existing targeted maintenance -SDK API. Account Pooler implements the same contract for its shared accounts. - -There is no extra adapter plugin. Declaring `maintenance.usage` alone does not -publish this contract; other provider authors must implement it explicitly. The -small amount of source duplication is intentional while the broader provider-contract -design develops. The provider kit and core runtime do not import this contract. -A replacement display can consume these sources without enabling Provider Usage. - -Resource IDs are opaque source-local addresses. `accountKey` is a nullable, -provider-issued quota identity, namespaced by issuer and account/organization -scope. Labels and email are presentation only. Known matching identities within a -selected location collapse to one observation in stable source order; quota -percentages are never summed. Unknown identities remain distinct. Location -selection happens first: shared sources are the default, and an explicit machine -selection shows that machine, even when it observes the same account as a pool. - -Inventory may return an unknown key until the first measurement. The measurement's -identity is authoritative. Each source remembers it for later cheap inventory; -it never reads credentials merely to list resources. Codex and Claude Code add -provider-owned identity and normalization metadata to their existing passthrough -maintenance responses. Core transports those extensions without interpreting them. -Implementations without optional normalization metadata remain compatible and report unknown identity/custom labels. - -Known windows carry `kind` (`five-hour`, `daily`, `weekly`, or `custom`) alongside -an optional model family. Known plans carry `{id, multiplier}` alongside their -fallback label. Consumers consistently render Weekly limit and Max (20x), while -retaining unfamiliar provider labels. These additions default to unknown/custom -when consuming older source contracts. Breaking semantics still require a new -method namespace; discovery introduces no independent version negotiation. diff --git a/plugins/provider-usage/settings-ui.tsx b/plugins/provider-usage/settings-ui.tsx deleted file mode 100644 index a0a5ad63e9..0000000000 --- a/plugins/provider-usage/settings-ui.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { type ReactNode } from "react"; -import { cn } from "@bb/shared-ui/lib/utils"; - -interface SettingsSectionProps { - action?: ReactNode; - actionPlacement?: "inline" | "responsive"; - children: ReactNode; - description?: string; - title: ReactNode; - bodyClassName?: string; -} - -export function SettingsSection({ - action, - actionPlacement = "responsive", - children, - description, - title, - bodyClassName, -}: SettingsSectionProps) { - return ( -
-
-
-
-

- {title} -

-
- {description ? ( -

- {description} -

- ) : null} -
- {action ?
{action}
: null} -
-
- {children} -
-
- ); -} - -interface SettingsRowListProps { - children: ReactNode; -} - -export function SettingsRowList({ children }: SettingsRowListProps) { - return
{children}
; -} - -export function SettingsBadge({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} diff --git a/plugins/provider-usage/settings.tsx b/plugins/provider-usage/settings.tsx index ceeb14ab64..81607c6710 100644 --- a/plugins/provider-usage/settings.tsx +++ b/plugins/provider-usage/settings.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { useRpc, experimental_ProviderIcon as ProviderIcon, @@ -20,11 +20,13 @@ import { type ProviderUsage, type UsageWindow, } from "./usage-schema.js"; -import { - SettingsSection, - SettingsBadge, - SettingsRowList, -} from "./settings-ui.js"; +function SettingsBadge({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} interface ProviderConfig { name: string; @@ -438,73 +440,86 @@ export function UsageSettings() { : "Couldn’t load usage. Try reloading usage." : null; return ( - - {machines.length > 1 ? ( - ({ - id: machine.id, - name: machine.displayName, - kind: machine.id.startsWith("source:") ? "source" : "host", - disabled: machine.status !== "connected", - }))} - selectedLocationId={selected?.id ?? null} - onSelectLocation={setSelectedId} - /> - ) : null} - +
+
+
+
+

+ Usage limits +

+
+

+ Your provider subscription usage. +

- } - > - {notice ? ( -

- {notice} -

- ) : null} - - {[...groups].map(([id, resources]) => { - const provider = resources[0]!; - return ( - - ); - })} - {groups.size === 0 && !notice ? ( -

- {loading - ? "Loading providers and usage…" - : selected?.id.startsWith("source:") - ? "No accounts report usage yet. Configure accounts in the source plugin’s settings, or choose a machine." - : "No providers report usage limits on this machine."} +

+
+ {machines.length > 1 ? ( + ({ + id: machine.id, + name: machine.displayName, + kind: machine.id.startsWith("source:") ? "source" : "host", + disabled: machine.status !== "connected", + }))} + selectedLocationId={selected?.id ?? null} + onSelectLocation={setSelectedId} + /> + ) : null} + +
+
+
+
+ {notice ? ( +

+ {notice}

) : null} - - +
+ {[...groups].map(([id, resources]) => { + const provider = resources[0]!; + return ( + + ); + })} + {groups.size === 0 && !notice ? ( +

+ {loading + ? "Loading providers and usage…" + : selected?.id.startsWith("source:") + ? "No accounts report usage yet. Configure accounts in the source plugin’s settings, or choose a machine." + : "No providers report usage limits on this machine."} +

+ ) : null} +
+
+
); } From fee42f01e8b6823b4f39880a4f1f2f83690d32db Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 11 Sep 2026 22:40:07 -0700 Subject: [PATCH 36/53] Remove usage contract internals from provider skills --- .../account-pool/references/accounts-and-routing.md | 4 ---- plugins/provider-acp/skills/acp-provider/SKILL.md | 10 ---------- .../skills/claude-code-provider/SKILL.md | 4 ---- plugins/provider-codex/skills/codex-provider/SKILL.md | 4 ---- 4 files changed, 22 deletions(-) diff --git a/plugins/account-pool/skills/account-pool/references/accounts-and-routing.md b/plugins/account-pool/skills/account-pool/references/accounts-and-routing.md index a0845ffe3b..f6c39ae838 100644 --- a/plugins/account-pool/skills/account-pool/references/accounts-and-routing.md +++ b/plugins/account-pool/skills/account-pool/references/accounts-and-routing.md @@ -78,7 +78,3 @@ one provider. Include disabled accounts too. Reordering changes the next failove sequence without moving the current account. `bb pool account priority ` sets an individual priority; the same operations are available through the `account.reorder` and `account.setPriority` plugin RPCs. - -## Discoverable usage - -This plugin exposes cheap `provider-usage.v1.listResources` inventory and `provider-usage.v1.getResource` measurements for independent usage displays. Fetch takes `{ resourceId, refresh }` and returns actual usage for that resource even when refresh is false; listing never collects quota. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect account-pool provider-usage.v1.listResources --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. diff --git a/plugins/provider-acp/skills/acp-provider/SKILL.md b/plugins/provider-acp/skills/acp-provider/SKILL.md index fc25922e14..515f84e379 100644 --- a/plugins/provider-acp/skills/acp-provider/SKILL.md +++ b/plugins/provider-acp/skills/acp-provider/SKILL.md @@ -21,13 +21,3 @@ models selectable through BB's model field. OpenCode ACP supports the core `bb thread compact` command; Cursor ACP does not expose compatible compaction. Check the actual agent's capabilities before attempting provider-specific recovery. - -## Usage resources - -This plugin directly implements Provider Usage's discoverable -`provider-usage.v1.listResources` and `provider-usage.v1.getResource` contracts for -its own usage-capable ACP agents, including Cursor. Listing is cheap metadata; -fetching measures only the returned host/provider resource ID. Inspect the copied -contract with `bb plugin rpc inspect provider-acp --json`. No display plugin needs -to be enabled. Other provider plugins must explicitly implement the usage contract; -`maintenance.usage` alone does not publish RPC methods. diff --git a/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md b/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md index 5602cabf76..48823e8cca 100644 --- a/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md +++ b/plugins/provider-claude-code/skills/claude-code-provider/SKILL.md @@ -18,7 +18,3 @@ with `bb plugin config provider-claude-code set `. Inspect the thread and provider state after a change; do not restart unrelated threads or change settings merely to answer a question. - -## Discoverable usage - -This plugin directly publishes cheap `provider-usage.v1.listResources` inventory and `provider-usage.v1.getResource` measurements for independent usage displays. Fetch takes `{ resourceId, refresh }` and returns actual usage for that resource even when refresh is false; listing never collects quota. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect provider-claude-code provider-usage.v1.listResources --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. diff --git a/plugins/provider-codex/skills/codex-provider/SKILL.md b/plugins/provider-codex/skills/codex-provider/SKILL.md index 6d0015109d..c3fdc2cc45 100644 --- a/plugins/provider-codex/skills/codex-provider/SKILL.md +++ b/plugins/provider-codex/skills/codex-provider/SKILL.md @@ -16,7 +16,3 @@ account access. Inspect models on the actual execution host with Use the core CLI skill for command syntax and official Codex guidance for upstream product behavior. - -## Discoverable usage - -This plugin directly publishes cheap `provider-usage.v1.listResources` inventory and `provider-usage.v1.getResource` measurements for independent usage displays. Fetch takes `{ resourceId, refresh }` and returns actual usage for that resource even when refresh is false; listing never collects quota. Inspect its descriptions and input/output JSON Schemas with `bb plugin rpc inspect provider-codex provider-usage.v1.listResources --json`. The settings Usage limits page discovers these sources; the existing `bb settings usage` command continues to show host-provider maintenance data. From 1b2ad690189c9e1c8578d554d1e58df9c4bff478 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Fri, 11 Sep 2026 22:43:39 -0700 Subject: [PATCH 37/53] Remove remaining usage prototype documentation and dead types --- .../features/plugin-provider-usage.md | 33 ++++++++----------- docs/api_to_audit.md | 6 ++-- .../src/templates/bb-guide-plugins.md | 7 ---- plugins/provider-usage/server.ts | 8 +---- 4 files changed, 18 insertions(+), 36 deletions(-) diff --git a/.bb/skills/verify-bb/features/plugin-provider-usage.md b/.bb/skills/verify-bb/features/plugin-provider-usage.md index e28cfa993c..054f9cf942 100644 --- a/.bb/skills/verify-bb/features/plugin-provider-usage.md +++ b/.bb/skills/verify-bb/features/plugin-provider-usage.md @@ -4,7 +4,7 @@ Status: **2026-09-05: 2 passed, 1 partial/blocked**. See [the audit](../MAINTENA ## Setup and entry points -Enable Provider usage and a provider implementing the usage RPC contract. Open its usage card and Settings → Usage. +Enable Provider usage and a provider implementing the usage RPC contract. Open its usage card and Settings → Installed plugins → Provider usage. Use the main skill’s isolated targets and evidence rules. A plugin can be present in this checkout but disabled in an installation. Enable it only in the test @@ -21,7 +21,7 @@ SKILL.md. Inspect nested `--help` before selecting flags and IDs. - `plugins/provider-claude-code/src/usage-source.ts` - `plugins/provider-acp/src/usage-source.ts` - `plugins/account-pool/src/usage-source.ts` -- `apps/app/src/components/settings/UsageLimitsSettingsSection.tsx` +- `plugins/provider-usage/settings.tsx` ## Feature recipes @@ -40,20 +40,15 @@ failed attempts and missing prerequisites as unverified results. Restore plugin configuration and remove only this run’s fixtures, registrations, and workers. External account changes use authorized disposable targets. -## Maintenance notes - -- Open Settings → Usage limits and the sidebar Provider usage disclosure. Compare core settings usage / sdk.system.usageLimits with plugin getUsage, which wraps per-machine providers and normalizes optional fields; it is not byte-for-byte the core response. Source: `plugins/provider-usage/app.tsx:112`. - -## Usage source prototype follow-up (2026-09-11) - -- Passed live: pool defaults on both displays, provider tabs and pooled account ordering, - matching weekly/model/plan labels, explicit machine selection, and Cursor - usage published directly by the ACP provider plugin. Screenshot evidence is in the - implementing thread’s `usage-review/explicit-providers.md`. -- Passed targeted tests: provider ownership filtering, no collection during inventory, - removed resources, disconnected hosts, request coalescing, force refresh, empty pools, - first-load/stale failures, known identity deduplication, and unknown-identity separation. -- A new source should be tested with the display plugin disabled. Each provider implementation has no - dependency on the display and publishes both source methods from its own registration. -- Do not deduplicate by email. Filter by selected location before normalizing observations; - explicit machine selection must remain available even when that account exists in a pool. +## Contract verification + +- Test a source with the display plugin disabled. Each provider implementation + publishes both source methods independently of the display. +- Check empty pools, loading, first-load errors, stale measurements after failed + refresh, disconnected hosts, and removed resources on both displays. +- Verify matching window and plan labels, account order, default pool selection, + and explicit machine selection. Do not deduplicate by email; known account + identities are deduplicated only within the selected location. +- The settings page fetches resources for the selected location; the footer + fetches only its selected provider tab. Compare shared pool sources through + their RPC contract; `bb settings usage` remains the direct host-maintenance view. diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index efbd9595f6..a108134eaa 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -2146,9 +2146,7 @@ controller that can request `open`, `close`, or `toggle`. Those requests go through the host's shared active-item coordinator, so opening one plugin's disclosure replaces another and a stale scoped `close` cannot dismiss a sibling. The existing `app.slots.sidebarFooterAction` remains a compatibility surface and -renders in the same footer row. User appearance preferences order the items and -move hidden shortcuts into More; hiding does not disable plugin callbacks or -programmatic disclosure controls. +renders in the same footer row. **Audit before stabilizing.** @@ -2917,6 +2915,7 @@ returns a credential only while that host is creating. Before stabilizing, verify creation cancellation through host removal, same-host restoration, serialized removal, plugin callers and UI/CLI parity. + ## `app.experimental_icons.register` and `experimental_Icon` Plugins register inline React artwork during app setup with `{ name, component }`. @@ -2953,6 +2952,7 @@ plugin app icons use this registration API. The manifest API is unchanged, and individual plugins can still declare their own branding SVG assets using the existing manifest fields. + ## `experimental_ProviderIcon` Shared frontend renderer for agent, machine, and environment provider artwork, diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index 958b15e1b2..eca0811304 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -927,10 +927,3 @@ Modal image debugging: `bb modal image build [--json]` prepares the saved image; `bb plugin rpc list [plugin-id] [--method ] [--json]` lists discoverable methods from running plugins, optionally restricted to one plugin. `bb plugin rpc inspect [method] [--json]` dumps registration and method descriptions plus input/output JSON Schemas. Copy the relevant schema into your consumer and call the existing plugin RPC endpoint. Discovery is opt-in advertising, not access control; method names may carry versions such as `provider-usage.v1.listResources`. `bb plugin rpc call [--input-file ] [--json]` invokes a method using server-side schema validation. Omitting the input file sends JSON null. Input files avoid putting sensitive values in command arguments. - -### Provider usage - -Provider Usage is enabled by default and shows usage in its plugin settings page -and sidebar footer. Right-click its footer shortcut and choose Hide to move it -into More. Settings → Appearance → Sidebar footer controls order and visibility. -See the `provider-usage` skill for source discovery. diff --git a/plugins/provider-usage/server.ts b/plugins/provider-usage/server.ts index 5aef35ce45..c66fbdeb8e 100644 --- a/plugins/provider-usage/server.ts +++ b/plugins/provider-usage/server.ts @@ -43,12 +43,7 @@ export const providerUsageRpcContract = defineRpcContract({ }, }); -interface UsageRequest { - force: boolean; - machineIds: string[] | null; - maxAgeMs: number; - providerId: string | null; -} +type UsageRequest = z.infer; function normalizedTint( tint: { light: string; dark: string } | undefined, ): { light: string; dark: string } | null { @@ -93,7 +88,6 @@ function normalizedUsage( } } -type Host = Awaited>[number]; type Provider = Awaited< ReturnType >[number]; From fc4510145c2f59a482f962f21583ada09c83a6a1 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Sun, 13 Sep 2026 19:32:17 -0700 Subject: [PATCH 38/53] Match footer usage source picker to settings --- plugins/provider-usage/app.tsx | 44 +++++++++++++--------------------- 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/plugins/provider-usage/app.tsx b/plugins/provider-usage/app.tsx index 8481e030ba..a17afcb7e7 100644 --- a/plugins/provider-usage/app.tsx +++ b/plugins/provider-usage/app.tsx @@ -272,7 +272,7 @@ function MachineSelector({ {machines.map((machine) => { const isActive = machine.id === activeMachine?.id; @@ -308,23 +306,13 @@ function MachineSelector({ aria-label={machine.displayName} aria-checked={isActive} onSelect={() => onSelect(machine.id)} - className={cn( - "flex items-center justify-between gap-3", - LIST_HOVER_TRANSITION, - )} + className="flex items-center gap-2" > + -
); } + +export function UsageSettings() { + const rpc = useRpc(); + const [machines, setMachines] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [refresh, setRefresh] = useState(0); + const forceGeneration = useRef(0); + useEffect(() => { + let disposed = false; + let running = false; + const force = refresh > forceGeneration.current; + forceGeneration.current = refresh; + async function load(force: boolean) { + if (running) return; + running = true; + setLoading(true); + setError(false); + try { + const inventory = await rpc.call("getUsage", { + force: false, + machineIds: null, + providerId: null, + maxAgeMs: 60_000, + }); + if (disposed) return; + setMachines(inventory.machines); + const selected = selectUsageMachine( + inventory.machines, + selectedId, + null, + ); + if (selected && selected.status === "connected") { + for (const providerId of new Set( + selected.providers.map((provider) => provider.providerId), + )) { + const result = await rpc.call("getUsage", { + force, + machineIds: [selected.id], + providerId, + maxAgeMs: 60_000, + }); + if (disposed) return; + setMachines(result.machines); + } + } + } catch { + if (!disposed) setError(true); + } finally { + running = false; + if (!disposed) setLoading(false); + } + } + void load(force); + const timer = window.setInterval(() => { + if (document.visibilityState === "visible") void load(false); + }, 60_000); + return () => { + disposed = true; + window.clearInterval(timer); + }; + }, [rpc, selectedId, refresh]); + return ( + setRefresh((value) => value + 1)} + /> + ); +} From 438a5287b85622a05055da921f02076a69f37d50 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Mon, 14 Sep 2026 11:24:09 -0700 Subject: [PATCH 46/53] Consolidate Provider Usage stories --- plugins/provider-usage/app.stories.tsx | 73 ++++++++++---------------- 1 file changed, 29 insertions(+), 44 deletions(-) diff --git a/plugins/provider-usage/app.stories.tsx b/plugins/provider-usage/app.stories.tsx index 50c4faeb22..3ff41928da 100644 --- a/plugins/provider-usage/app.stories.tsx +++ b/plugins/provider-usage/app.stories.tsx @@ -1,4 +1,5 @@ -import { useState, type ReactNode } from "react"; +import { useState } from "react"; +import { StoryCard, StoryRow } from "../../apps/app/.ladle/story-card.js"; import { ProviderUsageStatusContent, type UsageStoreSnapshot } from "./app.js"; import { UsageSettingsContent } from "./settings.js"; import type { @@ -154,29 +155,11 @@ function storySnapshot(name: ScenarioName): UsageStoreSnapshot { }; } -function Stage({ - title, - description, - children, -}: { - title: string; - description: string; - children: ReactNode; -}) { - return ( -
-

{title}

-

{description}

-
{children}
-
- ); -} - function SettingsPreview({ scenario }: { scenario: ScenarioName }) { const [selectedId, setSelectedId] = useState(null); const machines = scenario === "loading" ? [] : scenarios[scenario].machines; return ( -
+
= { "The latest refresh failed while cached measurements remain visible.", }; -function settingsStory(scenario: ScenarioName) { +const storyRows: readonly { label: string; scenario: ScenarioName }[] = [ + { label: "healthy", scenario: "healthy" }, + { label: "empty account pool", scenario: "emptyPool" }, + { label: "loading", scenario: "loading" }, + { label: "offline machine", scenario: "offline" }, + { label: "authentication", scenario: "authentication" }, + { label: "missing provider", scenario: "missingProvider" }, + { label: "failed refresh", scenario: "failedRefresh" }, +]; + +export function Settings() { return ( - - - + + {storyRows.map(({ label, scenario }) => ( + + + + ))} + ); } -function footerStory(scenario: ScenarioName) { +export function Disclosure() { return ( - - - + + {storyRows.map(({ label, scenario }) => ( + + + + ))} + ); } - -export const SettingsHealthy = () => settingsStory("healthy"); -export const SettingsEmptyPool = () => settingsStory("emptyPool"); -export const SettingsLoading = () => settingsStory("loading"); -export const SettingsOffline = () => settingsStory("offline"); -export const SettingsAuthentication = () => settingsStory("authentication"); -export const SettingsMissingProvider = () => settingsStory("missingProvider"); -export const SettingsFailedRefresh = () => settingsStory("failedRefresh"); - -export const FooterHealthy = () => footerStory("healthy"); -export const FooterEmptyPool = () => footerStory("emptyPool"); -export const FooterLoading = () => footerStory("loading"); -export const FooterOffline = () => footerStory("offline"); -export const FooterAuthentication = () => footerStory("authentication"); -export const FooterMissingProvider = () => footerStory("missingProvider"); -export const FooterFailedRefresh = () => footerStory("failedRefresh"); From df1f76a01585c552bed34adbef9ab3b137f8d0ee Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Mon, 14 Sep 2026 11:28:07 -0700 Subject: [PATCH 47/53] Fix Provider Usage disclosure header height --- plugins/provider-usage/app.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/provider-usage/app.tsx b/plugins/provider-usage/app.tsx index 4477b7c1f7..13e547c0e4 100644 --- a/plugins/provider-usage/app.tsx +++ b/plugins/provider-usage/app.tsx @@ -467,7 +467,7 @@ export function ProviderUsageStatusContent({
{providers.length === 0 ? null : (
Date: Mon, 14 Sep 2026 11:28:55 -0700 Subject: [PATCH 48/53] Simplify usage machine menu items --- plugins/provider-usage/app.tsx | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/plugins/provider-usage/app.tsx b/plugins/provider-usage/app.tsx index 13e547c0e4..b0a057c341 100644 --- a/plugins/provider-usage/app.tsx +++ b/plugins/provider-usage/app.tsx @@ -310,17 +310,8 @@ function MachineSelector({ name={machine.id.startsWith("source:") ? "Layers" : "Laptop"} className="size-3.5 shrink-0" /> - - {machine.displayName} - {machine.error !== null ? ( - - Unavailable - - ) : machine.status === "disconnected" ? ( - - Offline - - ) : null} + + {machine.displayName} Date: Mon, 14 Sep 2026 11:35:40 -0700 Subject: [PATCH 49/53] Unify Provider Usage feedback states --- plugins/provider-usage/app.test.tsx | 13 ++- plugins/provider-usage/app.tsx | 118 ++++++++++------------ plugins/provider-usage/settings.test.tsx | 13 +-- plugins/provider-usage/settings.tsx | 45 +++++---- plugins/provider-usage/usage-feedback.tsx | 64 ++++++++++++ 5 files changed, 158 insertions(+), 95 deletions(-) create mode 100644 plugins/provider-usage/usage-feedback.tsx diff --git a/plugins/provider-usage/app.test.tsx b/plugins/provider-usage/app.test.tsx index 2a7d807748..539836d1e4 100644 --- a/plugins/provider-usage/app.test.tsx +++ b/plugins/provider-usage/app.test.tsx @@ -393,7 +393,9 @@ describe("provider usage footer disclosure", () => { ); await waitFor(() => expect( - slot.getByText("Couldn’t refresh. Showing the last update."), + slot.getByText( + "Couldn’t refresh usage. Showing the last available update.", + ), ).toBeTruthy(), ); expect(slot.getByText("claude-team@example.com")).toBeTruthy(); @@ -405,7 +407,9 @@ describe("provider usage footer disclosure", () => { ); await waitFor(() => expect( - slot.queryByText("Couldn’t refresh. Showing the last update."), + slot.queryByText( + "Couldn’t refresh usage. Showing the last available update.", + ), ).toBeNull(), ); } @@ -422,7 +426,10 @@ it.each([ "Sign in to this account in the source plugin’s settings.", ], ["no-limits", "No usage limits reported for this plan."], - ["source-error", "Couldn’t refresh. Showing the last update."], + [ + "source-error", + "Couldn’t refresh usage. Showing the last available update.", + ], ] as const)("renders the %s shared-source state", async (state, expected) => { const usage: UsageProvider["usage"] = state === "expired" || state === "unauthenticated" diff --git a/plugins/provider-usage/app.tsx b/plugins/provider-usage/app.tsx index b0a057c341..d0714def9f 100644 --- a/plugins/provider-usage/app.tsx +++ b/plugins/provider-usage/app.tsx @@ -42,6 +42,13 @@ import { type UsageSnapshot, type UsageWindow as UsageWindowValue, } from "./usage-schema.js"; +import { + emptyUsageMessage, + hasReportedUsage, + offlineUsageMessage, + UsageFeedback, + usageFeedbackMessages, +} from "./usage-feedback.js"; import { UsageSettings } from "./settings.js"; @@ -385,6 +392,26 @@ export function ProviderUsageStatusContent({ providers.find((provider) => provider.id === requestedProviderId) ?? providers[0] ?? null; + const activeAccounts = activeProvider?.accounts ?? []; + const hasActiveUsage = hasReportedUsage(activeAccounts); + const feedback = + activeMachine === null + ? snapshot.error !== null + ? usageFeedbackMessages.loadFailed + : snapshot.isRefreshing + ? usageFeedbackMessages.loading + : usageFeedbackMessages.noSources + : activeMachine.status === "disconnected" + ? offlineUsageMessage(activeMachine, hasActiveUsage) + : snapshot.error !== null || activeMachine.error !== null + ? hasActiveUsage + ? usageFeedbackMessages.refreshFailed + : usageFeedbackMessages.loadFailed + : activeProvider === null + ? emptyUsageMessage(activeMachine) + : null; + const feedbackCanRetry = + snapshot.error !== null || activeMachine?.error != null; const panelId = useId(); const activeMachineId = activeMachine?.id ?? null; const activeProviderId = activeProvider?.id ?? null; @@ -569,26 +596,26 @@ export function ProviderUsageStatusContent({ } className="min-h-0 overflow-y-auto p-2.5" > - {activeMachine === null ? ( - snapshot.error !== null ? null : ( -

- {snapshot.isRefreshing - ? "Loading provider usage…" - : "No machines are enrolled."} -

- ) - ) : activeProvider === null ? ( -

- {activeMachine.status === "disconnected" - ? activeMachine.displayName + - " is offline. Usage will refresh when it reconnects." - : activeMachine.error !== null - ? null - : activeMachine.id.startsWith("source:") - ? "No accounts report usage yet. Configure accounts in the source plugin’s settings." - : "No providers report usage limits on this machine."} -

- ) : ( + {feedback === null ? null : ( + + void refreshUsage({ + force: true, + machineIds: + activeMachineId === null ? null : [activeMachineId], + maxAgeMs: 0, + providerId: activeProvider?.id ?? null, + }) + : undefined + } + className={activeProvider === null ? undefined : "mb-2"} + /> + )} + {activeProvider === null ? null : ( <>
{activeProvider.accounts.map((account) => ( @@ -624,19 +651,15 @@ export function ProviderUsageStatusContent({ ) : null}
- {activeMachine.status === "disconnected" ? ( -

- {activeMachine.displayName} is offline. Usage will - refresh when it reconnects. -

- ) : account.usage === null && snapshot.isRefreshing ? ( + {account.usage === null && snapshot.isRefreshing ? (

- Loading usage… + {usageFeedbackMessages.loading}

) : account.usage === null && - activeMachine.error !== null ? ( + (activeMachine?.error != null || + snapshot.error !== null) ? (

- Couldn’t load this account’s usage. + {usageFeedbackMessages.unavailable}

) : ( @@ -647,43 +670,6 @@ export function ProviderUsageStatusContent({
)} - {snapshot.error === null && activeMachine?.error == null ? null : ( -
- - {snapshot.data === null || - !activeProvider?.accounts.some( - (account) => account.usage !== null, - ) - ? "Couldn’t load usage." - : "Couldn’t refresh. Showing the last update."} - - -
- )}
); diff --git a/plugins/provider-usage/settings.test.tsx b/plugins/provider-usage/settings.test.tsx index f88b5ed9de..4591fb092a 100644 --- a/plugins/provider-usage/settings.test.tsx +++ b/plugins/provider-usage/settings.test.tsx @@ -139,9 +139,12 @@ it("renders loading and a friendly transport error without exposing raw errors", {}, { rpc: { getUsage: () => pending } }, ); - expect(slot.getByText("Loading providers and usage…")).toBeTruthy(); + expect(slot.getByText("Loading usage…")).toBeTruthy(); reject(new Error("Unexpected token 'b', bb connect...")); - await slot.findByText("Couldn’t load usage. Try reloading usage."); + await slot.findByText("Couldn’t load usage."); + expect( + slot.getByRole("button", { name: "Retry usage refresh" }), + ).toBeTruthy(); expect(slot.queryByText(/Unexpected token/)).toBeNull(); }); @@ -201,10 +204,8 @@ it("shows pending measurements without inventing usage, then reports an unavaila await slot.findByText("Loading usage…"); expect(slot.queryByText("0% used")).toBeNull(); finish(); - await slot.findByText("Couldn’t load usage. Try reloading usage."); - expect( - slot.getByText("Couldn't load usage right now. Try reloading usage."), - ).toBeTruthy(); + await slot.findByText("Couldn’t load usage."); + expect(slot.getByText("Usage unavailable.")).toBeTruthy(); expect(slot.queryByText(/Showing the last/)).toBeNull(); }); diff --git a/plugins/provider-usage/settings.tsx b/plugins/provider-usage/settings.tsx index 54129cbfa0..b5cbeac3eb 100644 --- a/plugins/provider-usage/settings.tsx +++ b/plugins/provider-usage/settings.tsx @@ -13,6 +13,13 @@ import { } from "@bb/shared-ui/dropdown-menu"; import { cn } from "@bb/shared-ui/lib/utils"; import type { providerUsageRpcContract } from "./server.js"; +import { + emptyUsageMessage, + hasReportedUsage, + offlineUsageMessage, + UsageFeedback, + usageFeedbackMessages, +} from "./usage-feedback.js"; import { selectUsageMachine, type UsageMachine, @@ -311,7 +318,7 @@ function ProviderUsageBody({ if (isError) { return (

- Couldn't load usage right now. Try reloading usage. + {usageFeedbackMessages.unavailable}

); } @@ -384,14 +391,18 @@ export function UsageSettingsContent({ } const notice = selected?.status === "disconnected" - ? `${selected.displayName} is offline. Usage will refresh when it reconnects.` + ? offlineUsageMessage(selected, hasReportedUsage(selected.providers)) : error || selected?.error - ? [...groups.values()].some((accounts) => - accounts.some((account) => account.usage !== null), - ) - ? "Couldn’t refresh usage. Showing the last available update. Try reloading usage." - : "Couldn’t load usage. Try reloading usage." - : null; + ? hasReportedUsage(selected?.providers ?? []) + ? usageFeedbackMessages.refreshFailed + : usageFeedbackMessages.loadFailed + : selected === null + ? loading + ? usageFeedbackMessages.loading + : usageFeedbackMessages.noSources + : groups.size === 0 + ? emptyUsageMessage(selected) + : null; return (
@@ -439,9 +450,12 @@ export function UsageSettingsContent({
{notice ? ( -

- {notice} -

+ 0 ? "mb-3" : undefined} + /> ) : null}
{[...groups].map(([id, resources]) => { @@ -462,15 +476,6 @@ export function UsageSettingsContent({ /> ); })} - {groups.size === 0 && !notice ? ( -

- {loading - ? "Loading providers and usage…" - : selected?.id.startsWith("source:") - ? "No accounts report usage yet. Configure accounts in the source plugin’s settings, or choose a machine." - : "No providers report usage limits on this machine."} -

- ) : null}
diff --git a/plugins/provider-usage/usage-feedback.tsx b/plugins/provider-usage/usage-feedback.tsx new file mode 100644 index 0000000000..8aa486abec --- /dev/null +++ b/plugins/provider-usage/usage-feedback.tsx @@ -0,0 +1,64 @@ +import { cn } from "@bb/shared-ui/lib/utils"; +import type { UsageMachine, UsageProvider } from "./usage-schema.js"; + +export const usageFeedbackMessages = { + loading: "Loading usage…", + noSources: "No usage sources available.", + loadFailed: "Couldn’t load usage.", + refreshFailed: "Couldn’t refresh usage. Showing the last available update.", + unavailable: "Usage unavailable.", +} as const; + +export function hasReportedUsage(providers: readonly UsageProvider[]): boolean { + return providers.some((provider) => provider.usage !== null); +} + +export function emptyUsageMessage(machine: UsageMachine): string { + return machine.id.startsWith("source:") + ? "No accounts report usage yet. Configure accounts in the source plugin’s settings." + : "No providers report usage limits on this machine."; +} + +export function offlineUsageMessage( + machine: UsageMachine, + hasUsage: boolean, +): string { + return hasUsage + ? `${machine.displayName} is offline. Showing the last available update.` + : `${machine.displayName} is offline. Usage will refresh when it reconnects.`; +} + +export function UsageFeedback({ + message, + retrying = false, + onRetry, + className, +}: { + message: string; + retrying?: boolean; + onRetry?: () => void; + className?: string; +}) { + return ( +
+ {message} + {onRetry ? ( + + ) : null} +
+ ); +} From d35d7cec95844ca3fd7c01bdfd7775a35d9c6a0d Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Mon, 14 Sep 2026 11:39:36 -0700 Subject: [PATCH 50/53] Remove inline usage retry actions --- plugins/provider-usage/app.test.tsx | 6 +++--- plugins/provider-usage/app.tsx | 15 --------------- plugins/provider-usage/settings.test.tsx | 4 ++-- plugins/provider-usage/settings.tsx | 2 -- plugins/provider-usage/usage-feedback.tsx | 15 --------------- 5 files changed, 5 insertions(+), 37 deletions(-) diff --git a/plugins/provider-usage/app.test.tsx b/plugins/provider-usage/app.test.tsx index 539836d1e4..a33094f10a 100644 --- a/plugins/provider-usage/app.test.tsx +++ b/plugins/provider-usage/app.test.tsx @@ -403,7 +403,7 @@ describe("provider usage footer disclosure", () => { slot.queryByText(/Unexpected token|bb connect|invalid JSON/i), ).toBeNull(); fireEvent.click( - slot.getByRole("button", { name: "Retry usage refresh" }), + slot.getByRole("button", { name: "Reload provider usage" }), ); await waitFor(() => expect( @@ -495,8 +495,8 @@ it.each([ expect(slot.getByText("42%")).toBeTruthy(); expect(slot.queryByText("private backend error")).toBeNull(); expect( - slot.getByRole("button", { name: "Retry usage refresh" }), - ).toBeTruthy(); + slot.queryByRole("button", { name: "Retry usage refresh" }), + ).toBeNull(); } await mounted.lifecycle.dispose(); }); diff --git a/plugins/provider-usage/app.tsx b/plugins/provider-usage/app.tsx index d0714def9f..77a0342f5c 100644 --- a/plugins/provider-usage/app.tsx +++ b/plugins/provider-usage/app.tsx @@ -410,8 +410,6 @@ export function ProviderUsageStatusContent({ : activeProvider === null ? emptyUsageMessage(activeMachine) : null; - const feedbackCanRetry = - snapshot.error !== null || activeMachine?.error != null; const panelId = useId(); const activeMachineId = activeMachine?.id ?? null; const activeProviderId = activeProvider?.id ?? null; @@ -599,19 +597,6 @@ export function ProviderUsageStatusContent({ {feedback === null ? null : ( - void refreshUsage({ - force: true, - machineIds: - activeMachineId === null ? null : [activeMachineId], - maxAgeMs: 0, - providerId: activeProvider?.id ?? null, - }) - : undefined - } className={activeProvider === null ? undefined : "mb-2"} /> )} diff --git a/plugins/provider-usage/settings.test.tsx b/plugins/provider-usage/settings.test.tsx index 4591fb092a..4f7eec886c 100644 --- a/plugins/provider-usage/settings.test.tsx +++ b/plugins/provider-usage/settings.test.tsx @@ -143,8 +143,8 @@ it("renders loading and a friendly transport error without exposing raw errors", reject(new Error("Unexpected token 'b', bb connect...")); await slot.findByText("Couldn’t load usage."); expect( - slot.getByRole("button", { name: "Retry usage refresh" }), - ).toBeTruthy(); + slot.queryByRole("button", { name: "Retry usage refresh" }), + ).toBeNull(); expect(slot.queryByText(/Unexpected token/)).toBeNull(); }); diff --git a/plugins/provider-usage/settings.tsx b/plugins/provider-usage/settings.tsx index b5cbeac3eb..7ef9848602 100644 --- a/plugins/provider-usage/settings.tsx +++ b/plugins/provider-usage/settings.tsx @@ -452,8 +452,6 @@ export function UsageSettingsContent({ {notice ? ( 0 ? "mb-3" : undefined} /> ) : null} diff --git a/plugins/provider-usage/usage-feedback.tsx b/plugins/provider-usage/usage-feedback.tsx index 8aa486abec..d53708c53a 100644 --- a/plugins/provider-usage/usage-feedback.tsx +++ b/plugins/provider-usage/usage-feedback.tsx @@ -30,13 +30,9 @@ export function offlineUsageMessage( export function UsageFeedback({ message, - retrying = false, - onRetry, className, }: { message: string; - retrying?: boolean; - onRetry?: () => void; className?: string; }) { return ( @@ -48,17 +44,6 @@ export function UsageFeedback({ )} > {message} - {onRetry ? ( - - ) : null}
); } From 48d0282bed710587ec6dc36899fac26cd16f1d5f Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Mon, 14 Sep 2026 11:41:06 -0700 Subject: [PATCH 51/53] Style Provider Usage notices --- plugins/provider-usage/usage-feedback.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/plugins/provider-usage/usage-feedback.tsx b/plugins/provider-usage/usage-feedback.tsx index d53708c53a..98e9e6ec8a 100644 --- a/plugins/provider-usage/usage-feedback.tsx +++ b/plugins/provider-usage/usage-feedback.tsx @@ -1,3 +1,4 @@ +import { Icon } from "@bb/shared-ui/icon"; import { cn } from "@bb/shared-ui/lib/utils"; import type { UsageMachine, UsageProvider } from "./usage-schema.js"; @@ -39,10 +40,15 @@ export function UsageFeedback({
+
); From 22a7723e4141f39e56262e3cd4639e190b1e54bd Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Mon, 14 Sep 2026 11:45:28 -0700 Subject: [PATCH 52/53] Keep usage loading feedback quiet --- plugins/provider-usage/app.tsx | 1 + plugins/provider-usage/settings.tsx | 1 + plugins/provider-usage/usage-feedback.tsx | 17 +++++++++++------ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/plugins/provider-usage/app.tsx b/plugins/provider-usage/app.tsx index 77a0342f5c..ddebbfa3f0 100644 --- a/plugins/provider-usage/app.tsx +++ b/plugins/provider-usage/app.tsx @@ -597,6 +597,7 @@ export function ProviderUsageStatusContent({ {feedback === null ? null : ( )} diff --git a/plugins/provider-usage/settings.tsx b/plugins/provider-usage/settings.tsx index 7ef9848602..28adbc7443 100644 --- a/plugins/provider-usage/settings.tsx +++ b/plugins/provider-usage/settings.tsx @@ -452,6 +452,7 @@ export function UsageSettingsContent({ {notice ? ( 0 ? "mb-3" : undefined} /> ) : null} diff --git a/plugins/provider-usage/usage-feedback.tsx b/plugins/provider-usage/usage-feedback.tsx index 98e9e6ec8a..93e982c9ef 100644 --- a/plugins/provider-usage/usage-feedback.tsx +++ b/plugins/provider-usage/usage-feedback.tsx @@ -31,24 +31,29 @@ export function offlineUsageMessage( export function UsageFeedback({ message, + loading = false, className, }: { message: string; + loading?: boolean; className?: string; }) { return (
-
); From d5a73395701209252be29a9be81297f12c359911 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Mon, 14 Sep 2026 11:49:31 -0700 Subject: [PATCH 53/53] Remove Provider Usage configuration docs --- docs/configuration.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 3cb93a644e..cb5ac49605 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -918,6 +918,12 @@ timelines and large expanded timeline details retain stable height-preserving wrappers while mounting only rows near their active scrollport. Toggle it with `bb settings experiment timelineWindowing `. +The `multiMachinePicker` experiment is off by default. When enabled, projects +with at least three machines use a searchable, target-first environment picker, +and machine-only pickers become searchable when they have more than five +machines. Toggle it with `bb settings experiment multiMachinePicker +`. + ## Thread Timeline Window Timeline pages select conversation groups using user-message anchors. The @@ -1423,10 +1429,3 @@ invalid, or corrupt entries are rebuilt; development and compiler diagnostic modes bypass the cache. The cache has no user configuration and can be removed while no builds are running. See [build performance](build-performance.md) for its identity, portability, and verification contract. - -## Provider Usage - -Provider Usage is enabled by default for newly registered installations; existing -plugin enable/disable choices are preserved. Its plugin settings page contains -provider subscription usage. See [Sidebar footer](#sidebar-footer) for ordering -and hiding its footer shortcut.