diff --git a/.bb/skills/verify-bb/features/plugin-provider-usage.md b/.bb/skills/verify-bb/features/plugin-provider-usage.md index bc500ecf92..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; 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 → 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 @@ -17,6 +17,11 @@ 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-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` +- `plugins/provider-usage/settings.tsx` ## Feature recipes @@ -24,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 | 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 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 @@ -35,6 +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`. +## 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/apps/app/.ladle/config.mjs b/apps/app/.ladle/config.mjs index 4ec7d5759e..e3f3909cda 100644 --- a/apps/app/.ladle/config.mjs +++ b/apps/app/.ladle/config.mjs @@ -43,6 +43,7 @@ export default { "src/**/*.stories.tsx", "../../plugins/workflows/**/*.stories.tsx", "../../plugins/ask-user-question/*.stories.tsx", + "../../plugins/provider-usage/*.stories.tsx", ], defaultStory: "", viteConfig: "./.ladle/vite.config.ts", diff --git a/apps/app/.ladle/ladle.css b/apps/app/.ladle/ladle.css index 2668db92df..33603501e0 100644 --- a/apps/app/.ladle/ladle.css +++ b/apps/app/.ladle/ladle.css @@ -7,6 +7,7 @@ the plugin's CSS separately. */ @source "../../../plugins/automations"; @source "../../../plugins/provider-retry"; +@source "../../../plugins/provider-usage"; .ladle-main { padding: 0; 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() { + + } + /> } /> { 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/app/src/components/settings/UsageLimitsSettingsSection.stories.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.stories.tsx deleted file mode 100644 index 0f27f10164..0000000000 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.stories.tsx +++ /dev/null @@ -1,243 +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, - | "hosts" - | "isError" - | "isFetching" - | "isLoading" - | "onSelectHost" - | "selectedHostId" - > - >; - -function UsagePreview({ - usage, - hosts, - isError = false, - isFetching = false, - isLoading = false, - onSelectHost, - selectedHostId, -}: UsagePreviewProps) { - return ( - - - - ); -} - -function MultipleMachinesPreview() { - const [selectedHostId, setSelectedHostId] = useState(HOSTS[0]?.id ?? null); - - return ( - - ); -} - -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 1d12fddf30..0000000000 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.test.tsx +++ /dev/null @@ -1,339 +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 onSelectHost = vi.fn(); - renderContent({ - usage: {}, - isLoading: false, - isError: false, - isFetching: false, - onRefresh: vi.fn(), - hosts: [primaryHost, remoteHost], - selectedHostId: primaryHost.id, - onSelectHost, - }); - - 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 limits machine" }), - { button: 0 }, - ); - fireEvent.click(screen.getByRole("menuitem", { name: /Build machine/u })); - - expect(onSelectHost).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(), - hosts: [primaryHost], - selectedHostId: primaryHost.id, - onSelectHost: 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 limits machine" }), - ).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 ca6b5df5dc..0000000000 --- a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx +++ /dev/null @@ -1,456 +0,0 @@ -import { useId, useMemo, useState } from "react"; -import type { Host, 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 { MachineStatusDot } from "@/components/machines/MachineStatusDot"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} 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 { 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 { - config: ProviderConfig; - usage: ProviderUsage | undefined; - isLoading: boolean; - isError: boolean; -} - -export interface UsageLimitsSettingsSectionContentProps { - usage: ProviderUsageResponse; - isLoading: boolean; - isError: boolean; - isProviderListLoading?: boolean; - isProviderListError?: boolean; - isFetching: boolean; - onRefresh: () => void; - providerStates?: Readonly>; - providers?: readonly ProviderInfo[]; - hosts?: readonly Host[]; - selectedHostId?: string | null; - onSelectHost?: (hostId: string) => void; -} - -function UsageMachinePicker({ - hosts, - selectedHostId, - onSelectHost, -}: { - hosts: readonly Host[]; - selectedHostId: string | null; - onSelectHost: (hostId: string) => void; -}) { - const selectedHost = - hosts.find((host) => host.id === selectedHostId) ?? hosts[0]; - - return ( - - - - - - {hosts.map((host) => { - const connected = host.status === "connected"; - return ( - onSelectHost(host.id)} - className="flex items-center gap-2" - > - - {host.name} - {host.id === selectedHost?.id ? ( - - ) : null} - - ); - })} - - - ); -} - -function ProviderUsageBlock({ - config, - usage, - isLoading, - isError, -}: ProviderUsageBlockProps) { - const planLabel = usage?.status === "ok" ? usage.planLabel : null; - const accountEmail = 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} -

- ) : null} - {!showsUsageWindows ? ( -
- -
- ) : null} -
-
- {planLabel ? {planLabel} : null} -
- {showsUsageWindows ? ( -
- -
- ) : null} -
- ); -} - -function ProviderUsageBody({ - config, - usage, - isLoading, - isError, -}: ProviderUsageBlockProps) { - if (isError) { - return ( -

- Couldn't load usage right now. Make sure the selected machine is - connected, then reload 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, - isLoading, - isError, - isProviderListLoading = false, - isProviderListError = false, - isFetching, - onRefresh, - providerStates = {}, - providers = [], - hosts = [], - selectedHostId = null, - onSelectHost, -}: UsageLimitsSettingsSectionContentProps) { - const showMachinePicker = hosts.length > 1 && onSelectHost !== undefined; - const providerById = new Map( - providers.map((provider) => [provider.id, provider] as const), - ); - 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." - : "No providers available."; - return ( - - {showMachinePicker ? ( - - ) : null} - - - - - Reload usage data - -
- } - > - - {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 [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 = 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} - /> - ); -} 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/views/SettingsView.stories.tsx b/apps/app/src/views/SettingsView.stories.tsx index cdd1ddf921..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,27 +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); - }} - hosts={usageHosts} - selectedHostId={selectedHostId} - onSelectHost={setSelectedHostId} - /> - ); -} - function ProvidersSettingsStory() { const [generalSettings, setGeneralSettings] = useState(defaultAppSettings); @@ -429,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/cli/src/commands/plugin.ts b/apps/cli/src/commands/plugin.ts index 99d8236088..fc6c563aa5 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,107 @@ export function registerPluginCommands( .description("Manage BB plugins") .enablePositionalOptions(); + const rpc = plugin + .command("rpc") + .description("Inspect discoverable plugin RPC methods"); + rpc + .command("list [plugin-id]") + .option("--method ", "Filter by exact method name") + .option("--json", "Output JSON") + .action( + 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 ") + .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 [method]") + .option("--json", "Output JSON") + .action( + action( + async ( + pluginId: string, + method: string | undefined, + opts: JsonOutputOptions, + ) => { + const methods = await createCliBbSdk( + getUrl(), + ).plugins.experimental_discoverRpc({ pluginId, 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/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/src/services/plugins/plugin-api.ts b/apps/server/src/services/plugins/plugin-api.ts index 1be0f2061c..c1e6f37468 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; @@ -778,12 +780,13 @@ export function createPluginApi(options: { }; const rpc: PluginRpc = { - register(contract, handlers) { + register(contract, handlers, options) { assertLive(); for (const [name, record] of normalizeRpcRegistration( 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/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, 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/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/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/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/docs/api_to_audit.md b/docs/api_to_audit.md index 02cca0c5ee..a108134eaa 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -1,5 +1,13 @@ # 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/configuration.md b/docs/configuration.md index dc9f372324..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 diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts index ef34adf279..f046078b5e 100644 --- a/packages/domain/src/plugin-sdk-version.ts +++ b/packages/domain/src/plugin-sdk-version.ts @@ -1,3 +1,3 @@ -export const PLUGIN_SDK_VERSION = "0.4.89"; +export const PLUGIN_SDK_VERSION = "0.4.90"; export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]); 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/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/package.json b/packages/plugin-sdk/package.json index 4a490ec2aa..e8d03b1997 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@get-bb/plugin-sdk", - "version": "0.4.89", + "version": "0.4.90", "homepage": "https://github.com/get-bb/bb#readme", "bugs": { "url": "https://github.com/get-bb/bb/issues" 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..3a98cebb9a 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, @@ -261,6 +262,9 @@ export interface FakePluginRegistrations { settingsDescriptors: PluginSettingDescriptors; httpRoutes: FakeHttpRouteRecord[]; websocketRoutes: ExperimentalFakeWebSocketRouteRecord[]; + experimental_publishedRpcMethods: Array< + NonNullable> + >; rpcMethods: string[]; services: FakeServiceRecord[]; schedules: FakeScheduleRecord[]; @@ -582,6 +586,7 @@ function jsonRoundTrip(value: unknown, what: string): unknown { } interface FakeRpcRecord { + publication: ReturnType; inputSchema: StandardSchemaV1; outputSchema: StandardSchemaV1; handler: (input: never) => unknown; @@ -835,12 +840,13 @@ function createFakePluginHostInternal( // --- rpc --- const rpcHandlers = new Map(); const rpc: PluginRpc = { - register(contract, handlers) { + register(contract, handlers, registrationOptions) { assertLive(); for (const [name, record] of normalizeRpcRegistration( contract, handlers, rpcHandlers, + registrationOptions, )) { rpcHandlers.set(name, record); } @@ -1568,6 +1574,11 @@ function createFakePluginHostInternal( settingsDescriptors, httpRoutes, websocketRoutes, + get experimental_publishedRpcMethods() { + return [...rpcHandlers.values()].flatMap((record) => + record.publication === null ? [] : [record.publication], + ); + }, get rpcMethods() { return [...rpcHandlers.keys()]; }, diff --git a/packages/sdk/src/areas/plugins.ts b/packages/sdk/src/areas/plugins.ts index 8707f73df0..6802d928a2 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,21 @@ export function createPluginsArea(args: CreateSdkAreaArgs): PluginsArea { jsonInit("POST", body), ); }, + async experimental_discoverRpc(input = {}) { + const query = pluginRpcDiscoveryQuerySchema.parse(input); + 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, + ); + }, 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..d2c9dab18a --- /dev/null +++ b/packages/sdk/test/plugin-rpc-discovery.test.ts @@ -0,0 +1,83 @@ +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(); +}); + +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/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..eca0811304 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 [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/package.json b/plugins/account-pool/package.json index 7d83d2ef2f..86b26f4b41 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.90" }, "bb": { "name": "Account Pooler [Experimental]", 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 bb06e7bb44..3e051e6222 100644 --- a/plugins/account-pool/src/server.test.ts +++ b/plugins/account-pool/src/server.test.ts @@ -1,3 +1,9 @@ +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"; @@ -497,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"); @@ -522,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, @@ -668,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( @@ -5812,3 +5847,73 @@ 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 inventory = usageResourceListSchema.parse( + await fixture.host.harness.behavior.callRpc(usageListMethod, {}), + ); + expect(inventory.label).toBe("Account Pooler"); + expect(inventory.resources).toEqual([ + expect.objectContaining({ + id: fixture.account.id, + providerId: "claude-code", + scope: { kind: "shared" }, + }), + ]); + 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([usageListMethod, usageFetchMethod]); +}); + +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(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(); + await fs.rm(dataDir, { recursive: true, force: true }); + } +}); 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/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(); diff --git a/plugins/account-pool/src/usage-contract.ts b/plugins/account-pool/src/usage-contract.ts new file mode 100644 index 0000000000..1ef6a414af --- /dev/null +++ b/plugins/account-pool/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/account-pool/src/usage-source.test.ts b/plugins/account-pool/src/usage-source.test.ts new file mode 100644 index 0000000000..aae1ecc467 --- /dev/null +++ b/plugins/account-pool/src/usage-source.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { usagePlanLabel, usageWindowLabel } from "./usage-source.js"; + +describe("usage presentation labels", () => { + it("names known windows consistently and retains other durations", () => { + expect(usageWindowLabel(10_080, "primary")).toBe("Weekly limit"); + expect(usageWindowLabel(300, "secondary")).toBe("Five-hour limit"); + expect(usageWindowLabel(1_440, "primary")).toBe("Daily limit"); + expect(usageWindowLabel(2_880, "primary")).toBe("2 day limit"); + expect(usageWindowLabel(null, "Custom limit")).toBe("Custom limit"); + }); + it("retains known Max multipliers without inventing a missing tier", () => { + expect( + usagePlanLabel({ + subscriptionType: "max", + rateLimitTier: "default_claude_max_20x", + }), + ).toBe("Max (20x)"); + expect( + usagePlanLabel({ subscriptionType: "max", rateLimitTier: null }), + ).toBe("Max"); + expect( + usagePlanLabel({ subscriptionType: "pro", rateLimitTier: null }), + ).toBe("Pro"); + expect( + usagePlanLabel({ subscriptionType: null, rateLimitTier: null }), + ).toBeNull(); + }); +}); diff --git a/plugins/account-pool/src/usage-source.ts b/plugins/account-pool/src/usage-source.ts new file mode 100644 index 0000000000..c74d9a3dec --- /dev/null +++ b/plugins/account-pool/src/usage-source.ts @@ -0,0 +1,190 @@ +import { type BbPluginApi } from "@get-bb/plugin-sdk"; +import type { AccountSummary } from "./contracts.js"; +import type { AccountPoolHub } from "./hub.js"; +import { + usageSourceRpcContract, + usageListMethod, + usageFetchMethod, + type UsageResource, +} from "./usage-contract.js"; + +export function usageWindowLabel( + minutes: number | null, + fallback: string, +): string { + if (minutes === 300) return "Five-hour limit"; + if (minutes === 1_440) return "Daily limit"; + if (minutes === 10_080) return "Weekly limit"; + if (minutes === null) return fallback; + return minutes % 1_440 === 0 + ? `${minutes / 1_440} day limit` + : `${minutes / 60} hour limit`; +} + +export function usagePlanLabel( + account: Pick, +): string | null { + const maxMatch = (account.rateLimitTier ?? "").match(/max_(\d+)x/u); + if (maxMatch) return `Max (${maxMatch[1]}x)`; + const subscription = account.subscriptionType; + return subscription + ? subscription.charAt(0).toUpperCase() + subscription.slice(1) + : 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, + { + async [usageListMethod]() { + const { accounts } = await hub.status(); + return { + label: "Account Pooler", + resources: accounts.map((account) => ({ + id: account.id, + accountKey: accountKey(account), + 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({ + kind: + label === "Five-hour limit" + ? "five-hour" + : label === "Daily limit" + ? "daily" + : label.startsWith("Weekly") + ? "weekly" + : "custom", + 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 { + 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:${family}`, + `Weekly · ${family}`, + window.utilization, + window.resetAt, + family, + ); + } + 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), + }; + 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, + accountKey: accountKey(account), + 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 { + accountKey: resource.accountKey, + observedAt: resource.observedAt, + usage: resource.usage, + }; + }, + }, + { + 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/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` diff --git a/plugins/bb-guide/skills/bb-cli/references/plugins.md b/plugins/bb-guide/skills/bb-cli/references/plugins.md index 3a55acdad3..cc618585b4 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 [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-acp/package.json b/plugins/provider-acp/package.json index a16f299175..21d671c4d6 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.90" }, "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/src/usage-contract.ts b/plugins/provider-acp/src/usage-contract.ts new file mode 100644 index 0000000000..1ef6a414af --- /dev/null +++ b/plugins/provider-acp/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-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-acp/src/usage-source.ts b/plugins/provider-acp/src/usage-source.ts new file mode 100644 index 0000000000..890405a4fa --- /dev/null +++ b/plugins/provider-acp/src/usage-source.ts @@ -0,0 +1,163 @@ +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-acp", + ) + ) + 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-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(); + 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 acp provider plugin. Inventory reads metadata only. Independent of display plugins.", + }, + ); +} diff --git a/plugins/provider-claude-code/package.json b/plugins/provider-claude-code/package.json index ddb3cd6e7f..b48d64b60e 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.90" }, "bb": { "name": "Claude Code provider", 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/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-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/package.json b/plugins/provider-codex/package.json index 14dc70e559..2116113163 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.90" }, "bb": { "name": "Codex provider", 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/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 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-codex/src/usage-source.test.ts b/plugins/provider-codex/src/usage-source.test.ts new file mode 100644 index 0000000000..f2af60f80d --- /dev/null +++ b/plugins/provider-codex/src/usage-source.test.ts @@ -0,0 +1,213 @@ +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 () => ({ + codex: { + 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: "codex", + displayName: "Custom", + pluginId: "provider-codex", + }, + { + 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", "codex")).toMatchObject({ + accountKey: null, + usage: { + status: "ok", + plan: null, + planLabel: "Custom subscription", + windows: [{ kind: "custom", label: "Tokens this month" }], + }, + }); + expect(collect).toHaveBeenCalledWith({ + hostId: "online", + providerId: "codex", + }); + await read("online", "codex"); + expect(collect).toHaveBeenCalledTimes(1); + await read("online", "codex", true); + expect(collect).toHaveBeenCalledTimes(2); + 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", "codex")).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: "codex", displayName: "Custom", pluginId: "provider-codex" }, + ], + }, + system: { + usageLimits: async () => ({ + codex: { + 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", "codex"]), + 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 { + codex: { + 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: "codex", displayName: "Custom", pluginId: "provider-codex" }, + ], + }, + system: { usageLimits: collect }, + }, + }); + try { + plugin(bb); + const read = (refresh: boolean) => + harness.behavior.callRpc(usageFetchMethod, { + resourceId: JSON.stringify(["host", "codex"]), + 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-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/README.md b/plugins/provider-usage/README.md index b0074f090b..c398d9ff02 100644 --- a/plugins/provider-usage/README.md +++ b/plugins/provider-usage/README.md @@ -1,8 +1,39 @@ # 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. +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. -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 → 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 +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. + +`bb settings usage --json` and `bb.sdk.system.usageLimits()` remain the +host-local provider-maintenance view; they do not aggregate shared pool accounts. + +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. + +Provider Usage is enabled by default for newly registered installations. Existing +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.stories.tsx b/plugins/provider-usage/app.stories.tsx new file mode 100644 index 0000000000..3ff41928da --- /dev/null +++ b/plugins/provider-usage/app.stories.tsx @@ -0,0 +1,231 @@ +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 { + ProviderUsage, + UsageMachine, + UsageProvider, + UsageSnapshot, +} from "./usage-schema.js"; + +export default { title: "plugins/Provider usage" }; + +const codexLogoUrl = new URL( + "../provider-codex/icons/codex.svg", + import.meta.url, +).href; +const claudeCodeLogoUrl = new URL( + "../provider-claude-code/icons/claude-code.svg", + import.meta.url, +).href; + +type ScenarioName = + | "healthy" + | "emptyPool" + | "loading" + | "offline" + | "authentication" + | "missingProvider" + | "failedRefresh"; + +function futureIso(hours: number): string { + return new Date(Date.now() + hours * 60 * 60_000).toISOString(); +} + +function provider( + id: string, + providerId: string, + usage: ProviderUsage | null, +): UsageProvider { + const isClaude = providerId === "claude-code"; + const displayName = isClaude ? "Claude Code" : "Codex"; + return { + id, + providerId, + accountLabel: `${id}@example.com`, + displayName, + logoUrl: isClaude ? claudeCodeLogoUrl : codexLogoUrl, + icon: null, + strings: { + iconTint: isClaude ? { light: "#D97757", dark: "#E38A6E" } : null, + }, + signInHint: "Sign in to this account in the provider’s settings.", + expiredHint: "Sign in again in the provider’s settings.", + usage, + }; +} + +function measured( + email: string, + usedPercent: number, + planLabel: string, +): ProviderUsage { + return { + status: "ok", + accountEmail: email, + planLabel, + windows: [ + { + label: "Weekly limit", + usedPercent, + resetsAt: futureIso(usedPercent > 90 ? 18 : 83), + cost: null, + }, + ], + }; +} + +function machine( + id: string, + displayName: string, + providers: UsageProvider[], + overrides: Partial = {}, +): UsageMachine { + return { + id, + displayName, + status: "connected", + providers, + error: null, + ...overrides, + }; +} + +const healthyPool = machine("source:account-pool", "Account Pooler", [ + provider("alex-codex", "codex", measured("alex@example.com", 28, "Pro")), + provider("sam-codex", "codex", measured("sam@example.com", 86, "Team")), + provider( + "team-claude", + "claude-code", + measured("team@example.com", 97, "Max (20x)"), + ), +]); +const healthyMachine = machine("host-m4", "Michael-M4", [ + provider("local-codex", "codex", measured("local@example.com", 17, "Pro")), +]); + +const scenarios: Record, UsageSnapshot> = { + healthy: { machines: [healthyMachine, healthyPool] }, + emptyPool: { + machines: [ + healthyMachine, + machine("source:account-pool", "Account Pooler", []), + ], + }, + offline: { + machines: [ + machine("host-studio", "Studio", [healthyMachine.providers[0]!], { + status: "disconnected", + }), + ], + }, + authentication: { + machines: [ + machine("source:account-pool", "Account Pooler", [ + provider("signed-out", "codex", { status: "unauthenticated" }), + provider("expired", "codex", { status: "expired" }), + ]), + ], + }, + missingProvider: { + machines: [ + machine("host-m4", "Michael-M4", [ + provider("missing", "codex", { status: "not_installed" }), + ]), + ], + }, + failedRefresh: { + machines: [ + machine("source:account-pool", "Account Pooler", [ + provider("cached", "codex", measured("cached@example.com", 64, "Pro")), + ]), + ], + }, +}; + +function storySnapshot(name: ScenarioName): UsageStoreSnapshot { + if (name === "loading") { + return { data: null, error: null, isRefreshing: true }; + } + return { + data: scenarios[name], + error: name === "failedRefresh" ? "Couldn’t refresh usage." : null, + isRefreshing: false, + }; +} + +function SettingsPreview({ scenario }: { scenario: ScenarioName }) { + const [selectedId, setSelectedId] = useState(null); + const machines = scenario === "loading" ? [] : scenarios[scenario].machines; + return ( +
+ {}} + /> +
+ ); +} + +function FooterPreview({ scenario }: { scenario: ScenarioName }) { + return ( +
+ {}} + snapshot={storySnapshot(scenario)} + threadMachineId={null} + refreshEnabled={false} + /> +
+ ); +} + +const descriptions: Record = { + healthy: "Multiple pooled accounts with provider grouping and quota badges.", + emptyPool: "Account Pooler is enabled and selectable but has no accounts.", + loading: "The initial usage request has not completed.", + offline: "The selected persistent machine is currently disconnected.", + authentication: "Signed-out and expired accounts remain distinct.", + missingProvider: "The selected machine does not have the provider installed.", + failedRefresh: + "The latest refresh failed while cached measurements remain visible.", +}; + +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 }) => ( + + + + ))} + + ); +} + +export function Disclosure() { + return ( + + {storyRows.map(({ label, scenario }) => ( + + + + ))} + + ); +} diff --git a/plugins/provider-usage/app.test.tsx b/plugins/provider-usage/app.test.tsx index 85b6d95ced..a33094f10a 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,42 @@ 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`, + icon: null, + strings: { iconTint: null }, + signInHint: "Sign in.", + expiredHint: "Sign in again.", + usage: { + status: "ok", + accountEmail: email, + planLabel: "Pro", + windows: [ + { + label: "Weekly limit", + usedPercent: usedPercent, + resetsAt: + email === "personal@example.com" + ? new Date(Date.now() + 51 * 60 * 60_000).toISOString() + : null, + cost: null, + }, + ], + }, + })); + const fetchMock = vi.fn( async (_input: RequestInfo | URL, _init?: RequestInit) => new Response( JSON.stringify({ @@ -71,6 +101,8 @@ 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", @@ -96,6 +128,8 @@ describe("provider usage footer disclosure", () => { }, { id: "codex", + providerId: "codex", + accountLabel: null, displayName: "Codex", logoUrl: "/api/v1/system/providers/codex/logo?h=codex", icon: null, @@ -126,6 +160,8 @@ 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, @@ -148,6 +184,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 +204,34 @@ 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", }); - }); + if (item?.kind !== "disclosure") throw new Error("missing disclosure"); - 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, - }), - }), - ), - ); - - 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, + providerId: null, }), }), - ); - } 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 +239,61 @@ describe("provider usage footer disclosure", () => { { context: { threadId: "thread-active" }, sidebarThreads: { - threads: [threadOnMachine("host-intel", "Intel")], + threads: [threadOnMachine("host-m5", "M5")], }, }, ); expect( - await slot.findByText( + slot.getByRole("button", { name: "Usage machine: Account Pooler" }), + ).toBeTruthy(); + expect( + slot.getByRole("heading", { name: "personal@example.com" }), + ).toBeTruthy(); + fireEvent.pointerDown( + slot.getByRole("button", { name: "Usage machine: Account Pooler" }), + { button: 0 }, + ); + fireEvent.click(slot.getByRole("menuitemradio", { name: "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%")).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%")).toBeTruthy(); + + fireEvent.click(codexTab); + expect(slot.getByRole("heading", { name: "Codex" })).toBeTruthy(); + expect(slot.getByText("codex@example.com")).toBeTruthy(); + expect(slot.getByText("37%")).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( + slot.getByText( "Intel is offline. Usage will refresh when it reconnects.", ), ).toBeTruthy(); @@ -253,57 +316,187 @@ describe("provider usage footer disclosure", () => { force: true, machineIds: ["host-intel"], maxAgeMs: 0, + providerId: null, }), }), ); - }); - 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, + providerId: null, + }), + }), ); - 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=""]')); + now.mockRestore(); + 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.getByText("82% used")).toBeTruthy(); + 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", + }); + fireEvent.click(windowButton); + expect(slot.getByText("Reset time not reported.")).toBeTruthy(); + expect(slot.getByText("82%")).toBeTruthy(); + 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 usage. Showing the last available 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: "Reload provider usage" }), + ); + await waitFor(() => + expect( + slot.queryByText( + "Couldn’t refresh usage. Showing the last available update.", + ), + ).toBeNull(), + ); + } + expect(diagnostics).toHaveBeenCalledTimes(3); + await mounted.lifecycle.dispose(); + }, 15_000); +}); - 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"); +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 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" + ? { 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, + 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, + }; + 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.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 41aad7363a..ddebbfa3f0 100644 --- a/plugins/provider-usage/app.tsx +++ b/plugins/provider-usage/app.tsx @@ -32,19 +32,27 @@ import { LIST_HOVER_TRANSITION } from "@bb/shared-ui/motion"; import { OPTION_BASE_CLASS_NAME, OPTION_INTERACTIVE_CLASS_NAME, - OPTION_MENU_CONTENT_CLASS_NAME, - OPTION_TRIGGER_CONTENT_CLASS_NAME, } from "@bb/shared-ui/option-display"; import { providerUsageTone, + selectUsageMachine, usageRpcSuccessSchema, type UsageMachine, type UsageProvider, 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"; -interface UsageStoreSnapshot { +export interface UsageStoreSnapshot { data: UsageSnapshot | null; error: string | null; isRefreshing: boolean; @@ -90,11 +98,13 @@ function refreshUsage({ force, machineIds, maxAgeMs, + providerId = null, signal, }: { force: boolean; machineIds: string[] | null; maxAgeMs: number; + providerId?: string | null; signal?: AbortSignal; }): Promise { activeRefreshCount += 1; @@ -106,13 +116,18 @@ function refreshUsage({ { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ force, machineIds, maxAgeMs }), - signal, + body: JSON.stringify({ force, machineIds, maxAgeMs, providerId }), + signal: + signal === undefined + ? AbortSignal.timeout(60_000) + : AbortSignal.any([signal, AbortSignal.timeout(60_000)]), }, ); + 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.", ); @@ -126,9 +141,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; @@ -139,36 +155,74 @@ 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 }: { window: UsageWindowValue }) { + 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" : formatUsdCents(window.cost.usedUsdCents, true) + " / " + formatUsdCents(window.cost.limitUsdCents, false); + const label = window.label + .replace(/^Five-hour limit$|^5 hours$/u, "5h") + .replace(/^Weekly limit$|^Weekly/u, "7d") + .replace(/^Daily limit$/u, "1d"); return ( -
-
- {window.label} - - {value} +
-
-
-
- {reset === null ? null : ( -

{reset}

- )} -
+ + + + + {Math.round(window.usedPercent)}% + + + + {showReset ? ( + + {reset ?? "Reset time not reported."} + {window.cost === null ? "" : ` · ${value}`} + + ) : null} + ); } @@ -184,7 +238,7 @@ function ProviderUsageBody({ provider }: { provider: UsageProvider }) { No usage limits reported for this plan.

) : ( -
+
{usage.windows.map((window) => ( ))} @@ -235,20 +289,18 @@ function MachineSelector({ OPTION_BASE_CLASS_NAME, OPTION_INTERACTIVE_CLASS_NAME, LIST_HOVER_TRANSITION, - "h-7 max-w-32 px-1 text-sidebar-foreground hover:bg-sidebar-accent", + "h-7 shrink overflow-hidden px-1 text-sidebar-foreground hover:bg-sidebar-accent", )} > - - - {activeMachine?.displayName ?? "No machines"} - + + {activeMachine?.displayName ?? "Usage"} {machines.map((machine) => { const isActive = machine.id === activeMachine?.id; @@ -259,27 +311,14 @@ 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" > - -