usage-core #
Provider usage and quota, as a pure Zig core with a C ABI, a CLI, and a Swift wrapper.
Hosts ask the core what to send and hand the bodies back, so the rules about what a
provider's quota means live in one place while TLS, proxies, cookies and HTTP/2 stay
with whatever the host already has: URLSession in a macOS app, fetch in the agent CLI.
It reports quota for Alibaba Model Studio (token plan), Anthropic, DeepSeek, Google
Antigravity, Charm Hyper, OpenAI Codex, opencode-go and zai — each with its own credential
kind (oauth, apiKey, session, or cli for a provider whose own CLI is already signed
in) and its own plumbing of percentages, reset times and balances into one shape.
The boundary: the core is pure, the host owns I/O #
The core never opens a socket, reads a clock, or touches the filesystem. Providers are
state machines: advance(state, responses) returns the requests to answer or the finished
report, so a provider that needs a value from one response to build the next request simply
takes another round.
| function | purpose |
|---|---|
usage_abi_version() |
ABI version, bumped on any signature or shape change. |
usage_providers() |
JSON array of provider ids, names and credential kinds. |
usage_state_new() |
Allocate opaque cross-round state for multi-round feeds (e.g. Hyper). |
usage_state_free(state) |
Free opaque cross-round state. NULL is accepted. |
usage_credentials_from_access_token(jwt, len) |
{"accountId"?, "email"?} from OAuth token claims. |
usage_advance(state, provider, len, credential_json, credential_len, responses_json, responses_len, now_ms) |
Requests to answer, or the finished report. |
usage_free(ptr) |
Release a returned string. NULL is accepted. |
Every call returns a NUL-terminated JSON string the caller frees with usage_free, and
reports failure inside that JSON ({"error":"..."}). NULL means only that allocation
failed. A request is either {"kind":"http", method, url, headers, body} for the host to
send, or {"kind":"command", command, args, timeoutMs} for the host to run. The core never
spawns anything; a host that will not run commands answers status: 127 and the feed routes
around it.
That split is deliberate. A usage query is one GET, and doing it here would mean shipping a TLS stack with its own trust store, proxy handling and HTTP version behaviour.
Build and test #
Pinned toolchain: Zig 0.16.0. The standard library moved args, stdio, fs and
clock APIs in 0.16, so the version is not incidental.
# hermetic install (checksum from ziglang.org/download/index.json)
curl -L -o /tmp/zig.tar.xz https://ziglang.org/download/0.16.0/zig-aarch64-macos-0.16.0.tar.xz
shasum -a 256 /tmp/zig.tar.xz # b23d70deaa879b5c2d486ed3316f7eaa53eacf6fc9cc747de152450d401489
mkdir -p ~/.local/share/zig && tar -xJf /tmp/zig.tar.xz -C ~/.local/share/zig
export PATH="$HOME/.local/share/zig/zig-aarch64-macos-0.16.0:$PATH"
zig build # libusage.a, libusage.dylib, usage.h, usage CLI
zig build test # core unit tests + the C ABI smoke test
zig build smoke # C ABI smoke test alone
zig build run -- providers
zig build run -- advance <provider> <credential.json> <responses.json>... [--now-ms N]
zig build run -- advance - # the same call as one JSON envelope on stdin
zig build run -- claims <access-token>
The smoke step is not decoration: it compiles tests/c_smoke.c with the toolchain, links
the installed static library, exercises multi-round state, and asserts against recorded provider bodies.
advance - takes the whole call as one JSON envelope line on stdin instead, so a credential
never lands in argv where other local processes can read it. The envelope ends at the first
newline, so a host that cannot half-close a pipe (an Erlang port) writes one line and reads the
answer. It is as stateless as the argument form: the host keeps every round's responses and
replays them.
echo '{"provider":"anthropic","credential":{"kind":"oauth","access":"…"},"responses":[],"nowMs":0}' \
| zig build run -- advance -
swift build --package-path swift
swift run --package-path swift usage-smoke tests/fixtures
For a macOS 13 host, build the library against its deployment target:
zig build -Dtarget=aarch64-macos.13
Limits #
A report's limits[] carries what a host needs to track a limit over time, beside the display
text:
| field | meaning |
|---|---|
id |
Stable machine key within the provider (weekly_opus, primary, codex_bengalfox.secondary). label is display text and can change. |
windowSeconds |
Window length when the provider states it or the plan fixes it; absent when unknown or calendar-shaped. |
scope |
The provider's own name for the model family or feature the limit is restricted to; absent means it counts all usage on the account. |
Parity with the TypeScript reference #
prime-agent carries the reference implementation in packages/ai/src/utils/usage/, and
its docs in packages/ai/docs/usage-query.md define the JSON shapes both sides speak. Both
implementations compute from the same fixtures and a vitest suite compares them, so a change
to either one fails the test rather than drifting quietly:
ZIG=$(command -v zig) npm --prefix packages/ai run test test/usage-zig-parity.test.ts
The fixtures under tests/fixtures/ are recorded provider bodies. The live codex one was
captured from chatgpt.com/backend-api/wham/usage with its user_id, account_id and
email fields removed.
Swift #
swift/ wraps the library for native macOS hosts (dawn's tray app is the first consumer):
UsageCoreis the thin C bridge:providers(),advance(provider:credentialJSON:responses:nowMs:)returning either the requests or the report, andcredentialsFromAccessToken. Every returned string is freed withusage_free, and a NULL return becomes a thrown error rather than a crash.UsageDriverruns the host loop withURLSession: ask the core for requests, send them, hand the responses back, repeat. A transport failure comes back as status0so a feed reports it as data instead of the call throwing.runCommand:answers command requests with only the environment allowlist (PATH,HOME, locale, timezone, proxies), a timeout and a stdout cap.usage-smokeproves the path against the recorded fixtures with no network.
Headers cross the ABI as ordered pairs because HTTP allows a repeated name;
UsageRequest.headers is the dictionary view for the common case.
Status #
- Eight providers have feeds. Unknown providers return
{"error":"unknown provider"}. - Providers differ in what they can answer from. Alibaba's token-plan keys are
inference-only, so its feed prefers the official
blCLI and falls back to a captured console session; a provider whose answer needs a second round trips the host loop again. - Bodies carry more than the parsers read today, so new limits are a parser change plus a fixture.