From bb83d180d5bfa03ec3dace038445ad260bea792d Mon Sep 17 00:00:00 2001 From: dpolishuk <466424+dpolishuk@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:59:26 +0300 Subject: [PATCH] fix(server): register a provider-usage.v1 source so the panel sees ZCode The Provider usage panel never calls provider/usage: it federates over the provider-usage.v1 RPC contract that only builtin provider plugins implement, so the quota windows shipped earlier were unreachable from it (the panel listed no ZCode entry at all). The production worker was verified first - spawned exactly as the daemon spawns it, it returned GLM Coding Max with all three live windows - isolating the gap to the missing federation. src/usageSource.ts registers a discoverable source: host-scoped resources for the zcode provider on connected non-ephemeral hosts, window-grammar mapping by label (five-hour/weekly/custom, stable ids), a 60 s observedAt cache with refresh bypass, and error usage states for disconnected hosts and failed collections. Measurements flow through the daemon's provider/usage path via system.usageLimits, reusing the bridge quota fetch and its cache. src/usageContract.ts pins the wire; server.ts wires it with real sdk deps; tests cover listing, mapping, caching, and degradation. Wire semantics recorded in knowledge/contracts/quota-usage-protocol.md. --- CHANGELOG.md | 8 + knowledge/contracts/quota-usage-protocol.md | 28 ++- knowledge/log.md | 14 ++ server.ts | 8 + src/usageContract.ts | 132 +++++++++++ src/usageSource.ts | 245 ++++++++++++++++++++ tests/usage-source.test.ts | 235 +++++++++++++++++++ 7 files changed, 668 insertions(+), 2 deletions(-) create mode 100644 src/usageContract.ts create mode 100644 src/usageSource.ts create mode 100644 tests/usage-source.test.ts 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; +}