NAVIGATION
ELOWEN DOCUMENTATION

Last updated: 10 October 2026

Browse documentation · Provider accounts and usage
Developer reference

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.