Provider accounts and usage
Key accounts
A key account is a provider that connects like an account in Settings -> Brain -> Accounts but authenticates with
a pasted API key. It is not an OAuth type and fakes no login: the entry stays an ordinary openai provider (so
custom-endpoint registration, Responses/Chat Completions, code mode, stateful responses, the capability status
routes and every stored <id>/<model> reference work unchanged) and carries a typed account kind.
src/shared/keyAccounts.ts is the one catalog, next to OAUTH_BUILTIN in spirit. Each kind (opencode-go, the
only consumer today) fixes the entry id (opencode, kept so stored model references stay valid), label, endpoint,
icon slug and the wire formats offered. GET /brain/key-accounts serves the catalog to Settings, which renders its
Accounts rows from it exactly as it renders OAuth rows from /brain/oauth/status; the same row component,
actions menu, test button, statistics drawer, tool-execution switches and usage rail serve both. A key account is
connected while its entry holds a key; Connect opens a dialog (key and API format) that upserts the entry through
the ordinary provider save, and Disconnect removes the entry after the usual confirmation. Entries with an
account never appear in the custom-provider list (web/modules/settings/BrainProvidersSection.tsx:109).
The catalog route is admin-only, or open during first-run setup, like the other brain settings routes
(src/api/routes/brainProviders.ts:58-61).
Validation sits at two boundaries. The API schema (PUT /config) rejects an unknown kind, a kind on a non-openai
entry, an id other than the catalog's and a format the account does not offer. Persistence (sanitizeBrainProviders)
keeps the kind only on the matching entry and pins label and endpoint to the catalog. Nothing is inferred from an
entry's id or URL: an entry without account is a plain custom provider. migrateKeyAccounts() (boot, beside the
other config migrations) converts the hand-made entry that predates the catalog, an openai entry under the kind's
id on the catalog endpoint, in place, keeping its key, models, format and switches; it is a no-op afterwards.
The API check is keyAccountIssue in src/api/schemas/config.ts:150. Persistence drops an api format the account
does not offer (src/store/config/brainProviders.ts:70). The migration runs at boot only when the store was not
opened with migrate: false (src/daemon/brainCore.ts:399), and its endpoint match ignores trailing slashes
(src/store/configStore.ts:1141-1143).
Subscription usage of a key account is keyed by its entry id (usageProviderKey in src/brain/providers.ts),
which for OAuth accounts is the built-in id they already used, so /brain/rate-limits/all and the session's
usageProvider can be looked up by the config entry id for every account and the web never sees the elowen-
registry namespace. Adding a kind is one catalog row, plus a UsageSource when the endpoint reports limits.
OAuth accounts
Elowen lets an operator connect PI's built-in OAuth accounts (ChatGPT/Codex, Claude, GitHub Copilot, Kimi,
xAI, Meta and OpenRouter) from Settings, the CLI setup wizard and the API. What maps an account type such
as oauth-xai onto PI is OAUTH_BUILTIN in src/shared/oauthAccounts.ts (import-free, so the store layer can read it). An account registers no provider of
its own: its native runtime descriptors come from PI's catalog (plus the persisted pi.dev overlay) and its token from
brain/auth.json (mode 0600, directory 0700). The entry synthesized from a connected credential uses
the built-in provider id; an explicit entry written by the CLI wizard uses oauth-<builtin>
(src/cli/setup/steps/aiProvider.ts:292). Connecting is refused with 409 while an entry that holds the built-in id
under a different account type exists (src/api/routes/brainOauth.ts:39-41). Sign-in is a device
code (xAI, Meta), a browser page with paste-back (Claude, Codex) or OpenRouter's loopback listener plus
paste-back for a remote browser; PI refreshes tokens on use, and Elowen proactively refreshes only
Anthropic ahead of time (src/brain/oauthRefresh.ts:5,22-29). Every list that names the account types is pinned to OAUTH_BUILTIN by
tests/contract/oauthProviderParity.test.ts.
The login flow is driven by BrainOAuthManager in src/brain/oauth.ts. Codex offers two methods: browser, where
the loopback callback can finish the login without a paste, and device_code, which the CLI wizard requests with
?method=device_code because the loopback is unreachable over SSH.
Disconnecting removes only the credential. brainConfigFromElowen hides a stored oauth-* entry whose credential is
gone, so no unreachable account group remains, and reconnecting restores the saved model selection
(src/brain/config.ts:36-46).
resolveProviderCredentials in src/brain/config.ts is the one credentialed-endpoint resolver used by
embeddings, image services, worker embedding jobs, categorization and gated plugin lookups. Stored API-key
entries win. Stored OpenRouter account selections and the synthetic built-in openrouter route resolve
against its current credential and canonical API endpoint. OpenRouter alone issues a permanent API key in
its OAuth access field; other account tokens must remain in host-owned refreshable PI transports and
are never resolved from the credential store for plugins or embeddings.
Categorization endpoint overrides may receive only a stored API-key provider's configured key. All OAuth overrides are rejected on settings writes; an existing OAuth override is ignored at dispatch and uses PI's canonical route instead, without changing stored settings or handing the account key to the override.
OpenRouter's account picker merges PI's native text descriptors with cachedProviderModels at the public
OpenRouter endpoint with a null key, by model ID, without network access. Reported output metadata wins.
OAuthModelCatalog carries IDs and a per-ID outputs map, including fixed Codex image kinds. Empty account
selections offer text or unknown outputs only; explicit lists preserve selected IDs and known output kinds.
OpenRouter auto-discovery also excludes :free; a missing or partial worker snapshot retains native models.
applyModelOverrides extends the existing native provider registration for new reported text IDs, using a
native OpenAI-compatible descriptor while preserving PI-owned auth. Shared-runtime rebuilds remove stale
additions and cleared pins. Known non-text-only outputs never enter the host's chat-facing ctx.listModels
list; mixed text/image models remain chat-capable and image-capable.
Provider usage rails
A connected account can show how much of its allowance is used: the 5-hour and weekly windows of a Claude,
ChatGPT, Kimi, xAI (Grok) or Meta (Muse) subscription, an OpenRouter account's balance, or the 5-hour, weekly and
monthly windows of an OpenCode Go key account. UsageService (src/brain/providerUsage.ts)
is the one poller; a UsageSource adapter per provider (anthropicUsage.ts, openaiCodexUsage.ts,
kimiUsage.ts, xaiUsage.ts, metaUsage.ts, openRouterUsage.ts, opencodeGoUsage.ts) supplies the cache key, the requests and the parsing, and bootstrap.ts
instantiates one service per adapter (src/daemon/bootstrap.ts:564-573). GET /brain/rate-limits/all serves every connected account keyed by
usage provider id: the PI provider id for an OAuth account, and opencode for OpenCode Go (src/api/routes/brain.ts:96-103). The Settings account row, the chat rail's Limits section and the CLI rail all render that one
ProviderUsage shape (UsageWindow and ProviderUsage live in src/shared/wireContract.ts).
The service owns the TTL cache (60 s), the single-flight per cache key, the 5 s timeout and the last snapshot on error:
a transient failure serves the last good snapshot, an authorization failure (401 or 403 on any
request, checked before the transient case) drops it, and a snapshot with no windows is rejected. requests(token, cacheKey, auth) returns a list, so an adapter whose figures
sit behind several endpoints lists them all; they are fetched in parallel, normalize(bodies, fetchedAt)
receives the bodies index-aligned with null for each failed request, and one failing endpoint never drops
the other's windows (all failing is the ordinary failure case). The cache key never contains a
secret: OpenRouter's account is a permanent API key, so its key is a digest of it.
providerUsageAfterRead(usage, failed, now) in src/shared/displayFormat.ts (mirrored in web/lib) is the one
rule for a kept snapshot: after a failed read, here or in the browser's or CLI's own fetch, the last snapshot
is served unchanged until its fetchedAt is 30 minutes old, then withdrawn (null). One provider (Anthropic)
refuses several usage polls an hour, and figures up to half an hour old still hold for 5-hour and weekly
windows. Nothing marks a kept snapshot; a withdrawn one simply disappears, and provider limit alerts leave
an open alert as it is while no reading is available.
xAI's adapter uses PI's xai OAuth credential from its one-login-per-provider store. PI supplies no stable account id, so a digest of the access token keys the cache, as in OpenRouter's adapter: replacing a login cannot reuse the previous login's fresh or stale reading. Token refresh re-keys through the shared service and starts a fresh snapshot; neither the credential nor its digest reaches the projection. One GET to https://cli-chat-proxy.grok.com/v1/billing?format=credits carries the bearer token and X-XAI-Token-Auth: xai-grok-cli, with no account header. It projects only the included allowance: config.creditUsagePercent and config.currentPeriod, not on-demand spending, prepaid credit balances or personal account fields. Proto3 omits a zero percentage; a present current period makes that omission a real zero, but absent or empty config yields no snapshot. Weekly periods use 10080 minutes and monthly periods use the shared 43200-minute label, with the actual reset from currentPeriod.end; unknown types derive minutes from start/end. As in the official Grok Build CLI, deprecated monthlyLimit/used and billingPeriodEnd are only used when the corresponding new fields are absent. No subscription tier is inferred. The ordinary 60 s cache, 5 s timeout, authorization handling and quota sink apply, with no additional polling or persistence path.
OpenCode Go is the one key account (see "Key accounts"): its adapter reads the key from the provider config instead of PI's auth store. opencodeGoAuth(() => resolveKeyAccountCredentials(config, brainCreds, 'opencode-go')) presents the key of the entry whose typed account is opencode-go as an api_key credential through the shared resolveProviderCredentials, re-read on every call; nothing is matched by id or URL, and the endpoint is the catalog's. The service map key is the entry id opencode. One GET <endpoint>/usage carries the key as Bearer plus a browser-like User-Agent (Cloudflare rejects bare clients with error 1010) and returns usage.rolling|weekly|monthly, each {status, percent, resetsAt}. Only percent and the ISO reset are projected, never absolute limits; the lengths are the documented 300 minutes, 10080 minutes and the shared 43200-minute month label, and a rate-limited window shows as full. A 401 (bad key) or 403 (no Go subscription) is the service's ordinary authorization failure: no snapshot and no invented zero windows. The cache key is a digest of the API key. The ordinary 60 s cache, 5 s timeout and quota sink apply.
Two optional parts of the seam exist for a provider whose usage is not a plain GET with the model token. A
UsageRequest may carry method ('GET' or 'POST') and a body string; absent, the request is a GET
without a body, which is what every adapter but Meta's sends. The third requests parameter, auth, is the
credential access, for an endpoint that wants another token than the model access token; adapters that do not
need it ignore it. Meta is the consumer: its plan allowance is only reported in the response of the Muse Code
key mint, a POST authorised by the long-lived identity token PI stores as refresh (the model key in access
is refused there). metaUsage.ts reads nothing from that response but is_subs_active and
subs_usage.window / subs_usage.weekly, and returns two ordinary time windows: the 5-hour window, whose length is the
window_duration_mins the response reports, and the weekly window, fixed at 10080 minutes. Reset stamps are Unix seconds,
and usedPercent is clamped to 100 because Meta may report more. A window without a usable length or percentage is
omitted. The service never keeps the minted key or the account fields beside it, and an inactive subscription
counts as no snapshot. The cost is one key
mint per poll (verified not to invalidate the key PI holds), so bootstrap.ts gives Meta's service a 5 minute
TTL through UsageServiceDeps.ttlMs instead of the default 60 s (src/daemon/bootstrap.ts:573). A dead identity session answers 401 or 403,
which the service already treats as an authorization failure: the snapshot is dropped and the rail goes away
until the account signs in again.
Quota history. A provider reports only the CURRENT window, so what share of a subscription was used on which
day exists only if the daemon writes it down. UsageServiceDeps.sink receives every FRESH snapshot (never a
cached or stale one) from the one poller: no second fetcher and no extra polling, since the 5 minute provider
alert sweep already polls every connected account. bootstrap.ts passes the same sink to every service; it is
ProviderQuotaStore.record (src/store/providerQuotaStore.ts), which keeps the subscription time windows only
(money and request meters are not a percentage of a subscription) in provider_quota_samples, keyed by the
usage provider id, and writes a reading only when it carries information: the value moved, the reset time
moved, or an hour passed since the last written one (a heartbeat, so a silent hour differs from a daemon that
was down). The sink is best-effort: a throw is logged through onSinkError with the code
provider.quota_history_failed, once per failure streak, and never fails the poll
(src/daemon/bootstrap.ts:559-562). Readings hold a percentage and a reset instant, no account name, address or token, and are purged
hourly beyond QUOTA_RETENTION_DAYS (120) by runOriginRetentionSweep, on a horizon of their own. They are not
user data, so account deletion and /users/:id/usage/reset do not touch them. Readings are keyed by usage provider
id, not by account: disconnecting an account, removing the provider entry or signing in with another account leaves
that provider's readings in place until the 120-day purge (like usage_by_provider_day, a removed provider's history
stays readable). A switch of account does not add phantom use, because the new account's window has another
reset time or a lower percentage, but the chart then shows one history for the provider id, not per account. The
table needs no migration: schema.sql
creates it on every open and there is nothing to backfill, because history before the first recorded reading
does not exist anywhere.
deriveQuotaDays (src/store/quotaHistory.ts) is the one derivation, a pure function of the readings. For the
longest window of the provider (the primary, usually weekly) a day's consumption is the sum of the upward steps of
usedPercent between consecutive readings, counted on the day of the later reading, inside one window
instance: a reading whose reset time moved by more than a minute starts a new instance from zero (an unknown
reset time on either side never separates two readings, because providers drop it now and then; recorder and
derivation share one predicate, sameResetInstant), a drop inside an instance is a correction that consumes
nothing and moves the baseline down (so a 50, 49, 50 wobble counts one point on the way back: a day is
accurate to a point or two of noise, not exact), and the first reading ever has no baseline. Usage between the
last reading before a window reset and the reset itself is never seen and is lost, and so is everything before
the first reading, so the first recorded day is a partial count. Every
window also reports its peak per day. A day appears only when a reading fell on it; a step that followed more
than three hours without a reading is counted on the later day and flagged afterGap when the step is positive. The figures are the
ACCOUNT's, so they include use outside Elowen, and they begin at the first recorded reading. The last
reading of each window before the requested range is read as its baseline, so a day shows the same figure
whichever range the drawer asked for. The read is index-bounded (primary key (provider, window_minutes, sampled_at), windows listed by a loose index scan, tests/store/providerQuotaPlan.test.ts), so its cost is the
readings inside the range; in the worst case measured (a reading of both windows on every 5 minute poll for
the whole 120 days, about 69,000 rows) an all-time read took roughly 45 to 60 ms, a 30 day read about 11 ms, and
a real instance writes far fewer rows because unchanged readings are not stored.
UsageWindow is a union: a subscription time window named by windowMinutes carries neither kind nor
amount, and a money or request meter always carries both, so a meter can never be built without its kind.
A meter is drawn with the figure that is left rather than a percentage: credits is the balance (the bar
is the share spent; the total only derives the balance and is never printed), limit a per-key spending cap
whose period is windowMinutes, and requests the free-model allowance that resets at 00:00 UTC. The OpenRouter adapter publishes a meter only
from usable amounts (finite, not negative, within a fixed bound) and omits one that fails; limit: null is a
key without a cap and draws nothing, while a cap of zero is drawn as fully used. Time
windows are ordered shortest-first, kinded meters follow in the order the adapter listed them. Only time
windows raise the provider limit alert (src/daemon/resourceAlerts.ts:30). The chat rail's compact strip shows a percentage per window and puts
the exact figure in the item's title. Labels and figures come from one mapping per client
(usageWindowMeter in web/lib/usageMeter.ts, rateLimitFields in src/cli/chat/telemetryPanel.ts).
Adding a provider means one adapter and one line in bootstrap.ts (a costly poll also sets its ttlMs there); a provider without an adapter has no rail.