diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e88bff..d10a28a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- 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 + short-lived project token the bridge already uses). Results are cached for + 60 s so panel refreshes do not respawn a probe; quota failures degrade to + the previous plan-label-only answer instead of inventing bars. - Support the CLI 0.17.1 automation stop-condition flow: the automation host answers the new `automation/pause` reverse RPC, scheduled runs keep the `CronPauseSelf` tool available (pausing their own schedule parks it as diff --git a/docs/configuration.md b/docs/configuration.md index 4bc4b37..b6946ac 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -153,8 +153,10 @@ The fallback **ZCode models unavailable** entry appears while the catalog is unavailable. It does not prove that a provider is authenticated. Likewise, health checks verify protocol/catalog availability, not model connectivity. Run `/zcode-check` in an idle session to perform one real connectivity -request; the provider usage panel reports account status and the active -Coding Plan label without invented usage numbers. +request; the provider usage panel reports account status, the active Coding +Plan label, and its live quota windows (5-hour, weekly, MCP) with reset times +from the plan's quota service, cached for a minute. Windows the service does +not report numbers for stay hidden rather than guessed. ## Individual Coding Plan diff --git a/knowledge/contracts/quota-usage-protocol.md b/knowledge/contracts/quota-usage-protocol.md new file mode 100644 index 0000000..ec43589 --- /dev/null +++ b/knowledge/contracts/quota-usage-protocol.md @@ -0,0 +1,99 @@ +--- +type: contract +title: Z.ai Coding Plan quota API and bb usage surfaces +description: Wire contract of the Z.ai/bigmodel quota-remaining endpoint (5-hour/weekly/MCP windows) and its mapping onto bb's provider/usage windows and provider.rateLimits deltas; live-verified on the zai family, bigmodel fixture-only. +status: stable +tags: [contract, usage, quota, coding-plan] +sources: + - title: docs/coding-plan-quota-remaining-api.md at zai-org/ZCode a60ba6a + resource: https://github.com/zai-org/ZCode/blob/a60ba6a/docs/coding-plan-quota-remaining-api.md + - title: packages/services/src/usage-stats/providers/bigmodelUsageQuotaProvider.ts at a60ba6a + resource: https://github.com/zai-org/ZCode/blob/a60ba6a/packages/services/src/usage-stats/providers/bigmodelUsageQuotaProvider.ts + - title: packages/shared/src/usage-quota.ts and zcodeEndpoint.ts at a60ba6a + resource: https://github.com/zai-org/ZCode/blob/a60ba6a/packages/shared/src/usage-quota.ts + - title: SDK 0.6.49 providerUsageWindowSchema / provider.rateLimits delta (bundled-types) + resource: ../../node_modules/@get-bb/plugin-sdk/bundled-types/bb-plugin-sdk-provider-bridge.d.ts + - title: Quota fetch and window mapping implementation + resource: ../../src/coding-plan.ts + - title: provider/usage wiring and cache + resource: ../../src/bridge.ts +generated: + by: zcode-agent/GLM-5.3 + at: 2026-11-09T00:00:00Z +verified: + - by: zcode-agent/GLM-5.3 (live probe through CodingPlanHost, GLM Coding Max) + at: 2026-11-09T13:10:00Z +--- + +# Z.ai Coding Plan quota API and bb usage surfaces + +Researched 2026-11-09 against upstream sync `a60ba6a` (CLI 0.17.1) and +implemented the same day. The zai family was exercised live through +`CodingPlanHost.usageWindows()` (GLM Coding Max: 5-hour/weekly/MCP windows +with real reset timestamps); the bigmodel family has fixture coverage only. + +## Quota endpoint + +```http +GET {origin}/api/monitor/usage/quota/limit +authorization: (raw key, no Bearer prefix) +``` + +- `origin` follows the plan family and reuses the same origins as + `src/coding-plan.ts` (`api.z.ai` for `zai`, `bigmodel.cn` for `bigmodel`). + Upstream env overrides: `ZAI_BUSINESS_BASE_URL`, `BIGMODEL_API_BASE_URL`, + and the full-URL `ZCODE_BIGMODEL_USAGE_QUOTA_URL` / `BIGMODEL_USAGE_QUOTA_URL`. +- Envelope: `{code?, msg?, success?, data: {level?, limits[]}}`; the plugin's + existing 0/200-code envelope check applies. +- Each limit: `{type, unit?, number?, usage?, currentValue?, remaining?, +percentage?, nextResetTime?, usageDetails?[{modelCode?, usage?}]}`. + `percentage` is **used** percent (display inverts to remaining); + `nextResetTime` is epoch milliseconds. + +Window identity (upstream dashboard matching rules): + +| Window | Match | +| -------- | ------------------------------------------------------- | +| 5-hour | `type === "TOKENS_LIMIT" && unit === 3 && number === 5` | +| Weekly | `type === "TOKENS_LIMIT" && unit === 6` | +| MCP time | `type === "TIME_LIMIT" && unit === 5 && number === 1` | + +Adjacent endpoints discovered but not wired: `/api/v1/coding-plan/reset` +(5h/weekly reset opportunities, status/use/history - the Desktop +"CodingPlanQuotaReset" feature) and the official-MCP quota provider with its +own credential source. + +## bb surfaces + +1. `provider/usage` (pull; wired to the quota endpoint since this change): + `status: "ok"` + `accountEmail` + `planLabel` + + `windows: [{label, usedPercent, resetsAt (ISO | null), cost?}]`. The + installed bb 0.46 daemon validates `resetsAt`/windows, so the pipeline is + 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 + `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 + `src/usage.ts`; the contextWindow delta additionally accepts a rich + `snapshot` (categories, `autoCompactAtTokens`) the plugin does not send yet. + +## Implemented mapping (`src/coding-plan.ts`, `src/bridge.ts`) + +- `quotaWindows(limits, now)` maps in a fixed display order: `5-hour window` + (TOKENS_LIMIT/unit3/number5), `Weekly window` (TOKENS_LIMIT/unit6), + `MCP quota` (TIME_LIMIT/unit5/number1); `usedPercent = percentage` + (already used-percent), `resetsAt = ISO(nextResetTime)` only while it is in + the future. +- `CodingPlanHost.usageWindows()` does one bounded GET with the short-lived + project token via the shared `request()` plumbing and degrades to + `undefined` on any account failure. +- The bridge fetches windows only from the `provider/usage` handler (never + `model/list`/`provider/health`, which share `probe`), behind a single-slot + 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. diff --git a/knowledge/decisions/feature-roadmap.md b/knowledge/decisions/feature-roadmap.md index adea1ee..c8c2062 100644 --- a/knowledge/decisions/feature-roadmap.md +++ b/knowledge/decisions/feature-roadmap.md @@ -46,11 +46,14 @@ back to the generic tool row so errors stay visible; unknown tools are unchanged. Remaining presentation gaps: full unified-diff views (bb renders `oldText`/`newText` itself), richer delegation summaries. -## Stage 2 — honest provider maintenance (done in v0.7.0) +## Stage 2 — honest provider maintenance (done in v0.7.0, windows in Unreleased) `provider/usage` now answers from sessionless discovery: account status plus the active individual Coding Plan subscription label (captured in -`src/coding-plan.ts`), with empty windows rather than invented numbers. +`src/coding-plan.ts`), originally with empty windows rather than invented +numbers; the Unreleased quota change fills real 5-hour/weekly/MCP windows from +the Z.ai quota-remaining endpoint behind a 60 s cache +([quota usage protocol](../contracts/quota-usage-protocol.md)). `/zcode-check [providerId/modelId]` runs the native `provider/testModelConnectivity` on demand - one real model request, no session prompt - and health guidance points to it. diff --git a/knowledge/index.md b/knowledge/index.md index cca983c..49b825f 100644 --- a/knowledge/index.md +++ b/knowledge/index.md @@ -23,6 +23,7 @@ not replace the user-facing [README](../README.md) or [docs/](../docs/). - [Protocol contracts](contracts/protocol-contracts.md) - The bb bridge, ZCode native protocol, and marketplace catalog contracts, with pinned versions, native model identities and context-budget translation rules. - [Off-peak protocol](contracts/off-peak-protocol.md) - The undocumented Z.ai idle-time ticket gateway (wire shapes, idle-plan per-run execution, 3102 continuation) and the plugin design derived from it; draft until live-gateway verification. - [Dynamic workflow protocol](contracts/dynamic-workflow-protocol.md) - POC-verified wire contract for ZCode dynamic workflows on CLI 0.17.0: gate params, tool family, skill gate, approval shape, the `causalityGraph` display payload, the task-notification wake turn, the cross-process `workflows/runs` read API, and the live-status sources (projection background jobs + v4 run journal events); draft - one happy path exercised. +- [Quota usage protocol](contracts/quota-usage-protocol.md) - The Z.ai/bigmodel Coding Plan quota-remaining endpoint (5-hour/weekly/MCP windows, used-percent semantics, nextResetTime) and its mapping onto bb's `provider/usage` windows and `provider.rateLimits` deltas; live-verified on the zai family, bigmodel fixture-only. ## Decisions diff --git a/knowledge/log.md b/knowledge/log.md index a379f6b..02b0c13 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -1,5 +1,25 @@ # Knowledge log +## 2026-11-09 (quota usage research and implementation) + +- **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 + `provider.rateLimits` delta + `rateLimited` recovery kind. Upstream sync + `a60ba6a` documents the Z.ai/bigmodel quota-remaining endpoint + (`GET /api/monitor/usage/quota/limit`, raw Coding Plan key auth, same origins + `src/coding-plan.ts` already uses) with 5-hour/weekly/MCP windows + (used-percent + `nextResetTime`), plus a 5h/weekly reset-opportunity API. +- **Implemented** the pull side: `CodingPlanHost.usageWindows()` fetches the + quota endpoint with the short-lived project token, `quotaWindows()` maps the + three windows in a fixed order (finite 0..100 percentage required, future + `nextResetTime` only), and `provider/usage` serves them behind a 60 s + single-slot cache so panel refreshes do not respawn the sessionless probe; + quota failures degrade to the previous plan-label-only answer. Verified live + through the real Desktop sign-in (GLM Coding Max: 5-hour 75%, weekly 19%, + MCP 1%, real reset timestamps); bigmodel covered by fixtures. Wire contract: + [quota usage protocol](contracts/quota-usage-protocol.md). + ## 2026-11-01 (crash self-healing) - **Fixed** threads stranded by an unexpected ZCode process exit. `session.ended` diff --git a/src/bridge.ts b/src/bridge.ts index 727b82d..ab874a7 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -49,7 +49,11 @@ import { type Snapshot, } from "./protocol.js"; import { Timeline, toolPresentation } from "./timeline.js"; -import { CodingPlanHost, type AccountHost } from "./coding-plan.js"; +import { + CodingPlanHost, + type AccountHost, + type UsageWindow, +} from "./coding-plan.js"; import { nativeInput } from "./input.js"; import { Usage } from "./usage.js"; import { runtimeSettings } from "./settings.js"; @@ -404,6 +408,8 @@ export function createBridge( automationTickMs?: number; /** Test seam: replace the off-peak gateway client entirely. */ offPeakClientFactory?: () => OffPeakServerClient; + /** Test seam: provider/usage window cache lifetime (default 60 s). */ + usageCacheTtlMs?: number; } = {}, ) { const write = @@ -418,6 +424,13 @@ export function createBridge( const sessions = new Map(); const constructing = new Set(); const startingClients = new Map(); + // Single-slot cache for provider/usage: one supported plan per installation, + // keyed by the exact request payload. Quota lookups ride the heavyweight + // sessionless probe, so panel refreshes must not respawn a CLI child or + // re-query the account service on every call. + const usageCacheTtlMs = config.usageCacheTtlMs ?? 60_000; + let usageCache: + { key: string; at: number; usage: Record } | undefined; // Most recent native session identity per thread, surviving session // release: cold dispatch compares a task's bound session against it to // detect threads that were rebound (fork/rewind) after the task was @@ -2195,7 +2208,11 @@ export function createBridge( } } - async function probe(raw: Message, cwd = homedir()) { + async function probe( + raw: Message, + cwd = homedir(), + options: { quota?: boolean } = {}, + ) { const launch = resolveLaunch( raw.providerOptions, config.env ?? process.env, @@ -2250,7 +2267,10 @@ export function createBridge( ) ) throw new Error(accountError); - return { snapshot, planLabel: account.planLabel ?? null }; + const windows: UsageWindow[] = options.quota + ? ((await account.usageWindows?.()) ?? []) + : []; + return { snapshot, planLabel: account.planLabel ?? null, windows }; } finally { account.close(); await client.close(); @@ -2416,20 +2436,34 @@ export function createBridge( } if (method === "provider/usage") { const p = record(parsed.data); + const key = JSON.stringify(parsed.data); + if ( + usageCache && + usageCache.key === key && + usageCache.at + usageCacheTtlMs > Date.now() + ) { + result(id, { supported: true, usage: usageCache.usage }); + return; + } try { - const { snapshot, planLabel } = await probe( + const { snapshot, planLabel, windows } = await probe( p, text(p.cwd) || homedir(), + { quota: true }, ); const available = snapshot.settings.model.available.filter( (model) => !model.disabledReason, ); - result(id, { - supported: true, - usage: available.length - ? { status: "ok", accountEmail: null, planLabel, windows: [] } - : { status: "unauthenticated" }, - }); + const usage = available.length + ? { status: "ok", accountEmail: null, planLabel, windows } + : { status: "unauthenticated" }; + if (available.length) + usageCache = { + key, + at: Date.now(), + usage: usage as Record, + }; + result(id, { supported: true, usage }); } catch (failure) { result(id, { supported: true, diff --git a/src/coding-plan.ts b/src/coding-plan.ts index 358e593..37281b8 100644 --- a/src/coding-plan.ts +++ b/src/coding-plan.ts @@ -61,6 +61,65 @@ export interface AccountHost { close(): void; /** Label of the active subscription, when an account host verified one. */ readonly planLabel?: string | null; + /** Quota windows for usage display, when the account can report them. */ + usageWindows?(): Promise; +} + +/** One bb usage window derived from a native quota limit. */ +export interface UsageWindow { + label: string; + usedPercent: number; + resetsAt: string | null; +} + +// Upstream dashboard matching rules (docs/coding-plan-quota-remaining-api.md): +// TOKENS_LIMIT/unit 3/number 5 is the 5-hour window, TOKENS_LIMIT/unit 6 the +// weekly window, TIME_LIMIT/unit 5/number 1 the MCP time window. The order +// fixes the panel display order independently of the response array. +const quotaWindowRules: readonly { + label: string; + type: string; + unit: number; + number: number | null; +}[] = [ + { label: "5-hour window", type: "TOKENS_LIMIT", unit: 3, number: 5 }, + { label: "Weekly window", type: "TOKENS_LIMIT", unit: 6, number: null }, + { label: "MCP quota", type: "TIME_LIMIT", unit: 5, number: 1 }, +]; + +const quotaNumber = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) ? value : undefined; + +/** + * Maps native quota-remaining limits onto bb usage windows. The native + * `percentage` is already used-percent; limits a rule cannot match with a + * finite 0..100 percentage are skipped rather than guessed. + */ +export function quotaWindows(limits: unknown, now: number): UsageWindow[] { + const rows = (Array.isArray(limits) ? limits : []).map(record); + return quotaWindowRules + .map((rule) => { + const limit = rows.find( + (row) => + text(row.type) === rule.type && + row.unit === rule.unit && + (rule.number === null || row.number === rule.number) && + (quotaNumber(row.percentage) ?? -1) >= 0 && + (quotaNumber(row.percentage) ?? 101) <= 100, + ); + const usedPercent = quotaNumber(limit?.percentage); + if (!limit || usedPercent === undefined) return undefined; + const nextReset = quotaNumber(limit.nextResetTime); + return { + label: rule.label, + usedPercent, + resetsAt: + nextReset !== undefined && nextReset > now + ? new Date(nextReset).toISOString() + : null, + }; + }) + .filter((window): window is UsageWindow => window !== undefined); } /** Implements ZCode's desktop-host contract for individual Coding Plan only. */ @@ -179,6 +238,30 @@ export class CodingPlanHost implements AccountHost { }; } + /** + * Quota windows for bb's usage panel: one bounded GET against the same + * account service, authorized by the short-lived project token (the key + * upstream's usage dashboard uses). Degrades to undefined so the panel + * keeps the plan label without invented bars. + */ + async usageWindows(): Promise { + if (!this.login) return undefined; + try { + const material = await this.getMaterial(); + const data = record( + await this.request( + "/api/monitor/usage/quota/limit", + material.token, + undefined, + true, + ), + ); + return quotaWindows(data.limits, this.now()); + } catch { + return undefined; + } + } + private async request( path: string, authorization: string, diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index 7222ff4..a6b5c44 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -10,6 +10,7 @@ import { threadDeltaNotificationParamsSchema, interactionRequestPayloadSchema, approvalInteractionOutcomeSchema, + providerUsageResultSchema, } from "@get-bb/plugin-sdk/provider-bridge"; import { createBridge } from "../src/bridge.js"; import { record } from "../src/protocol.js"; @@ -309,7 +310,7 @@ it("rejects a mismatched provider identity", async () => { expect(record(reply.error).code).toBe(-32602); }); -it("reports provider usage with the account plan label", async () => { +it("reports provider usage with the account plan label and quota windows", async () => { const h = harness({ accountFactory: () => ({ planLabel: "Coding Plan Fixture", @@ -317,17 +318,76 @@ it("reports provider usage with the account plan label", async () => { async authorize() { return { headersApplied: false }; }, + async usageWindows() { + return [ + { + label: "5-hour window", + usedPercent: 42, + resetsAt: "2026-11-09T00:00:00.000Z", + }, + { label: "Weekly window", usedPercent: 12, resetsAt: null }, + ]; + }, close() {}, }), }); const r = await h.rpc("provider/usage", { providerId: "zcode" }); + expect(providerUsageResultSchema.safeParse(r.result).success).toBe(true); expect(record(r.result)).toMatchObject({ supported: true, usage: { status: "ok", planLabel: "Coding Plan Fixture", accountEmail: null, - windows: [], + windows: [ + { + label: "5-hour window", + usedPercent: 42, + resetsAt: "2026-11-09T00:00:00.000Z", + }, + { label: "Weekly window", usedPercent: 12, resetsAt: null }, + ], + }, + }); +}); + +it("caches provider usage within the ttl and re-probes after it", async () => { + let quotaFetches = 0; + const accountFactory = () => ({ + planLabel: "Coding Plan Fixture", + async initialize() {}, + async authorize() { + return { headersApplied: false }; }, + async usageWindows() { + quotaFetches++; + return []; + }, + close() {}, + }); + const cached = harness({ accountFactory }); + await cached.rpc("provider/usage", { providerId: "zcode" }); + await cached.rpc("provider/usage", { providerId: "zcode" }); + expect(quotaFetches).toBe(1); + + const expired = harness({ accountFactory, usageCacheTtlMs: 0 }); + await expired.rpc("provider/usage", { providerId: "zcode" }); + await expired.rpc("provider/usage", { providerId: "zcode" }); + expect(quotaFetches).toBe(3); +}); + +it("keeps provider usage schema-valid without quota windows", async () => { + const h = harness({ + accountFactory: () => ({ + planLabel: "Coding Plan Fixture", + async initialize() {}, + async authorize() { + return { headersApplied: false }; + }, + close() {}, + }), }); + const r = await h.rpc("provider/usage", { providerId: "zcode" }); + expect(providerUsageResultSchema.safeParse(r.result).success).toBe(true); + expect(record(record(r.result).usage).windows).toEqual([]); }); diff --git a/tests/coding-plan.test.ts b/tests/coding-plan.test.ts index 087126a..bade068 100644 --- a/tests/coding-plan.test.ts +++ b/tests/coding-plan.test.ts @@ -468,3 +468,83 @@ it("coalesces simultaneous token refreshes", async () => { ]); expect(f.fetcher).toHaveBeenCalledTimes(8); }); + +it.each(["zai", "bigmodel"] as const)( + "maps %s quota windows with the short-lived project token", + async (family) => { + const f = fixture(family); + f.intercept((path) => { + if (!path.endsWith("quota/limit")) return undefined; + return { + code: 0, + data: { + level: "pro", + limits: [ + { + type: "TOKENS_LIMIT", + unit: 3, + number: 5, + percentage: 41.5, + nextResetTime: 1_800_000_000_000 + 3_600_000, + }, + { + type: "TOKENS_LIMIT", + unit: 6, + percentage: 12, + nextResetTime: 1_800_000_000_000 + 86_400_000, + }, + { type: "TIME_LIMIT", unit: 5, number: 1, percentage: 7 }, + { type: "TOKENS_LIMIT", unit: 9, percentage: 50 }, + { type: "TOKENS_LIMIT", unit: 6, percentage: 120 }, + { type: "TOKENS_LIMIT", unit: 3, number: 5, percentage: 99 }, + ], + }, + }; + }); + await f.host.initialize(f.client); + expect(await f.host.usageWindows()).toEqual([ + { + label: "5-hour window", + usedPercent: 41.5, + resetsAt: new Date(1_800_000_000_000 + 3_600_000).toISOString(), + }, + { + label: "Weekly window", + usedPercent: 12, + resetsAt: new Date(1_800_000_000_000 + 86_400_000).toISOString(), + }, + { label: "MCP quota", usedPercent: 7, resetsAt: null }, + ]); + const quotaCall = f.fetcher.mock.calls.find(([url]) => + String(url).endsWith("quota/limit"), + ); + expect(record(record(quotaCall?.[1]).headers).Authorization).toBe( + "private-project-canary-1", + ); + }, +); + +it.each(["http-500", "no-plan", "not-signed-in"])( + "degrades quota windows to undefined on %s", + async (scenario) => { + const f = fixture(); + if (scenario === "http-500") + f.intercept((path) => + path.endsWith("quota/limit") + ? new Response("unavailable", { status: 500 }) + : undefined, + ); + if (scenario === "no-plan") + f.intercept((path) => + path.endsWith("quota/limit") + ? { code: 302, msg: "不存在coding plan" } + : undefined, + ); + if (scenario === "not-signed-in") { + f.credentials["oauth:zai:access_token"] = ""; + f.save(); + } + await f.host.initialize(f.client); + expect(await f.host.usageWindows()).toBeUndefined(); + }, +);