fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
prime-agent packages ai docs usage-feeds.md
6.4 kB
Markdown
at main

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-agent runs 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 in per1WeekPercentage; 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_REQUIRED unless the request carries an antigravity/... 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, and status: "unknown" is only for a limit that exists with no percentage.
  • No feed is a first-class answer. alibaba without a captured console session, or a provider that has no quota API at all, must say so (skipped with 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.