Provider accounts and usage
Per-model wire API
resolveWireApi(entry, modelId, facts) in src/brain/providerEndpoint.ts is the pure transport
projection. It returns { api, baseUrl, source } in this order: model override, provider pin,
native PI catalog, the official OpenAI endpoint answered as Responses with source catalog,
endpoint-fingerprinted detection, then openAiApiFor as the current default.
facts.modelOverrides is the provider/model override map; optional facts.catalog supplies the
endpoint's native descriptor. The key-account catalog's optional piProvider names the PI catalog,
so Opencode Go Messages models retain their native base URL. When absent, as for Command, no native
PI descriptor is borrowed: the existing pins, detection and default resolve the wire, and
catalogLimits uses the existing endpoint and vendor catalog rows for model limits. Native Google adapters are excluded
because they cannot record attempt evidence through Elowen's fetch. Anthropic and OAuth retain
their native transports and ignore wire pins and detections. The resolver accepts only openai
entries; status consumers read other providers' model.api and model.baseUrl from the prepared
registry, including remote catalog overlays. Native models without a registered chat descriptor
are omitted from the wire projection rather than guessed from the bundled catalog. Probing and
invalidation skip non-OpenAI-compatible providers before resolving.
wireApiChoices(entry, modelId, facts) offers Responses and Chat Completions for OpenAI-compatible
models and Messages only when a native catalog descriptor supplies its endpoint.
wireApiFingerprint(entry) hashes provider type and the normalized endpoint including its port,
independent of pins and credentials. WIRE_API_PROTOCOL is wire-api-v1.
runtime.wireApi[providerId][modelId] holds WireApiDetection, containing the successful API,
unsupported API kinds, fingerprint, check time and protocol. Only
ConfigStore.setWireApiDetection(providerId, modelId, detection) and
clearWireApiDetection(providerId, modelId?) write it; generic configuration updates preserve it.
Provider/model removal prunes detections. Stored pins with no addressable endpoint lose only their API field;
API writes reject them with a schema error. Connections read detections live through the required
ConnectionRuntimeOwnerDeps.wireApi(): Promise<WireApiDetections> getter. connectionEntry(provider, connection, wireApi?)
carries that provider's model map, so the existing registry snapshot comparison publishes changed descriptors.
registerCustomProvider registers api, baseUrl and compatibility on each PI ProviderModelConfig.
openAiCompatibilityFor(api, entry) supplies compatibility only for the resolved Chat Completions API.
Session route checks and account failover retain that spawn-time compatibility together with API
and base URL, including the default CC compatibility and operator switches, until the next turn respawns.
The existing settings JSON stores it without a schema migration. The probe is its server-side
consumer; resolving a descriptor performs no network requests and incurs no usage.
Wire API probe and administrative routes
probeWireApi(deps, request) in src/brain/wireApiProbe.ts is the lazy network boundary.
Automatic chat and secondary-inference callers use startWireApiProbe(probe, request) to start it
in the background on first use, never awaiting it on the turn-start or spawn path. The current
request immediately uses the default API. Stored detection applies to subsequent inference and
causes the existing next-turn conversation respawn with its wire-api event.
Automatic probes share one in-flight promise per provider/model and endpoint.
Unexpected background rejections are logged as provider.wire_api_probe_failed.
The request carries the initiating principal, provider, model,
optional session and configured-work purpose. It skips explicit pins, native transports, catalog
resolutions and valid detections.
Every candidate is authorized through authorizeAttempt; subsequent candidates pin that same account
and credential generation. The probe rechecks the endpoint and pin/catalog resolution after authorization
and before publication, so a concurrent endpoint edit cannot dispatch or persist a stale candidate.
The transient descriptor copies the account's model with the candidate API and endpoint,
maxTokens: 16 and reasoning disabled, and sends the one-token prompt Hi without tools.
completeInferenceAttempt in src/brain/inferenceAttempt.ts is shared by the probe, configured
inference and RelayClient; it performs the existing spend admission, collects HTTP and stream
attempt evidence and records paid usage through recordInferenceProviderUsage.
It never changes the registered model or retries a probe.
The ladder tries Responses before Chat Completions. A confirmed HTTP 2xx records a detection,
even if the 16-token budget produces only reasoning, a length-truncated reply or no answer text.
Ordinary inference still requires usable text; only a probe asks whether the route exists.
A lower candidate records a downgrade only after every higher candidate proved route-absent in
that run. The classifier returns route-absent/stop for bare HTTP 404, 405 and 501 without a
structured model/error code, or HTTP 400 with the verified top-level body type
ModelProtocolUnsupported reported by Opencode. Structured model_not_found, generic 400,
rate limits, server failures and transport timeouts/resets cannot establish protocol absence.
No error-message text participates.
Transient, limited, unknown or aborted attempts stop the ladder, record no detection and log
provider.wire_api_probe_deferred. Automatic callers retain the resolver's default and retry the
probe after a process-local 30-second backoff. Refusals and an entirely absent ladder also keep
only process-local diagnostic status and use the same backoff. They never fail the initiating
chat or inference request; the real request reports its own authentication or provider error.
Administrators can see refused/deferred outcomes in status and pin an API. A successful
proof logs provider.wire_api_detected or provider.wire_api_downgraded.
A real inference failure with route-absent clears only a detected auto route through
invalidateWireApiDetection, logs provider.wire_api_invalidated and surfaces the original failure
without replay. The next request can probe again.
The admin-only API lives in src/api/routes/brainProviders.ts:
GET /brain/providers/wire-api/statusreturns{ providers: [{ providerId, models }] }. Each model hasmodelId,effective: { api, source },choices, an optional validdetection: { api, unsupported, checkedAt }and optional process-localprobestatus. WithproviderIdand repeatedmodelIdquery parameters, it projects that provider's offered model IDs before selection is saved. An empty requested list returns no model rows. The read changes no configuration and sends no inference requests; native models need a registry descriptor.DELETE /brain/providers/wire-api/detectionrequires{ providerId, modelId }, clears only that model's detection and releases its process-local throttle/status.ConfigStore.clearWireApiDetectionandclearWireApiProbeStaterequire the same model identity. They preserve explicit pins and every other model's observation. There is no manual probe route.
createCoreInference constructs the same wireApiProbe and invalidateWireApi closures for
configured inference and chat startup. Dispatch remains offline. A background detection
publishes a new configuration revision; later request admission resolves the updated descriptor,
and the existing revision guard prepares a fresh decision if publication races admission.
Azure hosted search probe
POST /brain/providers/hosted-tool-search/probe validates the configured Azure provider, model
and explicitly named connection, then calls BrainService.probeHostedToolSearch with the
authenticated caller and the stored credential generation. probeAzureHostedToolSearch admits
each HTTP request through authorizeAttempt with that user principal and pinned connection.
It resolves credentials only after admission and uses the authorized model's Azure endpoint.
A paused, revoked, exhausted or regenerated connection stops the probe without failover;
revocation between discovery and replay prevents the second request. Missing brain wiring
returns 503. Authorization failures return a structured error and preserve the prior capability
verdict. The existing synthetic search and replay payloads remain unchanged, with at most two
requests and a 60-second timeout per request. This raw protocol probe does not compose a
conversation or execute a production tool.
Provider accounts (connections) and routing
A provider entry is the LOGICAL identity — its id is what provider/model references, grants, role pins and
workflow pins name — and the accounts it can run on are its connections. This is the one vocabulary the
store, the brain runtime and the browser share: src/shared/providerAccounts.ts (import-free, so the browser
may type-import it without pulling daemon runtime code in).
The same import-free module owns wholesale provider-edit preparation, mirrored byte-for-byte in
web/lib/providerAccounts.ts. ConnectionPatch and ProviderEdit<PublicProvider> project a caller's
public shape without restating its optional provider fields. Setup narrows the key type to string;
settings also accepts explicit null. stripProvider removes only each connection's read-only
apiKeySet: absent apiKey stays absent (preserve the stored secret), while an explicit key or null
survives. It copies the provider, connection array and connections, retaining other nested references.
upsertProvider(providers, entry, 'merge' | 'replace') preserves list order and appends a new id.
Setup's upsertBrainProvider chooses merge (existing settings first, then the complete setup entry);
the web's providerEdits.upsertProvider chooses replace (the entry itself, including deliberately
omitted optional switches). Neither policy deep-merges accounts or routing, mutates its inputs,
normalizes values or changes the chosen default connection. takenConnectionIds collects all
accounts into an independent set in list order. These operations are linear in the provider/account
list and own no HTTP, config revision, cache, validation or error handling; those stay with their
existing callers and the store.
BrainProviderStored.connections(elowen-db, defined inpackages/db/src/repositories/types.ts) is the stored list andBrainProviderStored.routingthe provider's typed policy. The public DTO replaces each connection's secret withapiKeySet, andConfigStore.brainProviders()is the ONLY daemon-side read that carries plaintext secrets. Called with a patch it resolves them: a connection that names noapiKeykeeps the one stored under the same connection id, while an explicitnullclears it. The old provider-levelapiKeyis gone, so there is no second place a key could live. A real key replacement or explicit clear atomically increments the connection's stored generation in that same config commit; rename, omitted key and identical key keep it. Submitted generations never override the stored identity. OAuth login/logout retains the existingbumpConnectionGenerationowner.tests/daemon/keyAccountIdentitySave.test.tsverifies a warm old pin is refused and a replacement request runs on the new generation.- A connection carries a globally unique immutable
id, its ownlabel,enabled(the operator's PAUSE switch for that account — not a disconnect: credential, settings and history survive, and its maintenance refresh keeps running) andspendLimitMicrousd(see "Account spend limit" below; absent ornullmeans no limit). The accounts of one provider are an INTERCHANGEABLE pool: who may run on them is the provider/model grant the operator already configures, and an explicitnullpayer (instance work) needs no permission at all. routingis{ enabled, defaultConnectionId, idleRebalanceEnabled, idleRebalanceMinutes, minGainPercentPoints }, defaulting to off, the converted account as the default, idle rebalancing on, 60 minutes and 10 percentage points (PROVIDER_ROUTING_DEFAULTS). The store codec inpackages/db/src/config/codecs/store/config/brainProviders.tsowns routing bounds throughclampProviderRouting;ConfigStoreuses the same codec when saving a default account. A default account that is not one of the provider's own connections is dropped tonullinstead of being kept as a dangling pointer.- Routing OFF means exactly ONE choice, the named
defaultConnectionId, and no hidden failover: the default is not privileged, it passes the same checks as any other account (the pause switch, the caller's provider grant, authentication, the model's availability, a known limit), and a default that fails them leaves the provider UNAVAILABLE instead of moving the request somewhere the operator did not choose. A provider converted by the migration has one account, and the migration names it as that explicit default, so a migrated installation never relies on a guess. idleRebalanceEnabledis the operator switch for VOLUNTARY idle rebalancing and is separate fromenabled: with routing on, a conversation may move to a less-used account only after the idle threshold and only while this is on. Forced moves (an exhausted, revoked or disconnected account) are not rebalancing and ignore it.- Voice attempts use
operation: 'voice'at the existinggrantedseam inbrainCore, not a chat-model entry inusers.allowed_execs. The payer must holdcan_use_voice, voice must remain enabled, and current voice Settings must name this exact provider and one of its four configured models. Both the initial decision and admission's transaction-bound repeat read enforce this rule, including pinned attempts: revoking access or moving the configured pair refuses the old route even for an all-models account. Live calls, third-party calls, browser/plugin dictation and platform speech/transcription all reach this owner throughcreateVoiceRoute. Their existing identity/plugin checks and the selector's pause, credential, catalog, quota and spend checks remain independent. No model grants are added to user data.tests/voice/accountAuthority.test.tsexercises this rule through the real core and commit boundary. - An attempt may carry ONE configured-work purpose,
purpose: 'dashboard-digest': the dashboard digest names its own job, whose provider and model come from Settings rather than from the caller's pick, so the seam asks the owner for that purpose instead of the caller's interactive model grant. The owner answers only while the CURRENT configuration still resolves the digest to exactly that pair, and the commit guard repeats that read inside the transaction. It is an identity of configured work, never a way to skip a grant: an ordinary session or sessionless request that names the very same provider and model carries no purpose and still needs the caller's own grant, andcheckpointSourcehas no such exception at all. Only the internalcreateDashDigestInferencefactory sets this purpose; public and plugin inputs do not expose it.tests/daemon/digestAccountAuthority.test.tsexercises the actualbrainCoreowner and factory: billed digest work without interactive grants, ordinary same-pair requests with and without a session, checkpoint denial, live route races and unchanged account pause, quota, payer and generation checks. BrainConnectionRouteinsrc/brain/providerConnectionRouting.tsis the ONE authorized selection of one attempt (providerId,connectionId,generation,model): the account selector produces it and every real request path consumes it. Routing off is one branch of that same selector, not a second resolver a caller may reach for; no caller re-derives the account from a sticky binding after dispatch.ConfigStore.migrateProviderAccounts()(boot, inbuildBrainCorebeside the other config migrations) converts every stored row in place: it adds the connections and the routing policy, moves the old provider-level API key into the first connection, and materializes a connected OAuth account that has no stored entry. An OAuth connection reuses its credential-store key (the built-in provider id) as the connection id, so NO token moves betweenbrain/auth.jsonand the database. Provider ids, labels, models, model references, grants, the conversation default and the per-model overrides all survive, and a second run writes nothing. Where an API-key provider already occupies a connected account's built-in id, that provider keeps its id and the shadowed ACCOUNT is materialized beside it under a free provider id while its connection still keeps the credential key — no stored reference is redirected.- The account dimension is part of the KEYS, not an attribute beside them. PostgreSQL declares these
identities in
packages/db/migrations/0001_baseline.sql:usage_by_provider_dayis keyed(day, user_id, provider, connection_id, model),provider_quota_samplesis keyed(provider, connection_id, window_minutes, sampled_at)andbrain_provider_requestscarries the account each attempt was dispatched to (connectionId, exposed on the diagnostics DTO). Every row is copied with its OWN account, so a reading recorded before accounts existed stays''— historically UNASSIGNED, never attributed to an account that exists today, and never folded into another account's figure. The provider, user and monthly sums keep aggregating ACROSS accounts exactly as they aggregate across models; one account's figure is the same counter narrowed byconnection_id, not a second counter.
Skill review pinned connection and weekly guard
The optional personal skill reviewer consumes the settled request's exact BrainConnectionRoute, including provider, model, connection and generation, through the existing authorized-dispatch seam. It cannot rebalance or fail over to a different account when that source becomes unavailable. A missing source connection or generation change returns revoked; a lost exact request snapshot returns stale-source. Credentials are resolved live for the captured connection, never stored in the fork snapshot.
The host callback checks live authority and quota before review admission and again immediately before provider dispatch. It reads ProviderQuotaStore.current({ providerId, connectionId, generation }) through the existing usage dependency. It does not fetch provider usage at settlement, introduce a poller or average connections. The pure skillReviewQuotaReason in src/brain/service/skillReviewQuota.ts owns the weekly verdict:
- OAuth subscription evidence must contain a window with
kind === undefinedandwindowMinutes === 10080. - A finite
usedPercent >= 90blocks withquota; 89.9% passes this guard only with current evidence. - Missing/non-finite usage, future-dated or old
fetchedAt, and an expired reset areunknown-quota. Freshness uses the sharedUSAGE_COMPARABLE_MS. resetsAtis Unix seconds, whilefetchedAtand the freshness clock are milliseconds. Compare resets withnow / 1000. An already-high reading whose reset is still in the future remainsquotaeven when its sample is stale. A reset that has expired requires new evidence.- API-key routes have no subscription-weekly restriction. Normal provider/model/account grants and user/account spend admission remain mandatory.
A refused optional review has zero provider requests and writes and retains its pending plugin cadence for a later eligible exchange's new source. It does not delay the next user turn, schedule a retry of the old source or use another model. The source-shape gate supports Anthropic Messages, Codex Responses, OpenAI Responses and OpenAI Completions; there is no separate provider-measurement allowlist. API eligibility never overrides source, grant, quota or runner admission checks. Unsupported shapes and stateful Responses deltas return unsupported-route, and provider-hosted tools return hosted-tools. The review preserves every non-history wire field, including the parent's cache key, while extending the exact inherited history. See the learning contract and source shapes.
Account spend limit
An administrator may cap what ONE account spends in a UTC calendar month, the same month a user's own limit
counts in. The limit is spendLimitMicrousd on the connection: whole micro-USD within SPEND_LIMIT_MICROUSD
(src/shared/spendLimit.ts, byte-mirrored to web/lib/spendLimit.ts, the ONE bounds constant of every spend
limit: users, presets and accounts), refused by the PUT /config schema outside it and read as null by the
store codec when absent.
- Spend.
connectionSpend(providers, connection, now)insrc/brain/session/accountSpend.tsreturns{ spentMicrousd, limitMicrousd, resetsAt }. The spend isUsageProviderStore.spentOnConnection: the list-price counterusage_by_provider_daynarrowed to the account'sconnection_idover the current month, across every user it served, read throughidx_usage_by_provider_day_connection(declared bypackages/db/migrations/0001_baseline.sql;packages/db/tests/usage/plans.test.tsholds the plan).utcSpendMonthis the one month computation the user gate and the account share. A row with no price adds nothing, as for the user limit. - Enforcement.
ConnectionCandidatecarries thespendfact (the dispatch seam'sspenddep, wired toconnectionSpendinbrainCore) andunpricedModel, both filled incandidateForAND in the commit guardstillAuthorized, so the limit is judged before every attempt and again inside the admission transaction.connectionIneligibilityanswersspend-limitright after the quota checks whenspent >= limit, or when a limited account would serve a model with no list price on that account (its requests would never raise the spend; the same rule as the user limit'sunpriced_model).listPriceinauthorizedDispatch.tsreads the price per operation: chat from the account's own registry descriptor, images from PI's image descriptor or the endpoint's reported/modelslist, embeddings from the same listmodelOfferedreads. The authorized image and embedding model carries thatprice, andimageService.tsandembeddingCharge(src/embeddings/embeddingService.ts, the worker path included) record the provider's own charge when it reports one and that list price otherwise, so a request on a limited account is never recorded without a cost. Voice is never judged here: its price table refuses an unpriced voice model before any request. Nothing else is added: routing OFF reportsdefault-unavailablewith the account inblockedBy, routing ON skips the account and moves a bound conversation withpreviousLost: 'spend-limit'(ConversationAccountChangeReason). Chat, sessionless inference, embeddings, images and voice all reach it throughauthorizeAttempt. - Readable refusal. When exactly one account blocks and its reason is
spend-limit,accountSpendLimitNotice(src/brain/authorizedDispatch.ts) builds theaccount_spend_limitChatErrorNoticewith the account's label and the next month start. The spawner throws it asConnectionSpendLimitErrorat spawn, and the per-step check returns it as the refused step'sspendLimitNotice, the same carrier the user limit uses, so the conversation and a goal name the account and its reset rather than a provider error. Several blocked accounts keep the plain text listing each reason. - Limits. A running turn is settled after it finishes, so it may cross the limit by its own cost, exactly like the user limit. A live session whose account reaches its limit mid-session is held back at its next step check, before anything is sent; with routing on the account failover continues that same turn on another eligible account (see the account failover below), with routing off the step is refused. A voice call is admitted once and runs to its end.
- Projection.
GET /brain/rate-limits/alladds aspendwindow (connectionSpendUsageinsrc/brain/providerUsage.ts) for every visible API-key account (a custom endpoint and the Command, OpenCode Go and MiniMax key accounts): alone, or after the provider's own windows when the account also has a usage service, so Command keeps its editable spend limit beside its credit windows. An OAuth account gets it only without a usage service, since its subscription meter is its limit. The routed entry sums them (see "Provider usage rails"). Every recorded charge announces the charged accounts asprovider-usageevents, so the clients re-read the window without polling (see "Provider usage rails"), voice charges and charges a delegated turn records inside a sub-agent runner included.connectionConsumptionandprovenLimitskipspendwindows, so the limit is never judged twice. Settings → Elowen AI draws the window throughusageWindowMeter; the meter lists (drawnUsageWindowsinweb/lib/usageMeter.ts) leave an unlimited spend out beside the provider's own windows and keep it alone as "unlimited", and a spend with a limit always draws. The drawer shows a Spend limit row in the account's group of the provider drawer only where that window exists; the row is the sharedSpendLimitSliderand saves on commit throughpatchConnection. The CLI rail printsspend $12.30 / $50.00orunlimited.
Authorized dispatch, routing state and the commit boundary
src/brain/providerContracts.ts owns the operation, prepared-runtime and dispatch-result types consumed
by the runtime owner, dispatch, spawner, image service and sessionless inference. It contains type imports
only: consumers import data contracts from it and executable behavior from the owning runtime module.
Provider kinds come from the import-free src/shared/providerAccounts.ts, shared with its browser mirror,
rather than from the configuration service.
The voice catalog projection voiceProviderAccounts lives in the existing store codec
packages/db/src/config/codecs/store/config/brainProviders.ts. It projects every stored connection for catalog eligibility and
pricing; OAuth rows expose no API key. VoiceServices.voiceProviderPredicate supplies catalog eligibility
to the settings and calls APIs. Every actual voice request still authorizes its account through voiceRoute.
src/brain/authorizedDispatch.ts is THE seam every real model request goes through (chat, retry, compaction,
secondary inference, embeddings, images). It collects the provider's accounts with the facts their owners report
(the credential state, the model verdict from the account's OWN catalog, the last allowance snapshot the poller
produced, the durable assignment and limit state), hands them plus the requesting identity to
selectConnectionRoute, commits the decision and resolves the model on the chosen account. Callers pass a
provider, a model and who the request is for; nobody assembles candidates or an admission
locally, and there is no second account selection anywhere — the admin probe takes an explicit connectionId
for the same reason.
The spawner passes the session factory's account-limit callback through BrainService.limitConnection
with the route's provider id, connection id and validated reset. A missing checkpoint source provider refuses
model-switch preparation with native_checkpoint_conversion_failed before source or target transport.
Pinned account refusals retain their concrete eligibility reason in the readable session error.
What a FAILED attempt proved is decided in ONE place beside this seam: classifyProviderFailure
(src/brain/session/providerFailure.ts) reads only what a transport can PROVE — an HTTP status, a standard
Retry-After, a validated JSON error body and the dialect's own parsed SSE/WebSocket events — and answers a
kind, an action, a structured limit reason and a resetAt only when the provider really stated one.
src/brain/session/attemptEvidence.ts is where those facts are collected: its fetch records the status, the
headers and a bounded, validated copy of a FAILED body, event() records one dialect limit event, abort()
states that the CALLER stopped the attempt, and transportError maps a failure by its STRUCTURED code, so
only a name-resolution or connect failure is provably pre-send. A 429 with no reset excludes the current
attempt series only; a durable limit needs a validated reset; an open outcome (a timeout, a reset, an
unclassified failure) is never repeated. withAttemptEvidence scopes a collector to the attempt that is
running, and a caller holding it passes it explicitly instead. The session path classifies its own attempts,
and the image transport builds ONE collector per render and reads the verdict from it
(src/brain/imageService.ts); no caller parses an error message for any of this.
The identity is EXPLICIT:
{ kind: 'user', userId }or{ kind: 'instance' }. A missing identity is refused (no-principal) rather than treated as instance work.No network work and no secret resolution happens in the decision: preparing an account's runtime restores persisted catalogs offline, the allowance figures come from the shared durable
ProviderQuotaStore.current, addressed by provider, connection and current generation. Only the daemon's existingUsageServicepublishes fresh snapshots; a warm runner reads the same owner and starts no poller, and the grant answer comes from the seam that already owns it (isExecAllowedForUseroverconfiguredBrainProviders).Internal
beforeAdmission(attempt)receives the exact prepared target runtime and authorized model before any target admission. The real spawner uses it to convert throughcheckpointSourceon the old durable affinity and persist the portable journal cut. Callback failure propagates with no target assignment or binding. Selected and pinned attempts share this boundary. If the configuration revision changed at target admission, a fresh decision invokes the callback again with its current descriptor and target. The spawner re-reads the journal, so a persisted portable cut is reused rather than summarized again. After the await,ProviderConnectionsRepository.admitchecks the original revision and live grant plus the target's current credential, generation, pause, model and quota facts inside its transaction. No network or async work runs inside that transaction. This callback is internal, with no public/plugin input. That transaction is READ COMMITTED and serialized by the settings, payer and account-state row locks that spend settlement shares, so concurrent admissions on one account queue instead of failing; see "Provider admission and money" inpackages/db/contract/concurrency.md.tests/brain/session/checkpointAccountConversion.test.tsproves the source/target flow; central grant tests cover callback failure and target changes on both attempt branches.The decision is COMMITTED before the dispatch through
ProviderConnectionsRepository.admit, guarded by the settings revision and by a RE-READ of the caller's live grant inside the write transaction. The revision is captured BEFORE the candidates are collected: preparation is asynchronous, so a settings write landing in that window (a pause, a re-login) must invalidate the decision at COMMIT rather than be absorbed by a revision read afterwards. The store writes nothing on a conflict. When that admission failed and the revision moved, dispatch prepares a fresh authorized decision with the original request: principal, purpose, operation, provider/model, pin and exclusions all remain exact. This includes a concurrentConfigStore.setWireApiDetectionwrite; the new decision resolves the current descriptor rather than surfacing the stale revision error. A pinned account retains its original generation and refuses a replaced login; an unpinned decision may select a currently eligible login. Current grant, pause, catalog and quota facts are checked again, including the transaction guard. A conflict with an unchanged revision, runtime-acquisition failure or checkpoint callback failure propagates. Only a failed target admission can start fresh preparation; no provider request was sent on that decision, and no transport failure is replayed. Payer spend admission remains with the original caller, and every real request still passes its existing spend gate.tests/brain/authorizedDispatch.test.tscovers detection writes during preparation and checkpoint callbacks for selected and pinned attempts; grant and digest tests cover current eligibility and spend.Accounts are compared only through LIKE quantities (
connectionConsumption): identical, non-empty sets oftimewindows ormeter:<kind>:<unit>groups. Any difference, an empty set or a reading nobody refreshed makes the accounts NOT comparable, and the choice falls back to the least-recently-assigned order. An unknown or stale figure is never read as zero consumption, and an account's own reached limit (including an OpenRouter key cap of zero, or a window at its cap with a reset still ahead) blocks it. The OpenRouter spending-limit source reports a window length only, so no reset instant is invented for it.A voluntary idle move happens only with routing on,
idleRebalanceEnabled, past the longer of the idle threshold and the route's cache protection, and only towards a comparable account that is better by at leastminGainPercentPoints. Forced moves (paused, revoked, exhausted, a new login, a removed account) are a different question and ignore those switches.ONE
operationper request, named by the CALLER and never inferred here:chat,image,embeddingsorvoice. Only the catalog that can answer for that operation is consulted — the chat registry forchat, PI's typed image catalog or the endpoint's reported list forimage(a configured endpoint declares image models there, never as chat models), the endpoint's reported list forembeddings(an embedding-only model is never chat-eligible, so the chat registry cannot answer for it) and the models the voice settings name forvoice. Nothing else differs by operation: the same pause state, identity, settings revision, provider/model exactness and spend admission apply to all four.tests/brain/authorizedDispatchOperations.test.tsdrives all four, the refusals and both entry points through the real store, the real runtime owner and a REAL/modelssnapshot.The seam returns the model TYPED by the operation the caller asked for: a chat model with its compaction parts, an image model carrying PI's descriptor where the account's runtime has one, and the model id for the embeddings and voice transports. An unsupported kind, or a model no catalog of that account carries, is refused with the account named in
blockedByinstead of being handed over as another kind.checkpointSourceis the second entry point of the same seam: it resolves the account a native checkpoint ALREADY belongs to from the checkpoint's stored identity (provider, account, generation), writes NO binding into the routing store and never picks another account. A stale generation (regenerated), a paused or revoked account, a reader without the grant and a provider or account that is gone are all refused.An attempt may be PINNED to one account and one login (
pinnedConnection: { connectionId, generation }). A caller repeating work that already ran — a retry after a transient failure, a re-authorization before a further request on the same account — states the account it must continue on, and the seam either runs THERE or refuses: it never consults the selector, so no other account can be chosen for it, and a refusal writes no assignment and leaves the routing state exactly as it was. The account is still judged by every CURRENT fact (the live configuration, that account's catalog for the operation, its pause switch, its credential, its allowance and the caller's own live grant re-read inside the commit), and it is admitted atomically for itself under the same revision guard a selected route uses. The refusal vocabulary is about that ONE account:regeneratedwhen it was signed in again,not-configuredwhen it is gone,already-attemptedonly when the series really excluded it, and the account's own ineligibility reason otherwise. With routing OFF the operator named ONE account, so a pin must name that one: it never walks around the explicit default. A pinned route reports no move, because nothing moved.checkpointSourcestays what it is — a source CONVERSION that resolves an existing checkpoint's own account and writes no binding — and a pin never replaces it.A repeat on the SAME account is expressed as a PIN (
pinnedConnection), never as a fresh selection: the series states the account and the login it is retrying, so it cannot discover that the same account is no longer the selector's answer and quietly stop instead of repeating.A bounded, request-local series is the ONE owner of repeating a real request on another account (
src/brain/session/attemptSeries.ts, at most three accounts and one repeat of the same account): every attempt is authorized and admitted AGAIN through this seam, spending and accounting run per attempt, the provider and the model never change, and what may move on comes from the attempt's own verdict — a PROVEN account limit (or a 429 without a stated reset) excludes that account for the rest of the series, a transient failure repeats on the same account, and an unknown outcome, a timeout or a refused request stops it. A proven limit with a validated reset is written to the durable routing state through the one writer the daemon owns (ProviderConnectionsRepository.limit, passed asrecordLimitto the sessionless clients andlimitConnectionto the run boundary); a limit nobody stated never becomes durable. The series awaits this write before authorizing another account; a rejected write rejects the request without another attempt. Session turns never run this series: a transient failure there is PI's native retry (Settings → Limits), and only a proven account limit moves the session, so no second retry loop exists. The payer is admitted BEFORE each attempt's account is chosen, so a refused payer writes no routing admission. The child worker that runs one embedding batch posts to an EXPLICIT base URL the daemon resolved and runs no series, so itsrecordLimithook fails loudly instead of pretending a limit was recorded; and a batch whose configured endpoint moved while the request was in flight is REFUSED, because that is a different vector space and filing its vectors under the old identity would silently mix two.The helper/image/embedding series captures one strict canonical
RetryPolicyand limits the whole request, including account failover, tomin(4, 1 + maxRetries)dispatches. Every repeated admission waits through the canonical delay and abort-aware wait; a typed transient Retry-After constrains its same-account repeat without clipping to the exponential cap. This does not add retry eligibility or change the existing pin, spend and per-attempt authorization rules. Native PI chat has a different owner and does not honor final HTTP Retry-After with SDK retries zero; see retry policy and HTTP-only dispatch evidence.The identity of an IMAGE render is the HOST's, not the transport's:
ImageServiceawaits its asynchronousidentity()reader before spend admission and passes the resolvedRoutingPrincipalinto account authorization. It refuses anullprincipal, so a plugin render that established no paying account is refused before any admission or provider request instead of being billed to the instance. The host answers from the same payer seam a turn uses (the payer resolver over the session's own row), and INSTANCE work needs an explicit statement from the plugin host context — a missing session is not one.Embeddings go through this seam too: the client authorizes each batch as
operation: 'embeddings', reads the authorized account's own endpoint throughrouteCredentials, and takes fromproviderEndpointonly the provider's endpoint, for the vector-space identity that must survive accounts coming and going; that lookup resolves no credential and chooses no account. Both readproviderBaseUrl(src/brain/config.ts): a stored endpoint's own URL, the fixed OpenRouter API for an OpenRouter login (whose storedbaseUrlis empty), and none for other logins, so a vector is always filed under the endpoint that produced it. Whether an account offers an embedding model is read from that endpoint's own reported list; for an OpenRouter login that is OpenRouter's public embeddings list (OPENROUTER_EMBEDDINGS_ENDPOINT). Every embedding request statespurpose: 'configured-embeddings': the instance embedding model (Settings, provider and model, no explicit base URL) is admitted for any account without that account's own model grant, because memory recall and indexing run it on every account's behalf; any other embedding model still needs the caller's grant. The worker path authorizes once, in the daemon, and hands the child one endpoint with its key, so every request it makes belongs to the account admitted here. An embedding REFERENCE in the settings is guarded by the account's KIND (an OAuth account that cannot embed is refused); a reference to an account the same write removes is stored verbatim and never re-pointed at another account, and it fails loudly at the embedder's next call instead of degrading quietly.A conversation may CONTINUE a step that provably ran out of the allowance of the account it was dispatched to, on another eligible account of the same provider.
src/brain/session/accountFailover.tsis the one owner of that decision andinstallAccountFailovermakes it atagent_before_settle, after PI's native retry and BEFORE any plugin hook, so a plugin's fail-open can never decide it. The owner re-authorizes through THE seam with the accounts this series already used (excludedConnectionIds, so no account runs a step twice and the series ends on the seam's own refusal instead of a counter), records a PROVEN limit throughBrainDeps.limitConnectionwhen the provider stated a reset, drops the state of the account it leaves, and only then swaps the runtime behind the session's model (routedRuntime.setPeer). Only a verdict whose action isfailovermoves anything: every other failure is left to PI's native retry on the same account, which classifies it itself and is bounded by the Limits budget (retryMaxRetries,retryBaseDelayMs). A move drops ONLY the failed assistant step from model context (context_editwithreplacement: null), so the tool calls and results that attempt already executed stay in the prefix and are never run again. The policy and its boundary handler are installed for every ordinary account route, a single account included (failoverPossibleinbrain/service/spawner.ts): its proven limit is still recorded, and the seam's refusal then ends the turn. A checkpoint source conversion never installs it. A conversation whose active branch holds a native checkpoint the selected account cannot replay (nativeCheckpointReplayable, a different login) does not move: converting it needs the login that wrote it, which is the account the move leaves, and a conversion cannot run inside the settling turn. The failover'sbeforeAdmissionhook (carriesHistoryinfactory.ts) refuses before the seam commits, so the binding and the checkpoint stay on that account, the failed step ends the turn, and the conversation continues there once the account may serve again. This holds for a provider limit answer and for a held account alike.The per-request account check (
createRoutedSessionRuntimewithverifyAccountRouteinbrain/service/spawner.ts) feeds the same owner. When the pinned re-check refuses the bound account for a limit of that account alone (quota-limitedorspend-limit), the refusal carriesheldand the routed runtime records it on the running attempt's evidence (AttemptEvidence.held). Nothing is sent; the classification isaccount-heldwith actionfailover, so the failover re-authorizes exactly as for a provider limit (series bound,excludedConnectionIds,previousLost, state reset, swap, account-changed chat event with the held reason) but records no new limit, since the hold already is the account's own fact. With routing off the seam refuses every other account and the step's readable refusal ends the turn. Any other refusal reason (paused, revoked grant, new login, model gone) still ends the step without a move.tests/brain/session/sessionAttemptOwnership.test.tsproves both reasons end to end, routing on and off.What must NOT cross an account move is dropped by the same owner (
resetAccountBoundStateinsrc/brain/session/factory.ts), before the next request can reach the new account: the Responses continuation chain (chain.fail(), because a remote response id was issued by the account that left), the cache-watch baseline and its pending snapshot (they describe the old account's prefix), and the Codex turn state (codexRouting.resetTurnState(), a backend affinity of the old login). The Codex routing jar insrc/brain/session/codexRouting.tsis keyed by the ACCOUNT its cookies were issued to, so one account's routing cookie can never be replayed on another account's request, and the Codex access token resolves through the ROUTED runtime so it follows a move.The evidence a failover reads is produced where the attempt is opened:
ProviderRequestRecordercreates oneAttemptEvidenceper provider call, publishes it for the transport below, marks the send and the caller's abort, and keeps it as the session's last attempt (lastAttemptEvidence). An attempt that left no evidence moves nothing.tests/brain/session/accountFailover.test.tsdrives the owner over the REAL seam, routing store and runtime owner;accountFailoverBoundary.test.tsproves what the model sees next;accountIsolation.test.tsandcodexRouting.test.tsprove the state that must not cross a move; andtests/brain/sessionAccountRoute.test.tsproves the wiring (the policy and the boundary are installed for every ordinary account route).The account a conversation runs on reaches the transcript as the
accountfield of the existing chatmodelevent (ChatEventDetails, shapeConversationAccountViewinsrc/shared/wireContract.ts): the serving account's id and label plus, once the conversation has moved, a bounded reason.rebalancedis the one VOLUNTARY move, where nothing was lost. The previous account is named only while the reader may still reach it — otherwise the label is dropped and the reason kept — so a transcript never reveals an account its reader cannot access, and a conversation that never moved carriesnull. The seam STATES the change (conversationAccountChangeReason) for the attempt it just committed,brain/service/spawner.tscarries it on the session, andBrainServicerecords it on themodelevent (brain/service/lifecycle.ts), where the label is shaped once throughconversationAccountViewandmayNameanswers for the READING account. Both helpers live with the seam (src/brain/authorizedDispatch.ts) and the shape itself in the wire contract (ConversationAccountView,ConversationAccountChangeReason), which is what the web imports type-only; the mirroredsrc/shared/providerAccounts.tsstays importless and holds no copy of it.The conversation binding lives in
brain_conversation_connectionsand the per-account assignment/limit state inprovider_connection_state(ProviderConnectionsRepositoryinelowen-db). Its PostgreSQL implementation inpackages/db/src/providerConnections/index.tslocks the settings, payer and account state rows in one admission transaction, so daemon and runner requests share the same authority. The binding is session-scoped: the journal session sweep drops it with the conversation on delete, account wipe,/clearand the retention sweep, while the per-account state survives, because an assignment order and a limit belong to the ACCOUNT.A login or a logout applies ONE identity rule, in one place (
src/brain/accountIdentity.ts): it retires the account's prepared runtime FIRST (closing every scoped credential view of the old identity, so a callback still in flight cannot write over the new one) and then awaits the durable generation write (ConfigRepository.bumpConnectionGeneration) before invokingafterChange. A failed write rejects the identity change and does not run that effect. Whatever else a login implies (a personalized catalog to re-fetch, the conversation default to reseed) is the caller's part, passed in as an after-change effect, so the order the rule depends on cannot be re-stated by a second caller.tests/brain/accountIdentityGeneration.test.tsdrives the REAL manager pipeline (login, stale callback, logout) over this rule andtests/brain/authorizedDispatch.test.tsdrives the dispatch seam through its real store, configuration and runtime owner. That generation is what makes the change real across processes: a forked runner reads it from the shared database and refuses a route authorized against the previous login (tests/brain/accountIdentityGeneration.test.ts). A provider save renews what was prepared from the previous list throughConfigStore.onProvidersChanged: a changed account's runtime is dropped and a removed one is retired.The rule is proven across PROCESSES, not only in one:
tests/brain/connectionRuntimeAcrossProcesses.test.tsruns real Node child processes over the native PIgetAuthand the shared file credential store (a local HTTP fixture holds the first refresh through IPC) and shows that two processes of ONE account count as one spend, that two connections stay isolated, that a logout waits for an in-flight refresh and the runner's nextgetAuthis null, and that a relogin as the later writer wins while a running runner sees the new identity. It is not a live provider login, and it does not claim that an already running runner reloads a generation written to the database.An account's reported
/modelslist survives a settings write:ConfigStoreretains the snapshot by the ACCOUNT's own endpoint (the provider's base URL plus that connection's key), which is the key the worker stores and the registry reads, so a save no longer forgets an API-key account's catalog.The poller stays ONE (
UsageService, one instance per ACCOUNT keyed by connection id): each reads its own credential. Its asynchronousUsageAuth.get(nativeId)maps only that native id to its own connection key; other credentials remain invisible, andgetApiKeyresolves through that connection's existing runtime. Every source awaits credential reads incacheKeyandrequests;UsageService.getUsageandcachedUsageawait the cache identity. PI's file-backedBrainCredentialAccess.getremains synchronous.UsageRepository.quota.publish(usage, { providerId, connectionId, generation })records the complete current allowance and the historical time-window readings under the same account owner. Publication checks the configured generation inside its PostgreSQL transaction under the settings-row lock, so a late poll cannot restore a retired identity. The daemon's background pass renews the PERSONAL catalog of every account that already has a prepared runtime, and only such an account: preparation happens in an attempt or a catalog read.
The real consumer of the whole shape today is the store itself: sanitizeBrainProviders normalizes a patch
into it and ConfigStore.publicView redacts it, covered end to end by
packages/db/tests/config/providerAccountsMigration.test.ts (real migration, idempotency, the id collision) and
packages/db/tests/config/configBrain.test.ts. The selector, the routing store and the per-connection runtimes are the
next consumers and are expected to plug into these same names rather than add their own. Verify the seams above through their real
consumers and not through a copy of them: tests/brain/authorizedDispatch.test.ts drives selection, pinning
and refusal over the real store, configuration and runtime owner, tests/brain/authorizedDispatchOperations.test.ts
adds the operation-typed catalogs, the checkpoint-source conversion and the account line,
tests/brain/connectionRuntimeAcrossProcesses.test.ts proves the identity rule across processes,
tests/brain/session/accountFailover.test.ts and tests/brain/session/accountStepVerification.test.ts prove
what a moving account does and does not change mid-turn, tests/brain/session/attemptEvidence.test.ts proves
the evidence a verdict reads, tests/daemon/daemonBootWiring.test.ts proves the host actually wires the seam
(voice route included) and tests/api/brainRoutes/providers.test.ts proves the HTTP surface over it.
Key accounts
BrainProviderEntry.origin and the public BrainModelOption.source describe authentication as
api-key or oauth. brainConfigFromElowen produces those two values and listBrainModels
passes them to GET /brain/models; an omitted origin remains api-key. Provider ids are
independent of provenance, including custom ids named relay. This type narrowing changes
no credentials, provider endpoint, model selection or persisted configuration.
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 supplies the HTTP catalog, next to OAUTH_BUILTIN in spirit. Each kind (opencode-go, command and
minimax) fixes the endpoint, the icon slug and the wire formats offered, and names the id the
catalog historically stored the account under. apiFormats is non-empty: providerDraftForKind
initializes a new key provider from its first format, Chat Completions for Command, Opencode Go and MiniMax.
Optional defaultModels seeds only a newly created provider's explicit model selection; without it the
existing discovery behavior is unchanged. MiniMax seeds MiniMax-M3.1-Flash and MiniMax-M3, both with
tool calling, on the international https://api.minimax.io/v1 endpoint. Its catalog piProvider: 'minimax'
reuses native model descriptors when available. The single offered creation format is Chat Completions;
no unsupported Responses choice is added. The account mark is the official https://www.minimax.io/favicon.ico,
converted losslessly to WebP; model marks remain unchanged. The key HelpTip in Czech, Slovak and English
asks for the international Subscription Key, not a pay-as-you-go or China key.
Existing provider pins and Auto remain untouched by creation defaults. The provider's own id and label are the operator's (see
"Provider accounts (connections) and routing"): the catalog fixes what the service IS, never what an
installation calls it. GET /brain/provider-kinds includes keyAccountCatalog() beside the OAuth
kind catalog. Settings renders Linked accounts from that answer and the configured providers, with
OAuth connection status from /brain/oauth/status; the same provider
record with its icon actions (statistics, test, tool execution), menu, account rows and usage rail serves both. A key
account is connected while its entry holds a key; Add account opens a dialog (key and API format) that upserts the
entry through the ordinary provider save, and Delete account, offered while the provider has more than one account,
removes one after the usual confirmation (a provider's only account goes with Remove provider). Entries with an
account never appear in the AI providers list (web/modules/settings/BrainProvidersSection.tsx).
The catalog route is admin-only, or open during first-run setup, like the other brain settings routes
(GET /brain/provider-kinds in src/api/routes/brainProviders.ts).
Validation sits at two boundaries. The API schema (PUT /config) rejects an unknown kind, a kind on a non-openai
entry, a format the account does not offer, and a malformed connection or routing policy. Persistence
(sanitizeBrainProviders) keeps the kind only on an openai entry and pins the endpoint and the allowed
formats 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
historical entryId on the catalog endpoint, in place; it is a no-op afterwards. Adding Command to the
catalog converts the existing command entry by setting account: 'command' and the catalog label.
The raw migration preserves its provider id, every connection id and stored key, generation, spend
limit, routing, models, API, compatibility and switches. Stored provider/model references, grants
and connection-keyed usage therefore retain their identities without a second migration or usage path.
Per-model wire pins live separately at brain.modelOverrides['<provider>/<model>'].api, as read by
GET /brain/providers/wire-api/status and resolveWireApi. The raw migration leaves the rest of the
settings document unchanged, including those overrides. For example, a provider pinned to Responses
keeps its stored API while command/deepseek/deepseek-v4.1-flash pinned to Completions still resolves
to Completions with source model-pin after migration.
The API check is keyAccountIssue in src/shared/keyAccounts.ts, which the PUT /config schema in src/api/schemas/config.ts runs on each provider entry. Persistence drops an api format the account
does not offer (sanitizeBrainProviders in packages/db/src/config/codecs/store/config/brainProviders.ts). The daemon awaits the existing config.migrateKeyAccounts() repository preparation before publication; runners skip it. Endpoint matching ignores trailing slashes (migrateKeyAccounts in packages/db/src/config/configStore.ts).
Every usage consumer names the account directly. Everything account-shaped —
/brain/rate-limits/all, the session's usageProvider and the limit alerts — names the ACCOUNT's connection id instead,
which is stable across a rename of either the provider or the account. Adding a kind is one catalog row, plus a UsageSource and its apiKeyUsageSources row
when the endpoint reports limits. Command already has both in commandUsage.ts and bootstrap.ts:
its pinned https://api.commandcode.ai/provider/v1 endpoint still selects the same usage source by
host and path, for every connection, including multiple Command accounts. The account kind does
not change that lookup.
MiniMax plan allowance
minimaxUsage.ts joins the existing UsageSource table in bootstrap.ts under
api.minimax.io/v1. Each connection uses the live resolveConnectionCredentials reader through
apiKeyUsageAuth; the cache identity is a digest of that connection's current key. The shared scheduler,
cache, timeout, stale/error handling, quota history and GET /brain/rate-limits/all projection are unchanged.
No credential is inferred from a provider id, and no second poller or UI window shape is introduced.
The official M Plan FAQ documents
GET https://www.minimax.io/v1/token_plan/remains, Authorization: Bearer <Subscription Key>
and Content-Type: application/json. This is deliberately NOT the signed-in-user endpoint in
MiniMax Code's account client:
https://platform.minimax.io/v1/api/openplatform/coding_plan/remains, which uses a login token
and optionally X-Group-Id. Elowen only has the pasted Subscription Key.
The response projection follows that client's fetchTokenPlanQuota and readQuotaWindow:
reject nonzero base_resp.status_code; take the first object of model_remains as the shared coding quota,
not one duplicated bar per model. For current_interval (300 minutes) and current_weekly (10080 minutes),
prefer *_remaining_percent; otherwise calculate remaining percent from *_usage_count / *_total_count.
Despite its name, usage_count is REMAINING, not spent. Used percent is 100 minus the clamped remaining
percent. end_time and weekly_end_time are epoch milliseconds, converted to seconds; absent/nonpositive
resets stay null. Status 3 means unlimited and is omitted rather than treated as a cap. Malformed or absent
windows stay absent; there is no monthly field in this CLI parser, so no monthly window is invented.
A wholly empty result is unavailable through the shared service, never a fabricated zero.
The parser fixture is synthetic and pins the CLI response fields, not a captured customer response. The FAQ documents the key endpoint but not its response schema: parity with the CLI parser still requires a live Subscription Key check. Video-only quotas are outside this coding-account projection.
The OpenAI-compatible API
documents Chat Completions, Bearer authentication and both current coding models.
MiniMax Code's packages/local-runtime/src/model-provider/minimax-api.ts instead selects
Anthropic Messages on https://api.minimax.io/anthropic (international) or
https://api.minimaxi.com/anthropic (China). Its packages/config/src/minimax-model-catalog.ts
marks M3.1 Flash, M3, M2.7 and M2.7-highspeed as tool-capable; the initial Elowen selection uses the
two models in the current public API table. China is not silently substituted for the international account.
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). A sign-in is started for ONE named account, so
POST /brain/oauth/start takes providerId and connectionId, answers 404 for an unknown account and 409
while a login for that account is already live or the service's single loopback listener is bound
(src/api/routes/brainOauth.ts). Sign-in is a device code (xAI, Meta, Kimi Code, GitHub Copilot), a browser
page with paste-back (Claude) 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). 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
(brainConfigFromElowen in src/brain/config.ts).
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. Because that key is permanent, an OpenRouter account is the one OAuth account that can serve embeddings (brainProviderSupportsEmbeddings exported by elowen-db, implemented in packages/db/src/config/document.ts), and a settings write that names any other OAuth account for embeddings is refused.
Categorization endpoint overrides may receive only a stored API-key provider's configured key. A settings write that pairs a categorization baseUrl with an OAuth provider is refused. A stored OAuth
override is ignored at dispatch (src/daemon/coreInference.ts) and uses PI's canonical route instead, without changing
stored settings or handing the account key to the override.
PI's native ModelRuntime is the only authority for the OpenRouter chat catalog. During a catalog refresh in the
background worker it asks GET /models/user (at the provider's configured base URL, with the connected key) and
persists the key's allowed chat models with their context and output limits and prices in models-store.json;
the daemon only restores that list offline. A 401, 403 or 404 leaves PI's unfiltered catalog. Elowen adds no
chat model, filter or metadata of its own: applyModelOverrides only applies operator pins and models.dev
supplements to PI's descriptors, and the retired public all-kinds snapshot is inert. The account picker
(oauthReportedModels in src/brain/models.ts) lists PI's chat descriptors, PI's image descriptors (an id that is
both stays chat-capable and image-capable; an image-only descriptor never becomes chat) and the worker's public
embeddings list, because PI has no embeddings model type and embeddingModelCatalog needs a positively reported
embeddings output for OAuth accounts. Audio, speech, video and rerank kinds are not offered: no caller selects
them. 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. Known non-text-only outputs never enter the host's chat-facing
ctx.listModels list.
Proactive OAuth refresh and shutdown
The daemon starts startOAuthCredentialRefreshLoop in src/brain/oauthRefresh.ts immediately and checks
every minute. It asks the shared PI runtime for Anthropic auth with four hours of minimum validity, so an
idle account rotates before its shorter-lived refresh token expires. Other providers retain PI's normal
request-time refresh behavior. A missing Anthropic OAuth credential produces no rotation or success log.
Only one scheduler pass runs at a time. Target discovery failures are caught by this loop before
account refresh, logged with their original typed cause under auth.oauth_refresh_failed, and do not
escape as unhandled rejections. Discovery has its own third-consecutive-failure counter, reset by a
successful discovery; the next scheduled pass retries without altering per-account counters or cadence.
The four-hour lead can rotate a still-valid access token
while a conversation is working. Chat resolves credentials per provider call; compaction uses the
same runtime resolution for each custom attempt and each native history or split-turn prefix request
through session/compactionModelRoute.ts. Neither path pins a credential across the whole operation.
PI refreshes inside CredentialStore.modify, rechecking expiry under the lock. Elowen's
FileCredentialStore serializes the complete read, network refresh and write across the daemon and
runners, then fsyncs a private temporary file and atomically renames it before releasing the lock.
Concurrent request-time refreshes use that same seam and re-read the rotated credential instead of
consuming the previous refresh token again. PI's default AuthStorage also holds the lock across refresh
and persistence, but writes its file in place; Elowen does not use that store for shared runtime credentials.
Stopping the loop disables future checks and awaits the active getAuth without cancelling its caller.
PI cancellation races only the caller's wait once a refresh has started: the rotation and write can
continue after that promise rejects. Aborting that wait during shutdown could therefore let the process
exit before the replacement refresh token was saved. maintenance.ts joins the loop's stop promise in
beforePause; after checkpointing, shutdown.ts clears the checkpoint exit guard and bounds that wait
by the remainder of the existing five-second checkpoint budget. A refresh that completes within that
budget is persisted before exit. On budget expiry the pause continues its unparkable wait, restart notice
and plugin shutdown instead of reporting a checkpoint timeout or skipping those courtesies. The subsequent
24-second exit guard keeps the combined asynchronous budget below the unit's 30-second stop timeout.
A refresh that outlasts the cleanup budget, second signal or forced kill can still lose an outstanding
rotation; the pause adds no new timeout or configuration and never waits indefinitely.
Failures log the provider and the structured error code through the existing failureCode helper,
with auth.oauth_refresh_failed. Each account reports only its third consecutive refresh failure;
other failed attempts remain local-only, and a successful auth resolution resets that account.
Invalid credential JSON or credential shape remains immediate. FileCredentialStore exposes the
sanitized CredentialFileError with syntax or shape kind; maintenance recognizes it directly
or as PI's error cause without examining messages. Only syntax errors receive the existing bounded
torn-write retry before propagation. For PI auth resolution, oauth identifies
provider refresh/derivation errors and auth identifies credential-store errors. Anthropic currently
puts HTTP status and OAuth endpoint error details only inside its error message, alongside the response
body. Those details are not parsed or logged, nor are error messages, stacks or credential values.
Failures during the stop wait are logged too. The isolated tests/brain/oauthRefresh.test.ts exercises
real PI auth resolution with a fake endpoint, SIGTERM, shared-store contention and redacted error output.
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, the 5-hour, weekly and
monthly windows of an OpenCode Go key account, or the 5-hour, weekly and billing-month credit windows of a Command Code key.
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, commandUsage.ts) supplies the cache key, the requests and the parsing, and bootstrap.ts
reconciles one service per connected account through ConfigStore.onProvidersChanged after provider saves
and OAuth generation commits. An unchanged identity keeps its service; login/add starts the new service,
logout/removal clears its current snapshot and alert, and a paused account remains maintained. The same
maintenance stop unsubscribes reconciliation and drains the producers. tests/daemon/accountUsageLifecycle.test.ts
verifies actual warm bootstrap changes, Codex account headers, Meta identity tokens and an actual second Node
process using the runner's buildBrainCore attach path. GET /brain/rate-limits/all serves every connected account keyed by
the ACCOUNT's connection id — the identity a route, a credential and a usage row already name, so two accounts of one
service cannot share a key (src/api/routes/brain.ts). An administrator receives the whole register; anybody else receives only the
accounts the dispatcher's own eligibility rule would let them run on, read from the account's on/off switch and
the caller's provider grant. A provider with routing on and more than one account also gets ONE combined entry under its
RoutedUsageKey, routing:<provider id> (src/shared/wireContract.ts): per window (kind and length) the mean used percent of
its non-paused accounts in the caller's already filtered map that report that window, the soonest of their resets, money and
request amounts summed and the oldest fetchedAt (meanProviderUsage in src/brain/providerUsage.ts). A spend window
(an account's own spend against its limit, see "Account spend limit") sums spend and limits instead and states its
percentage of the sums; one unlimited account makes the sum unlimited (total: null). The PUT /config
schema refuses a connection id starting with routing:, so the key never names an account. The session's usageProvider
names the bound account, or that combined key when isRoutedUsageProvider in src/brain/providerUsage.ts
returns true: routing enabled and more than one configured connection. Both sessionIdentity and the rate-limit route
use this single predicate. connectionEntry preserves the logical provider's routing switch and connection ids in every
BrainProviderEntry, without sibling credentials, so session status reads the same usage configuration as the route.
The chat rail keeps that key across a failover; no surface reads a provider id or splits a string to do it, and none averages on its own. 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). Each rate-limit request
recomputes the mean from the per-account services' last readings. Both clients look up the returned map by
usageProvider, so updated account readings refresh the mean under the same routing key.
When accounts are read, and how clients hear of it. The daemon owns the timing in one place,
createProviderUsageSchedule in src/daemon/providerUsageSchedule.ts, which the maintenance loop
(src/daemon/maintenance.ts) ticks every 10 s and once at boot. A tick reads the accounts that are due through
syncProviderLimitAlerts (src/daemon/resourceAlerts.ts), which also judges each read account's limit alert. An
account is due at once when its poller is new (a boot, a newly connected account, a re-login that replaced the
poller); every 60 s while a conversation turn or a delegated child runs (BrainService.busy(), turns plus
children); every 5 minutes otherwise; and, after a read that left it with nothing to show, again after 10 s, then
30 s, then on the ordinary cadence. That retry is what repairs an empty or failed first read without a client
asking at the right moment. A poller's own TTL still caps how often its provider is asked:
Meta keeps 300 s because each of its polls mints a key. GET /brain/rate-limits/all never polls: it serves each
poller's last answer (UsageService.current()), so no client read costs the provider anything and the schedule
is the only cadence. A newly connected account is also read once at once by reconcileAccountUsage in bootstrap.ts.
Every change of what that route answers is announced once on the core event bus as
provider-usage { connectionId } (ProviderUsageChanged in src/shared/wireContract.ts, ElowenEvent in
src/api/sse.ts). The producers:
- the poller's
onChange(UsageServiceDeps.onChange, wired inbootstrap.ts) when a poll yields a fresh snapshot, withdraws one (an authorization failure, or the last snapshot aged out) or reads another account behind a switched credential; - a recorded charge, which moves the account's
spendwindow:createAccountUsageHealthinsrc/brain/session/accountSpend.tsfor turns, inference, embeddings and images, andcreateVoiceUsageSinkinsrc/voice/usage.tsfor voice, which writes through its own store; both get the sameannounceChargedcallback inbrainCore.ts. A sub-agent runner builds its own core and bus, sosrc/subagent/runner.tsforwards each such event as aprovider-usageIPC frame (RunnerToDaemoninsrc/subagent/protocol.ts), whichSubagentRunnerHostand the pool hand to the sinkbootstrap.tsattaches (attachProviderUsageSink) to republish it on the daemon's bus; - a provider save that turns its combined
routing:<provider>entry on or off (isRoutedUsageProviderchanges), announced under each of the provider's accounts bybootstrap.ts, whether or not they have a poller.
A cache hit and a failed poll that keeps serving the same snapshot announce nothing. The frame carries
no figures. GET /events delivers it only where the route would show the account: to an administrator, or to a caller
visibleUsageAccounts (src/api/usageVisibility.ts, the one rule the route also uses) admits, judged at delivery.
It is never recorded in the activity feed. The web (web/lib/useElowenEvents.ts) cancels a read of the
useBrainRateLimitsAll query still in flight (it may predate the change, and invalidation alone would join it) and
invalidates the query, on the event and whenever its event stream opens; the CLI (ChatApplication.openUsageEvents)
follows GET /events?presence=0 through BrainClient.instanceEvents and re-reads the map on each provider-usage
frame, on every (re)connection and when the conversation's usageProvider changes. Neither client keeps a timer.
The service owns the TTL cache (30 s, below the schedule's shortest cadence so a due read is never answered from
cache), 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). The route serves the answer of the
last scheduled read, so a withdrawal reaches clients at the next read of that account, at most one cadence later. 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 through its per-connection auth view. 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 30 s cache, 5 s timeout, authorization handling and quota sink apply, with no additional polling or persistence path.
Services whose key lives in Elowen's provider config rather than PI's auth store have one selection rule and one
credential view. bootstrap.ts keeps one table, apiKeyUsageSources, keyed by the host and path of the entry's
configured endpoint (normalizedEndpoint in src/brain/providerEndpoint.ts; an explicit port never matches):
opencode.ai/zen/go/v1 selects opencodeGoUsageSource and api.commandcode.ai/provider/v1 selects
commandUsageSource. The endpoint is the only part of such an entry that names the service; a key account's
endpoint is fixed by its catalog row, so the OpenCode Go account always matches. Its credential view is
apiKeyUsageAuth(source.authKey, () => resolveConnectionCredentials(config, brainCreds, route)) in
providerUsage.ts: that one connection's key is presented as an api_key credential to the source's own authKey
only, awaiting the native configuration repository on every call. Removed or replaced keys take effect
on the next read without a synchronous configuration cache. Adding such a provider is one adapter plus one table row; an entry on any other endpoint
has no usage service and no rail. The service map key is the connection id, and the ordinary 30 s cache, 5 s timeout,
authorization handling and quota sink apply. Both adapters send a browser-like User-Agent (Cloudflare rejects bare
clients with error 1010) and key the cache by a digest of the API key.
OpenCode Go (opencodeGoUsage.ts, see "Key accounts"): one GET <endpoint>/usage with the key as Bearer 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.
Command Code (commandUsage.ts): its documented Provider API has no usage route, so three GETs with the key as
Bearer read the allowance the cmd CLI reads: /alpha/billing/credits, /alpha/usage/summary and
/alpha/billing/subscriptions on https://api.commandcode.ai. windowLimits.fiveHour and windowLimits.weekly are
each {used, cap, exceeded, resetAt} in credits with the reset in epoch milliseconds; they project to 300 and 10080
minutes as used / cap, full when exceeded, and a window without a positive cap is omitted. The billing month
projects to the shared 43200-minute label: used is the summary's totalMonthlyCredits for the billing period, the
grant is that plus what credits.monthlyCredits says is left, and the reset is the subscription's
currentPeriodEnd; it is omitted when one of the three figures is missing or the grant is not positive, and the
two rolling windows still come through when only the summary or the subscription read fails. A positive credits.purchasedCredits is a pay-as-you-go balance
Command keeps serving from past every cap, so the windows then carry continuesPastCap and cappedWithFutureReset
in providerConnectionRouting.ts never makes such a window a quota-limited reason; without purchased credits the
cap limits the account as before, and the account's own spend limit binds either way. A 401 is the ordinary
authorization failure.
ChatGPT (openaiCodexUsage.ts) does the same for a Codex account with credits: the /wham/usage credits block is
{has_credits, unlimited, overage_limit_reached, balance} with the balance as a decimal string, beside
spend_control.reached. An unlimited grant, or a positive balance while neither the overage limit nor the spend
control is reached, means ChatGPT keeps serving Codex past the plan windows, so both windows carry
continuesPastCap; a 100 % weekly window then no longer makes the account quota-limited. Without such credits the
windows limit the account as before.
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 30 s (src/daemon/bootstrap.ts), so even while work runs it is
read at most every 5 minutes; only an account with nothing to show is retried sooner (see the usage schedule above). 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
await repositories.usage.quota.publish (packages/db/src/usage/quota.ts). It writes the complete allowance into
provider_quota_current, keyed by logical provider and connection with its credential generation. That row
preserves every fresh fetchedAt, time window and money/request meter; current performs one indexed read
for daemon and runners, with no network or process-local routing cache. The selector retains its existing
30-minute comparable/unknown rule and keeps a capped time window blocked while its reset is still ahead.
The same owner feeds record, which keeps subscription time windows only in provider_quota_samples, keyed
by logical provider, connection, window and sample time. History writes only when a reading 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: every failure stays in the local log, only the third consecutive failed sink call reports, and a successful write resets the series. A sink failure never fails the poll
(src/daemon/bootstrap.ts). Readings hold a percentage and a reset instant, no account name, address or token, and are purged
hourly beyond 120 days by maintenance.maintain('origins') in packages/db/src/maintenance/index.ts, on a horizon of their own. They are not
user data, so account deletion and /users/:id/usage/reset do not touch historical readings. Each connection's
history is derived independently and provider totals sum those histories. Disconnecting, removal or re-login
clears only the current identity snapshot; historical readings remain until the 120-day purge. The
table is declared by packages/db/migrations/0001_baseline.sql; schema provisioning applies that
baseline rather than creating tables on connection open. There is nothing to backfill, because history
before the first recorded reading does not exist anywhere.
deriveQuotaDays (packages/db/src/usage/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, connection_id, window_minutes, sampled_at), windows listed by a loose index scan, packages/db/tests/usage/plans.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, and the alert belongs to ONE ACCOUNT (src/daemon/resourceAlerts.ts): its key is the
connection id, so two accounts of one service raise two alerts instead of colliding on one, the text names the account whenever
its provider carries more than one, and the raised data carries both ids plus that account's own reset instant — two
subscriptions' windows are never merged into one series. 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.
Web login and account TOTP
The daemon, not the browser proxy, owns password-session admission through
UsersRepository.accountSecurity.beginLogin and completeTotpLogin. A correct password on an
enabled account yields only a five-minute preauthentication challenge, never a principal or bearer.
All clients use the same route; CLI-looking headers cannot exempt a password login.
Account controls remain in /account?cat=security, require a full non-impersonated human login,
and use the repository's evidence-based local-password eligibility rather than hash or identity guesses.
Microsoft SSO calls issueSsoSession after identity verification. It stamps server-side SSO assurance
and returns the same authenticated envelope, including absolute sessionExpiresAt, without
Elowen TOTP even for a mixed local-password/SSO account. This is not a claim that Microsoft enforced
MFA: tenant Conditional Access owns that policy. SSO does not disable an existing Elowen factor.
A mixed account still challenges on its password route; existing linked accounts establish local
password evidence through a successful password login before enrollment becomes eligible.
The session itself is trusted until its original start plus the current security.tokenTtlDays;
there is no device credential or extra TTL setting. Impersonation responses resolve the same
repository envelope, and returning preserves the original administrator trust anchor and assurance.
Enrollment rotation, disable and separately proved administrator reset revoke browser sessions
through the transactional facet and its postcommit notification; API and paired-computer
credentials are not browser sessions. A different current administrator must reset a lost factor,
using password plus a fresh code when enrolled, or server-recorded SSO within five minutes.
There are no recovery codes, password-only self-disable, terminal OTP, hidden reset or CLI recovery.
A lone administrator without another administrator or usable SSO has no supported lost-factor recovery.
After a committed administrator reset, the route raises an owner-scoped warning through the existing
AlertService, using core catalog keys in Czech, Slovak and English. Its key includes the target and
expected security revision; it never contains the proof or factor material. Delivery failure logs only
auth.security_notification_failed and does not repeat or roll back the durable reset/audit. Transport
cleanup is different: failure returns auth_security_cleanup_failed with committed:true, not a false
claim that the mutation rolled back; the caller must refresh state before another deliberate action.
Login-session labels are projected once by packages/db/src/users/sessionDevice.ts with Bowser,
in the repository's LoginSessionView.device field. The web LoginSessionsSection displays
Browser · OS (or the parser's mobile model, such as iPhone), a current-session marker, and a
localized Unknown device for missing or unrecognized headers. This is self-reported identification,
never authorization or a physical-device credential. Raw headers are not returned in the DTO or logged.
The BFF's shared forwardHeaders carries User-Agent to password, TOTP completion, enrollment
rotation and Microsoft callback routes. Session issuance truncates it to 512 characters in
mintSession, only for full sessions; advisor and impersonation tokens retain NULL. Returning
from impersonation preserves the original administrator session's header with its trust anchor.
Non-HTTP test issuance has no header. Migration 0014_session_device.sql adds nullable
auth_tokens.user_agent with length/scope constraints; old rows remain NULL. Parsing costs one
bounded local parse per listed row, with no network lookup. Existing session visibility and
revocation permissions are unchanged; there is no administrator list of another account's sessions.
Microsoft identity coordinates
src/auth/msProvisioning.ts resolves the existing authenticated platform descriptor once at module
initialization and exports MICROSOFT_EXTERNAL_PROVIDER, MICROSOFT_PLUGIN and MICROSOFT_LINK_SETTING_KEY.
MicrosoftSsoService imports these coordinates for external identity reads and writes, plugin
enablement/configuration and Teams-link promotion and removal; provisioning uses the same plugin coordinate.
A missing authenticated bootstrap descriptor fails at startup. The fixed public
POST /auth/sso/msteams/start and POST /auth/sso/msteams/callback routes and the current msteams provider
id remain unchanged. This is an internal core boundary with no plugin contribution, persistence migration or
additional lookup during a request.
buildBrainCore constructs one MicrosoftAccountProvisioner through createCoreServices in src/daemon/services.ts. The plugin host and MicrosoftSsoService receive that exact instance; SSO never constructs another provisioner. HTTP reads the same SSO service from ServerDeps.services.microsoftSso. Its shared AccountPresetService reapplies committed settings through reapplyAccountSettings to the late-bound live brain.
Microsoft SSO accounts are created by MicrosoftAccountProvisioner in src/auth/msProvisioning.ts. A new account
gets the same new-account preset as POST /users: UsersRepository.linkExternalIdentity creates it, then
AccountPresetService.assignPreset applies accounts.newAccountPresetId. No selected preset leaves
the new account's grants closed. An existing external identity is not reassigned on its next login. The Microsoft plugin's
five account-default settings (default models, preferred model, default plugins, allowed tools and YOLO) are no
longer read from the plugin. Account presets own these defaults.