diff --git a/CHANGELOG.md b/CHANGELOG.md index d10a28a..7272857 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- Make ZCode visible in the **Provider usage** panel: the panel federates over + the `provider-usage.v1` RPC contract that only builtin provider plugins + implemented, so the quota windows shipped in the previous change were + unreachable from it. The plugin now registers a discoverable usage source + (host-scoped resources, panel window grammar, 60 s measurement cache with + refresh bypass) whose readings flow through the daemon's `provider/usage` + path. Disconnected hosts and failed collections report error usage states + instead of blocking the panel. - Show real Coding Plan quota usage in bb's usage panel: `provider/usage` now reports 5-hour, weekly, and MCP windows with used-percent and reset times from the Z.ai/bigmodel quota-remaining endpoint (same account service and diff --git a/knowledge/contracts/quota-usage-protocol.md b/knowledge/contracts/quota-usage-protocol.md index ec43589..026ecdd 100644 --- a/knowledge/contracts/quota-usage-protocol.md +++ b/knowledge/contracts/quota-usage-protocol.md @@ -72,13 +72,30 @@ own credential source. live. Limits without a finite 0..100 `percentage` are omitted, not invented (the same honesty rule as stage-2 usage); `resetsAt` drops to `null` when `nextResetTime` is missing or stale. -2. `provider.rateLimits` thread delta (push; still unused): a +2. **"Provider usage" panel federation** (the panel never calls + `provider/usage` itself): bb's builtin provider-usage plugin discovers + usage sources server-side via `experimental_discoverRpc` of + `provider-usage.v1.listResources` and reads each with + `provider-usage.v1.getResource`. Only plugins that register that contract + with `experimental_discoverable: true` appear; the builtin provider + plugins (codex, claude-code, acp) implement it, and `providers.list({ +capability: "usage" })` (fed by the manifest's `maintenance.usage`) is + display metadata, not a data source. Resources are + `{accountKey: null, id: JSON([hostId, providerId]), providerId, label, +scope: {kind: "host", hostId, hostName}}`; windows carry the panel grammar + (`kind: five-hour|daily|weekly|custom`, stable `id`, `model`, `cost`); + measurements flow server → `system.usageLimits({hostId, providerId, +refresh})` → daemon `provider/usage` → the bridge quota fetch. The panel's + plugin caches per resource and invalidates on `thread.idle`/`thread.failed`. + Implemented in `src/usageSource.ts` (2026-11-09) after the first quota PR + shipped windows that the panel could not see. +3. `provider.rateLimits` thread delta (push; still unused): a `subscription-window`-kind state with per-window `resetsAtMs` and `allowed/warning/blocked` status, plus the `rateLimited` provider-recovery kind. Upstream CLI classifies HTTP 429 as retryable rate-limited with `retryAfterMs` (`failure-classifier.ts`); a quota-exhausted turn failure is the natural trigger to push this state. -3. Per-turn `usage` and `contextWindow` deltas are already implemented in +4. Per-turn `usage` and `contextWindow` deltas are already implemented in `src/usage.ts`; the contextWindow delta additionally accepts a rich `snapshot` (categories, `autoCompactAtTokens`) the plugin does not send yet. @@ -97,3 +114,10 @@ own credential source. cache keyed by the exact request payload with a 60 s TTL (`usageCacheTtlMs` test seam) so panel refreshes neither respawn the CLI probe nor re-query the account service. +- `src/usageSource.ts` registers the panel-facing `provider-usage.v1` source + (contract in `src/usageContract.ts`, wired from `server.ts`): host-scoped + resources for the zcode provider, window-grammar mapping by label, a 60 s + observedAt cache with `refresh` bypass, and error usage states for + disconnected hosts and failed collections. `src/usageSource.ts` takes + injected deps; `tests/usage-source.test.ts` covers listing, mapping, + caching, and degradation. diff --git a/knowledge/log.md b/knowledge/log.md index 02b0c13..7ba2a55 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,20 @@ ## 2026-11-09 (quota usage research and implementation) +- **Found** why the quota windows were invisible in the Provider usage panel + after the first merge: the panel never calls `provider/usage`. It federates + over the `provider-usage.v1` RPC contract that only builtin provider + plugins (codex, claude-code, acp) implemented server-side; a provider + bridge answering `provider/usage` alone is not a panel source, and + `maintenance.usage`/`providers.list({capability:"usage"})` is display + metadata only. The production worker was verified end-to-end first (spawned + exactly as the daemon does; it returned GLM Coding Max with all three + windows), isolating the gap to the missing federation registration. + Implemented the source in `src/usageSource.ts` (discoverable + `provider-usage.v1.listResources`/`getResource`, host-scoped resources, + panel window grammar, 60 s cache with refresh bypass, error states); + federation semantics recorded in + [quota usage protocol](contracts/quota-usage-protocol.md). - **Researched** how to show real ZCode usage in bb. The per-turn `usage` and `contextWindow` deltas already flow; the gaps are `provider/usage` windows (answered with `windows: []` since v0.7.0) and the untouched diff --git a/server.ts b/server.ts index 3cb5e48..d982c82 100644 --- a/server.ts +++ b/server.ts @@ -9,6 +9,7 @@ import { runRowView, } from "./src/runs.js"; import { workflowRunsRpcContract } from "./src/runsContract.js"; +import { registerPluginUsageSource } from "./src/usageSource.js"; export default async function plugin(bb: BbPluginApi) { const settings = bb.settings.define({ @@ -287,4 +288,11 @@ export default async function plugin(bb: BbPluginApi) { "ZCode dynamic-workflow run history for this workspace (sessionless cold read).", }, ); + + // The "Provider usage" panel federates over the provider-usage.v1 RPC + // contract; without a discoverable source it lists nothing even though the + // bridge answers provider/usage. Resources are the zcode provider on every + // connected host; measurements flow through the daemon's provider/usage + // path (the bridge's quota fetch) via system.usageLimits. + registerPluginUsageSource(bb); } diff --git a/src/usageContract.ts b/src/usageContract.ts new file mode 100644 index 0000000..ec86bca --- /dev/null +++ b/src/usageContract.ts @@ -0,0 +1,132 @@ +import { z } from "zod"; + +/** + * The wire contract of bb's federated "Provider usage" panel. The panel + * discovers usage sources through experimental RPC discovery of + * `provider-usage.v1.listResources` and reads each resource with + * `provider-usage.v1.getResource`; builtin provider plugins (codex, + * claude-code, acp) implement the same two methods server-side. This is not + * part of the plugin SDK - matching the builtin wire is the integration. + * + * Pure zod: nothing here touches node-only APIs. + */ + +const accountKeySchema = z + .string() + .min(1) + .nullable() + .describe( + "Provider-issued quota account identity. Null means unknown; unknown accounts must not be merged.", + ); + +const usageWindowSchema = z.object({ + kind: z.enum(["five-hour", "daily", "weekly", "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 usageStateSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("ok"), + accountEmail: z.string().nullable(), + planLabel: z.string().nullable(), + windows: z.array(usageWindowSchema), + }), + z.object({ + status: z.literal("not_installed"), + accountEmail: z.string().nullable(), + planLabel: z.string().nullable(), + }), + z.object({ + status: z.literal("unauthenticated"), + accountEmail: z.string().nullable(), + planLabel: z.string().nullable(), + }), + z.object({ + status: z.literal("expired"), + accountEmail: z.string().nullable(), + planLabel: z.string().nullable(), + }), + z.object({ + status: z.literal("error"), + accountEmail: z.string().nullable(), + planLabel: z.string().nullable(), + message: z.string(), + }), +]); + +const usageResourceSchema = z.object({ + accountKey: accountKeySchema, + 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 usageSourceRpcContract = { + "provider-usage.v1.listResources": { + input: z.object({}).strict(), + output: 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), + }), + }, + "provider-usage.v1.getResource": { + input: 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.", + ), + }) + .strict(), + output: z.object({ + accountKey: accountKeySchema, + observedAt: z + .number() + .int() + .nonnegative() + .nullable() + .describe( + "Last successful measurement time in epoch milliseconds; null if never observed.", + ), + usage: usageStateSchema, + }), + }, +} as const; diff --git a/src/usageSource.ts b/src/usageSource.ts new file mode 100644 index 0000000..caa5237 --- /dev/null +++ b/src/usageSource.ts @@ -0,0 +1,245 @@ +import type { z } from "zod"; +import type { + BbPluginApi, + ExperimentalPluginRpcHandlersWithContext, +} from "@get-bb/plugin-sdk"; +import { usageSourceRpcContract } from "./usageContract.js"; + +type UsageResult = z.infer< + (typeof usageSourceRpcContract)["provider-usage.v1.getResource"]["output"] +>; + +/** The sdk surface the usage source needs; injectable for tests. */ +export interface UsageSourceDeps { + hosts: { + list(): Promise< + Array<{ + id: string; + name: string; + type: string; + status: string; + }> + >; + }; + providers: { + list(args: { + hostId: string; + capability: "usage"; + }): Promise>; + }; + usageLimits(args: { + hostId: string; + providerId: string; + refresh: boolean; + }): Promise< + Record< + string, + | { + status: "ok"; + accountEmail: string | null; + planLabel: string | null; + windows: Array<{ + label: string; + usedPercent: number; + resetsAt: string | null; + cost?: { usedUsdCents: number; limitUsdCents: number } | null; + }>; + } + | { + status: "not_installed" | "unauthenticated" | "expired" | "error"; + accountEmail?: unknown; + planLabel?: unknown; + message?: string; + } + > + >; + now(): number; +} + +export const USAGE_SOURCE_CACHE_MS = 60_000; + +/** + * The panel-facing side of the usage feature: a discoverable + * provider-usage.v1 source listing the zcode provider on every connected + * host, with measurements flowing through the daemon's provider/usage path + * (the bridge's quota fetch) via system.usageLimits. The panel federates + * only over this contract - a bridge that answers provider/usage alone is + * invisible to it. + */ +export function registerUsageSource( + register: ( + handlers: ExperimentalPluginRpcHandlersWithContext< + typeof usageSourceRpcContract + >, + options: { + experimental_discoverable: boolean; + experimental_description: string; + }, + ) => void, + deps: UsageSourceDeps, + cacheMs = USAGE_SOURCE_CACHE_MS, +) { + const cache = new Map(); + // The panel sorts windows by its own grammar; ours map by label. + const windowKind = (label: string) => + label === "5-hour window" + ? ("five-hour" as const) + : label === "Weekly window" + ? ("weekly" as const) + : ("custom" as const); + + const resourceId = (hostId: string, providerId: string) => + JSON.stringify([hostId, providerId]); + + const unavailable = (id: string, message: string): UsageResult => ({ + accountKey: null, + observedAt: cache.get(id)?.observedAt ?? null, + usage: { + status: "error", + accountEmail: null, + planLabel: null, + message, + }, + }); + + register( + { + "provider-usage.v1.listResources": async () => { + const hosts = await deps.hosts.list(); + const resources: Array<{ + accountKey: null; + id: string; + providerId: string; + label: string; + scope: { kind: "host"; hostId: string; hostName: string }; + }> = []; + for (const host of hosts.filter((h) => h.type !== "ephemeral")) { + const providers = await deps.providers + .list({ hostId: host.id, capability: "usage" }) + .catch(() => []); + for (const provider of providers) { + if (provider.pluginId !== "zcode" || provider.id !== "zcode") + continue; + resources.push({ + accountKey: null, + id: resourceId(host.id, provider.id), + providerId: provider.id, + label: provider.displayName, + scope: { + kind: "host", + hostId: host.id, + hostName: host.name, + }, + }); + } + } + const listed = new Set(resources.map((r) => r.id)); + for (const key of cache.keys()) if (!listed.has(key)) cache.delete(key); + return { resources }; + }, + "provider-usage.v1.getResource": async ({ resourceId: id, refresh }) => { + let hostId = ""; + let providerId = ""; + try { + [hostId, providerId] = JSON.parse(id); + } catch { + hostId = ""; + } + const host = (await deps.hosts.list().catch(() => [])).find( + (h) => h.id === hostId, + ); + if (!host || host.status !== "connected") + return unavailable(id, "Machine is disconnected."); + const cached = cache.get(id); + if ( + !refresh && + cached?.usage.status === "ok" && + cached.observedAt !== null && + deps.now() - cached.observedAt < cacheMs + ) + return cached; + try { + const limits = await deps.usageLimits({ + hostId, + providerId, + refresh, + }); + const usage = limits[providerId]; + if (!usage) throw new Error("Provider returned no usage."); + if (usage.status !== "ok") { + const email = + typeof usage.accountEmail === "string" && + usage.accountEmail !== "" + ? usage.accountEmail + : null; + const label = + typeof usage.planLabel === "string" && usage.planLabel !== "" + ? usage.planLabel + : null; + const state: UsageResult["usage"] = + usage.status === "error" + ? { + status: "error", + accountEmail: email, + planLabel: label, + message: usage.message ?? "Usage could not be collected.", + } + : { + status: usage.status, + accountEmail: email, + planLabel: label, + }; + return { + accountKey: null, + observedAt: cached?.observedAt ?? null, + usage: state, + }; + } + const entry: UsageResult = { + accountKey: null, + observedAt: deps.now(), + usage: { + ...usage, + windows: usage.windows.map((w, i) => ({ + kind: windowKind(w.label), + id: `${i}:${w.label}`, + label: w.label, + usedPercent: w.usedPercent, + resetsAt: w.resetsAt, + model: null, + cost: w.cost ?? null, + })), + }, + }; + cache.set(id, entry); + return entry; + } catch { + return unavailable( + id, + "Usage could not be collected from this machine.", + ); + } + }, + }, + { + experimental_discoverable: true, + experimental_description: + "Host-local usage owned by the ZCode provider plugin (Coding Plan quota windows). Inventory reads metadata only. Independent of display plugins.", + }, + ); +} + +/** Wire the usage source onto the plugin's rpc area with real sdk deps. */ +export function registerPluginUsageSource(bb: BbPluginApi) { + registerUsageSource( + (handlers, options) => { + bb.rpc.register(usageSourceRpcContract, handlers, options); + }, + { + hosts: bb.sdk.hosts, + providers: bb.sdk.providers, + usageLimits: (args) => bb.sdk.system.usageLimits(args), + now: () => Date.now(), + }, + ); +} diff --git a/tests/usage-source.test.ts b/tests/usage-source.test.ts new file mode 100644 index 0000000..07899ba --- /dev/null +++ b/tests/usage-source.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ExperimentalPluginRpcHandlersWithContext } from "@get-bb/plugin-sdk"; +import { + registerUsageSource, + type UsageSourceDeps, +} from "../src/usageSource.js"; +import { usageSourceRpcContract } from "../src/usageContract.js"; + +type Handlers = ExperimentalPluginRpcHandlersWithContext< + typeof usageSourceRpcContract +>; + +function fixture(overrides: Partial = {}) { + let now = 1_000_000; + const usageLimits = vi.fn( + async () => + ({ + zcode: { + status: "ok", + accountEmail: null, + planLabel: "GLM Coding Max", + windows: [ + { + label: "5-hour window", + usedPercent: 8, + resetsAt: "2026-11-09T19:00:37.483Z", + }, + { label: "Weekly window", usedPercent: 23, resetsAt: null }, + { + label: "MCP quota", + usedPercent: 1, + resetsAt: "2026-12-01T07:48:13.981Z", + }, + ], + }, + }) as Awaited>, + ); + const deps: UsageSourceDeps = { + hosts: { + list: async () => [ + { + id: "mach", + name: "Deepol's Mac", + type: "persistent", + status: "connected", + }, + { + id: "tmp", + name: "Ephemeral", + type: "ephemeral", + status: "connected", + }, + { + id: "off", + name: "Offline", + type: "persistent", + status: "disconnected", + }, + ], + }, + providers: { + list: async ({ hostId }) => + hostId === "mach" + ? [ + { + id: "zcode", + pluginId: "zcode", + displayName: "ZCode", + }, + { id: "codex", pluginId: "provider-codex", displayName: "Codex" }, + ] + : [], + }, + usageLimits, + now: () => now, + ...overrides, + }; + let handlers!: Handlers; + let options!: { + experimental_discoverable: boolean; + experimental_description: string; + }; + registerUsageSource((h, o) => { + handlers = h; + options = o; + }, deps); + return { + handlers, + options, + deps, + usageLimits, + advance: () => (now += 60_000), + }; +} + +describe("provider-usage.v1 source", () => { + it("registers a discoverable source and lists only the zcode provider on persistent hosts", async () => { + const f = fixture(); + expect(f.options.experimental_discoverable).toBe(true); + const listed = await f.handlers["provider-usage.v1.listResources"]( + {}, + {} as Parameters[1], + ); + expect(listed).toEqual({ + resources: [ + { + accountKey: null, + id: JSON.stringify(["mach", "zcode"]), + providerId: "zcode", + label: "ZCode", + scope: { + kind: "host", + hostId: "mach", + hostName: "Deepol's Mac", + }, + }, + ], + }); + }); + + it("maps quota windows into the panel grammar and caches a fresh measurement", async () => { + const f = fixture(); + const id = JSON.stringify(["mach", "zcode"]); + const first = await f.handlers["provider-usage.v1.getResource"]( + { resourceId: id, refresh: false }, + {} as Parameters[1], + ); + expect(first).toEqual({ + accountKey: null, + observedAt: 1_000_000, + usage: { + status: "ok", + accountEmail: null, + planLabel: "GLM Coding Max", + windows: [ + { + kind: "five-hour", + id: "0:5-hour window", + label: "5-hour window", + usedPercent: 8, + resetsAt: "2026-11-09T19:00:37.483Z", + model: null, + cost: null, + }, + { + kind: "weekly", + id: "1:Weekly window", + label: "Weekly window", + usedPercent: 23, + resetsAt: null, + model: null, + cost: null, + }, + { + kind: "custom", + id: "2:MCP quota", + label: "MCP quota", + usedPercent: 1, + resetsAt: "2026-12-01T07:48:13.981Z", + model: null, + cost: null, + }, + ], + }, + }); + expect( + ( + usageSourceRpcContract["provider-usage.v1.getResource"].output as { + parse: (v: unknown) => unknown; + } + ).parse(first), + ).toEqual(first); + // Cached within the TTL: no second collection. + await f.handlers["provider-usage.v1.getResource"]( + { resourceId: id, refresh: false }, + {} as Parameters[1], + ); + expect(f.usageLimits).toHaveBeenCalledTimes(1); + // refresh forces a fresh collection. + await f.handlers["provider-usage.v1.getResource"]( + { resourceId: id, refresh: true }, + {} as Parameters[1], + ); + expect(f.usageLimits).toHaveBeenCalledTimes(2); + }); + + it("reports a disconnected host and a failed collection as error usage", async () => { + const f = fixture(); + const ctx = {} as Parameters[1]; + const disconnected = await f.handlers["provider-usage.v1.getResource"]( + { resourceId: JSON.stringify(["off", "zcode"]), refresh: true }, + ctx, + ); + expect(record(disconnected).usage).toMatchObject({ + status: "error", + message: "Machine is disconnected.", + }); + f.usageLimits.mockImplementation(async () => { + throw new Error("private-network-canary"); + }); + const failed = await f.handlers["provider-usage.v1.getResource"]( + { resourceId: JSON.stringify(["mach", "zcode"]), refresh: true }, + ctx, + ); + expect(record(failed).usage).toMatchObject({ + status: "error", + message: "Usage could not be collected from this machine.", + }); + expect(JSON.stringify(failed)).not.toContain("canary"); + }); + + it("passes non-ok usage states through without inventing windows", async () => { + const f = fixture({ + usageLimits: vi.fn( + async () => + ({ zcode: { status: "unauthenticated" } }) as Awaited< + ReturnType + >, + ), + }); + const reply = await f.handlers["provider-usage.v1.getResource"]( + { resourceId: JSON.stringify(["mach", "zcode"]), refresh: true }, + {} as Parameters[1], + ); + expect(reply).toEqual({ + accountKey: null, + observedAt: null, + usage: { status: "unauthenticated", accountEmail: null, planLabel: null }, + }); + }); +}); + +function record(value: unknown) { + return value as Record; +}