Navigace
ELOWEN · Dokumentace

Poslední aktualizace: 20. října 2026

Procházet dokumentaci · Provider accounts and usage

Tento článek zatím není přeložen. Je dostupný v angličtině.

Developer reference

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/status returns { providers: [{ providerId, models }] }. Each model has modelId, effective: { api, source }, choices, an optional valid detection: { api, unsupported, checkedAt } and optional process-local probe status. With providerId and repeated modelId query 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/detection requires { providerId, modelId }, clears only that model's detection and releases its process-local throttle/status. ConfigStore.clearWireApiDetection and clearWireApiProbeState require 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 in packages/db/src/repositories/types.ts) is the stored list and BrainProviderStored.routing the provider's typed policy. The public DTO replaces each connection's secret with apiKeySet, and ConfigStore.brainProviders() is the ONLY daemon-side read that carries plaintext secrets. Called with a patch it resolves them: a connection that names no apiKey keeps the one stored under the same connection id, while an explicit null clears it. The old provider-level apiKey is 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 existing bumpConnectionGeneration owner. tests/daemon/keyAccountIdentitySave.test.ts verifies a warm old pin is refused and a replacement request runs on the new generation.
  • A connection carries a globally unique immutable id, its own label, enabled (the operator's PAUSE switch for that account — not a disconnect: credential, settings and history survive, and its maintenance refresh keeps running) and spendLimitMicrousd (see "Account spend limit" below; absent or null means 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 explicit null payer (instance work) needs no permission at all.
  • routing is { 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 in packages/db/src/config/codecs/store/config/brainProviders.ts owns routing bounds through clampProviderRouting; ConfigStore uses the same codec when saving a default account. A default account that is not one of the provider's own connections is dropped to null instead 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.
  • idleRebalanceEnabled is the operator switch for VOLUNTARY idle rebalancing and is separate from enabled: 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 existing granted seam in brainCore, not a chat-model entry in users.allowed_execs. The payer must hold can_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 through createVoiceRoute. 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.ts exercises 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, and checkpointSource has no such exception at all. Only the internal createDashDigestInference factory sets this purpose; public and plugin inputs do not expose it. tests/daemon/digestAccountAuthority.test.ts exercises the actual brainCore owner 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.
  • BrainConnectionRoute in src/brain/providerConnectionRouting.ts is 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, in buildBrainCore beside 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 between brain/auth.json and 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_day is keyed (day, user_id, provider, connection_id, model), provider_quota_samples is keyed (provider, connection_id, window_minutes, sampled_at) and brain_provider_requests carries 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 by connection_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 === undefined and windowMinutes === 10080.
  • A finite usedPercent >= 90 blocks with quota; 89.9% passes this guard only with current evidence.
  • Missing/non-finite usage, future-dated or old fetchedAt, and an expired reset are unknown-quota. Freshness uses the shared USAGE_COMPARABLE_MS.
  • resetsAt is Unix seconds, while fetchedAt and the freshness clock are milliseconds. Compare resets with now / 1000. An already-high reading whose reset is still in the future remains quota even 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) in src/brain/session/accountSpend.ts returns { spentMicrousd, limitMicrousd, resetsAt }. The spend is UsageProviderStore.spentOnConnection: the list-price counter usage_by_provider_day narrowed to the account's connection_id over the current month, across every user it served, read through idx_usage_by_provider_day_connection (declared by packages/db/migrations/0001_baseline.sql; packages/db/tests/usage/plans.test.ts holds the plan). utcSpendMonth is the one month computation the user gate and the account share. A row with no price adds nothing, as for the user limit.
  • Enforcement. ConnectionCandidate carries the spend fact (the dispatch seam's spend dep, wired to connectionSpend in brainCore) and unpricedModel, both filled in candidateFor AND in the commit guard stillAuthorized, so the limit is judged before every attempt and again inside the admission transaction. connectionIneligibility answers spend-limit right after the quota checks when spent >= 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's unpriced_model). listPrice in authorizedDispatch.ts reads the price per operation: chat from the account's own registry descriptor, images from PI's image descriptor or the endpoint's reported /models list, embeddings from the same list modelOffered reads. The authorized image and embedding model carries that price, and imageService.ts and embeddingCharge (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 reports default-unavailable with the account in blockedBy, routing ON skips the account and moves a bound conversation with previousLost: 'spend-limit' (ConversationAccountChangeReason). Chat, sessionless inference, embeddings, images and voice all reach it through authorizeAttempt.
  • Readable refusal. When exactly one account blocks and its reason is spend-limit, accountSpendLimitNotice (src/brain/authorizedDispatch.ts) builds the account_spend_limit ChatErrorNotice with the account's label and the next month start. The spawner throws it as ConnectionSpendLimitError at spawn, and the per-step check returns it as the refused step's spendLimitNotice, 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/all adds a spend window (connectionSpendUsage in src/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 as provider-usage events, 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. connectionConsumption and provenLimit skip spend windows, so the limit is never judged twice. Settings → Elowen AI draws the window through usageWindowMeter; the meter lists (drawnUsageWindows in web/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 shared SpendLimitSlider and saves on commit through patchConnection. The CLI rail prints spend $12.30 / $50.00 or unlimited.

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 existing UsageService publishes 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 (isExecAllowedForUser over configuredBrainProviders).

  • Internal beforeAdmission(attempt) receives the exact prepared target runtime and authorized model before any target admission. The real spawner uses it to convert through checkpointSource on 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.admit checks 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" in packages/db/contract/concurrency.md. tests/brain/session/checkpointAccountConversion.test.ts proves 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 concurrent ConfigStore.setWireApiDetection write; 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.ts covers 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 of time windows or meter:<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 least minGainPercentPoints. Forced moves (paused, revoked, exhausted, a new login, a removed account) are a different question and ignore those switches.

  • ONE operation per request, named by the CALLER and never inferred here: chat, image, embeddings or voice. Only the catalog that can answer for that operation is consulted — the chat registry for chat, PI's typed image catalog or the endpoint's reported list for image (a configured endpoint declares image models there, never as chat models), the endpoint's reported list for embeddings (an embedding-only model is never chat-eligible, so the chat registry cannot answer for it) and the models the voice settings name for voice. 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.ts drives all four, the refusals and both entry points through the real store, the real runtime owner and a REAL /models snapshot.

  • 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 blockedBy instead of being handed over as another kind.

  • checkpointSource is 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: regenerated when it was signed in again, not-configured when it is gone, already-attempted only 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. checkpointSource stays 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 as recordLimit to the sessionless clients and limitConnection to 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 its recordLimit hook 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 RetryPolicy and limits the whole request, including account failover, to min(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: ImageService awaits its asynchronous identity() reader before spend admission and passes the resolved RoutingPrincipal into account authorization. It refuses a null principal, 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 through routeCredentials, and takes from providerEndpoint only 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 read providerBaseUrl (src/brain/config.ts): a stored endpoint's own URL, the fixed OpenRouter API for an OpenRouter login (whose stored baseUrl is 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 states purpose: '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.ts is the one owner of that decision and installAccountFailover makes it at agent_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 through BrainDeps.limitConnection when 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 is failover moves 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_edit with replacement: 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 (failoverPossible in brain/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's beforeAdmission hook (carriesHistory in factory.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 (createRoutedSessionRuntime with verifyAccountRoute in brain/service/spawner.ts) feeds the same owner. When the pinned re-check refuses the bound account for a limit of that account alone (quota-limited or spend-limit), the refusal carries held and the routed runtime records it on the running attempt's evidence (AttemptEvidence.held). Nothing is sent; the classification is account-held with action failover, 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.ts proves both reasons end to end, routing on and off.

  • What must NOT cross an account move is dropped by the same owner (resetAccountBoundState in src/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 in src/brain/session/codexRouting.ts is 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: ProviderRequestRecorder creates one AttemptEvidence per 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.ts drives the owner over the REAL seam, routing store and runtime owner; accountFailoverBoundary.test.ts proves what the model sees next; accountIsolation.test.ts and codexRouting.test.ts prove the state that must not cross a move; and tests/brain/sessionAccountRoute.test.ts proves 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 account field of the existing chat model event (ChatEventDetails, shape ConversationAccountView in src/shared/wireContract.ts): the serving account's id and label plus, once the conversation has moved, a bounded reason. rebalanced is 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 carries null. The seam STATES the change (conversationAccountChangeReason) for the attempt it just committed, brain/service/spawner.ts carries it on the session, and BrainService records it on the model event (brain/service/lifecycle.ts), where the label is shaped once through conversationAccountView and mayName answers 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 mirrored src/shared/providerAccounts.ts stays importless and holds no copy of it.

  • The conversation binding lives in brain_conversation_connections and the per-account assignment/limit state in provider_connection_state (ProviderConnectionsRepository in elowen-db). Its PostgreSQL implementation in packages/db/src/providerConnections/index.ts locks 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, /clear and 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 invoking afterChange. 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.ts drives the REAL manager pipeline (login, stale callback, logout) over this rule and tests/brain/authorizedDispatch.test.ts drives 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 through ConfigStore.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.ts runs real Node child processes over the native PI getAuth and 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 next getAuth is 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 /models list survives a settings write: ConfigStore retains 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 asynchronous UsageAuth.get(nativeId) maps only that native id to its own connection key; other credentials remain invisible, and getApiKey resolves through that connection's existing runtime. Every source awaits credential reads in cacheKey and requests; UsageService.getUsage and cachedUsage await the cache identity. PI's file-backed BrainCredentialAccess.get remains 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 in bootstrap.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 spend window: createAccountUsageHealth in src/brain/session/accountSpend.ts for turns, inference, embeddings and images, and createVoiceUsageSink in src/voice/usage.ts for voice, which writes through its own store; both get the same announceCharged callback in brainCore.ts. A sub-agent runner builds its own core and bus, so src/subagent/runner.ts forwards each such event as a provider-usage IPC frame (RunnerToDaemon in src/subagent/protocol.ts), which SubagentRunnerHost and the pool hand to the sink bootstrap.ts attaches (attachProviderUsageSink) to republish it on the daemon's bus;
  • a provider save that turns its combined routing:<provider> entry on or off (isRoutedUsageProvider changes), announced under each of the provider's accounts by bootstrap.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.