Usage feeds #
How /usage, prime-agent usage --json, the Zig core, and extension providers share one
definition of "what is this account's quota".
The shape #
A provider with a quota feed is a module exporting a UsageFeed. A feed is driven by the
host, never the other way round: the host resolves a credential, asks the feed for the next
requests, sends them with its own HTTP stack, hands the responses back, and repeats until
the feed reports.
interface UsageFeed {
provider: string; // "anthropic", "alibaba", ...
providerName: string; // "Anthropic"
credentialKinds: readonly CredentialKind[]; // which credentials this feed accepts
advance(state: UsageState, responses: readonly UsageResponse[]): UsageStep;
}
type UsageStep =
| { requests: UsageRequest[] } // answer these, then call advance again
| { report: UsageReportBody }; // finished
type UsageRequest =
| { kind: "http"; method: "GET" | "POST"; url: string;
headers?: Record<string, string>; // includes any provider-mandated User-Agent
body?: string } // already encoded; POSTs carry their own body
| { kind: "command"; command: string; args: string[];
stdin?: string; timeoutMs?: number }; // the host runs it; exit status is the status
interface UsageResponse { status: number; body: string }
A command request exists because some providers ship a supported CLI that is a better interface
than their scraped endpoint: alibaba's token plan prefers bl usage token-plan --console-region … --console-site … --output json and only falls back to a captured browser session. The core never
spawns anything — it asks, and the host decides:
- a host that runs commands answers with the exit status and stdout (
coding-agentruns only allowlisted command names, with a narrowed environment, a timeout and an output cap); - a host that will not run commands answers
status: 127, and the feed either routes around it (the console-session path) or reports that as the reason it has no feed.
advance is called first with no responses, then once per round of responses, in order.
Single-request providers ignore responses on the first call and report on the second;
providers that need a value from one response to build the next request (hyper: teams, then
team billing) use the loop the way it is meant to be used.
The report #
interface UsageReportBody {
accountId?: string; email?: string; planType?: string;
limits: UsageLimit[]; // may be empty for balance-only providers
balance?: string; // "11.83" — dollars or credits, already formatted
notes?: string[]; // extra lines the UI shows
error?: string | null; // a failed fetch is data, not an exception
}
interface UsageLimit {
label: string; // "Primary", "Weekly", "Gemini", "Claude", ...
usedPercent?: number; // 0..100, absent when the provider reports no percentage
windowLabel?: string; // "5h window", "7d window"
resetsAt?: number; // epoch milliseconds
status: "ok" | "warning" | "exhausted" | "unknown";
detail?: string; // e.g. "3 reset cards"
}
Rules that exist because providers break them:
- Units differ per provider. codex and opencode send percent; anthropic sends percent in
limits[].percent; alibaba sends a fraction inper1WeekPercentage; antigravity sends a remaining fraction. Convert at the edge and store percent. - Timestamps differ too. codex sends epoch seconds or milliseconds in
reset_at; anthropic, antigravity, hyper and opencode send ISO-8601 strings, with and without fractional seconds. Normalise to epoch milliseconds. - Provider identity can be gated. antigravity returns
403 SUBSCRIPTION_REQUIREDunless the request carries anantigravity/...User-Agent, so a feed's request headers are part of its definition. - A missing window is not a zero.
null/absent means "this provider does not report that window"; the limit is omitted, andstatus: "unknown"is only for a limit that exists with no percentage. - No feed is a first-class answer.
alibabawithout a captured console session, or a provider that has no quota API at all, must say so (skippedwith a reason) rather than report zero usage.
Credentials #
Credentials are provider-defined, because reality is not a union of two shapes:
| kind | shape | providers |
|---|---|---|
oauth |
{ access, refresh?, accountId?, email?, ... } |
codex, anthropic, antigravity |
apiKey |
{ apiKey } |
opencode-go, zai, deepseek, hyper |
session |
{ session } — opaque, provider-defined |
alibaba ({ url, region, secToken?, headers }) |
cli |
{ config? } — no secret; the host runs the provider's own signed-in CLI |
alibaba |
A feed declares which kinds it accepts; a host that cannot supply one reports the provider as
skipped and says why. Alibaba's session is captured from a browser HAR of the console
(scripts/capture-alibaba-session.mjs) because token-plan API keys are inference-only.
Two implementations, one definition #
| typescript | zig | |
|---|---|---|
| feed modules | packages/ai/src/utils/usage/providers/<provider>.ts |
native/usage/src/providers/<provider>.zig |
| driver | advance |
usage_advance(provider, credential_json, responses_json, now_ms) |
| discovery | usageProviders() |
usage_providers() |
The Zig ABI speaks the same JSON: usage_advance returns either {"requests":[…]} or
{"report":{...}}, where a request is {"kind":"http","method","url","headers":[["k","v"]],"body"?}
to send or {"kind":"command","command","args","timeoutMs"} for the host to run, so a native
host drives the identical loop with NSURLSession. usage_supported_providers() returns
[{"provider","providerName","credentialKinds"}].
The Zig core lives in its own repository and is consumed here at native/usage/ as a
submodule, so its lifecycle (toolchain pin, releases) is separate from this package while the
contract stays pinned by the parity test.
Adding a provider means one module in each language, one fixture, and a parity case. The
parity test (packages/ai/test/usage-zig-parity.test.ts) computes both implementations from
the same recorded bodies, so a change on either side fails the build instead of drifting.