Models, accounts and provider usage
Instance conversation model picker
Settings → Models → Model roles (web/lib/i18n/dictionaries/en.ts:732-741, web/modules/settings/ModelRolesSection.tsx) uses the shared BrainModelField, an anchored RowPicker dropdown, to edit brain.defaultModel. The saved provider/model pair seeds the row, not catalog ordering or a default flag. The instance default row passes allowDefault={false} (ModelRolesSection.tsx:238), so once a model is selected the picker offers no inherit/clear option. The utility and digest rows keep the pinned default row, because BrainModelField defaults allowDefault to true (web/components/ui/BrainModelField.tsx:14; ModelRolesSection.tsx:262, :285). A saved choice that the catalog no longer lists stays visible as a pinned, selected row labelled with missingLabel, next to the localized unavailable warning badge (BrainModelField.tsx:38; ModelRolesSection.tsx:230, :240). Loading and failed catalogs use the shared LoadingState and ErrorState; retry reloads the catalog without changing the pair (ModelRolesSection.tsx:168-174). No shared picker primitive or token changes are needed.
The independent autosave writer uses useUpdateConfig (web/lib/mutations.ts:24). It stores the returned config snapshot. When brain.defaultModel or brain.providers changes, it invalidates the brain model catalogs (brain-models, grantable-brain-models) and the personal CLI settings (my-cli-settings); a voice or provider change also invalidates the voice catalog (mutations.ts:31-39). A 409 conflict advances the cached snapshot to the server's current one, so an explicit retry uses the current revision (mutations.ts:42-49). Account's empty or stale personal selection reads effectiveChatRoute for its display and selected model capabilities (web/modules/account/CliSection.tsx:111-122). This route applies account permissions, unlike serverDefaultRoute, which describes the instance role (CliSection.tsx:384). It never infers a route from the first option or a catalog flag. The administrator's personal model is not written by the instance row.
Model selection
A single choice from a catalog, a grouped list or a long list is one control, RowPicker in
web/components/ui/RowPicker.tsx: an anchored combobox dropdown, never a drawer or a window. It is the
shadcn Popover and Command (cmdk) parts rendered in place, because overlayStack marks every portaled
<body> child inert. The trigger is RowPickerTrigger popup="listbox" (role="combobox",
aria-haspopup="listbox", a chevron that turns while open) and the popup content is a plain frame around the
listbox. Rows are RowPickerItems, a ManageSelectionItem plus two optional host-only fields: pinned rows (group: '') come first without a heading, then one
group per group with its groupIcons icon and groupLabel; each row has an icon, a truncating label, badges
and a check on the current pick, and a disabled row carries disabledHint as its title. description adds a
muted second line under the label, which the search also matches. action adds one small control on the row's
right edge after the check; RowPicker stops its click from picking the row and its press from taking focus from
the list, so arrows and Enter keep driving the dropdown. Because focus stays on the list, the action is a
pointer control, and keyboard users reach the row but not the action. Without either field a row is
unchanged. ChoiceField passes both through from its options, in the dropdown form only
(web/components/ui/ChoiceField.tsx:32). The consumer is the account voice field: VoiceAccountSection.tsx:44 calls
useVoiceOptions in web/modules/voice/voiceSamples.tsx. Each GPT-Live-1 voice shows a presentation and accent label
composed from a local table (voiceSamples.tsx:16-29, :75), not OpenAI's own description text, and a speaker that
plays a sample from web/public/voices/<voice>.m4a, one sample at a time. The file header says the samples were
recorded once from each voice (voiceSamples.tsx:12-14). web/tests/modules/voiceSampleParity.test.tsx keeps its voice list
equal to the daemon's LIVE_VOICES and checks that every sample file exists. The plugin kit's
RowPickerItem does not publish these fields. Every single-choice
dropdown looks the same as the shadcn Select, from one source in web/components/ui/shadcn/select.tsx:
the trigger is selectTriggerVariants (open: primary frame, wash and ink, keyed to data-state="open"), and
a row is choiceItemClass with the current pick marked data-state="checked" (primary wash and ink) and
trailed by the choiceCheckClass check (select.tsx:42, :132, :138). SelectItem, RowPicker's cmdk rows and the chat /model list
(ModelOptionList, Radix radio items) all compose them and add only their primitive's own highlight
selector (data-highlighted for Radix, data-selected for cmdk). Without these classes a new dropdown
reads as a different control; compose them rather than restyling a row. A search box shows
only above eight rows (SEARCH_THRESHOLD = 8, RowPicker.tsx:14, :203). It filters on the label, the description, the group label and the badge text, ignoring case and
diacritics (keywordsOf and rowFilter, RowPicker.tsx:58-63, :77-78; normalizeText in web/lib/normalizeText.ts, also used by ManageSelectionModal and the site
search). It takes focus on open off a phone only, so no keyboard covers the list (RowPicker.tsx:242). Width is the trigger's,
at least 16rem and at most 28rem; height is at most 24rem and the space available (RowPicker.tsx:135, :235).
Picking a different row calls onChange once and closes; picking the current row, Escape or an outside press
closes with no change, and focus returns to the trigger (Radix leaves it where the reader clicked after an
outside press; RowPicker.tsx:210-214, :249-257). There is no Save step. The trigger's aria-controls names the cmdk listbox, and focus on
open goes to the search box, or to the listbox itself when there is no search box or on a phone, never to the
generic cmdk root: both carry aria-activedescendant, and the root would let cmdk move focus to the search
box on the next pointer move and raise a phone's keyboard. disabled keeps an empty or
read-only record's shape; notice is one muted, non-selectable line above the rows for a loading, error or
unavailable state; onOpenChange reports every open and close, for loading a catalog on first open; onClosed
fires after a pick has closed the dropdown, with focus guaranteed on the trigger (the pick restores it itself),
which is where a follow-up dialog opens from; it does not fire for Escape or an outside press. ChoiceField (more than three items, counting a stored value the options do not list, or picker="always"), BrainModelField, ModelCatalogField
and the plugin timezone and destination fields are built on it, and plugins
reach it as components.RowPicker (UI API 40, web/lib/pluginUi.tsx:349; packages/plugin-ui-kit/index.d.ts:829, :1172-1173). The unit-test helper pickRowOption(trigger, option) in
web/tests/test-utils.tsx opens one and clicks a row.
OAuth and API-key model dialogs use modelPickerItems and modelOutputFilterOptions in
web/modules/settings/providerModelPicker.ts, feeding the same ManageSelectionModal badges and kind filter. The shared
OAuthModelCatalog response carries models and a generic outputs map; OpenRouter includes embeddings,
images, video and other reported kinds, merged by ID with native PI text models so a missing or partial
snapshot does not empty the picker. Empty selections offer only text-capable or unknown-output models.
Plugin image fields accept both fixed image classifications and reported image output; a mixed text/image
model remains selectable in both chat and image fields, narrowed to the field's chosen provider.
embeddingModelCatalog in web/lib/modelProvider.ts is shared by ModelRolesSection and plugin configuration
(ModelRolesSection.tsx:112, PluginConfigEditor.tsx:78).
Chat/output filters import their byte-pinned owner web/lib/modelCatalog.ts directly (modelProvider.ts:3);
model identities, visible labels and diagnostic labels remain in modelProvider.ts. API-key models with
unknown outputs remain selectable, while OAuth models must advertise embeddings (modelProvider.ts:15-17). The embedding provider
picker merges those offered OAuth routes with stored API-key choices by provider ID, so a connected
OpenRouter account absent from brain.providers can be selected without a parallel provider record (ModelRolesSection.tsx:178-181).
Per-model account choices over the models that qualify (Fast mode in Account → Models) use RowMultiPicker,
the multi-select twin of RowPicker in web/components/ui/RowPicker.tsx: one trigger the width of a record's
control cell opening ManageSelectionModal (a checkbox list with a Save step, which a dropdown cannot be), fed by brainModelSelection so the rows carry model and provider
icons (web/modules/account/CliSection.tsx:123, :280, :420). A disabled trigger keeps the record's shape when its catalog is empty; the record's status says why.
All row dialog controls compose RowPickerTrigger (popup="dialog", the default, which keeps
aria-haspopup="dialog") from the same module, including allowed skins, customer-skin management and plugin
modal editors; the dropdown uses the same component with popup="listbox", so both share one class source.
Pass label for the field's accessible name, summary for the truncating value, aria-expanded and
onClick from the owning surface.
Native button props such as disabled and ref are forwarded. The trigger is a native button on the
Select's trigger surface (selectTriggerVariants: outline maps to its default, ghost to its borderless
line), which owns the record-width h-9 geometry, hover, disabled and open styling; the global
button:focus-visible ring marks keyboard focus (web/app/styles/base.css), and the trigger adds the data-row-picker marker.
Optional icon is a decorative rendered brand mark; trailingIcon defaults to ChevronDown (RowPicker.tsx:27) and
the plugin modal field editor supplies Pencil (web/modules/settings/ModalFieldRow.tsx:45). Without icons from the caller, only the chevron renders.
The trigger performs no catalog reads, modal mounting or persistence; these remain caller-owned.
BrainProvidersSection prepares every provider write with its module-local stripProvider and upsertProvider functions. stripProvider removes the public-only apiKeySet flag; upsertProvider replaces the matching id in place or appends a new entry while preparing the complete list. Provider drafts, key connections, capability switches and account model selections use this preparation before useSaveBrainProviders; removal maps retained entries through stripProvider. An omitted apiKey leaves the stored credential intact. A missing provider entry is materialized only when a switch or model selection is saved. Switch removal omits the field, preserving hosted-search's off-only and code-mode/stateful's on-only storage contracts. The helpers perform linear in-memory preparation and add no requests or persistence path. Key-account connection state requires an AccountRow with its key catalog specification, and the existing pending-save interlock remains in force.
ModelsField feeds CatalogMultiSelectField with brainModelSelection(models, model => model.exec). The shared model-selection owner supplies provider groups and their icons while retaining each complete executable id. CatalogMultiSelectField still pins saved values absent from the supplied catalog so users can remove stale selections. An empty catalog adds no available rows, and preparation performs no reads or provider calls. Plugin configuration model fields use this same presentation as the other brain model selectors.
Provider OAuth sign-in
BrainProvidersSection owns the provider list and its existing provider-save mutation. Its
ProviderModal owns the API endpoint draft and debounced model probe (web/modules/settings/ProviderModal.tsx:68-72), KeyAccountDialog owns key-account
credentials and API format (KeyAccountDialog.tsx:24, :42), and AccountModelsModal owns account-catalog loading and selection. These
module-local dialogs compose shared Modal, Field, Button, state components and
ManageSelectionModal; they add no overlay or persistence mechanism.
API provider edits reconstruct form-owned fields so clearing temperature removes it, while carrying
the stored codeModeEnabled: true, hostedToolSearchEnabled: false and
responsesStatefulEnabled: true switches. They never spread the stored entry over the edited draft
(web/modules/settings/BrainProvidersSection.tsx:171-191).
ToolExecutionControl renders only capabilities reported by the daemon (web/modules/settings/ToolExecutionControl.tsx:49). All three switch writes use
the section's typed setProviderSwitch through the existing provider save (BrainProvidersSection.tsx:249-266, :344-348): absent hosted-search means
on, absent code-mode/stateful means off. OAuth accounts without a stored entry are materialized under
their built-in provider id when a switch or model selection is saved (BrainProvidersSection.tsx:248, :258).
The module-local ToolExecutionControl requires all three pending booleans and change handlers, including statefulPending and onStatefulChange. BrainProvidersSection passes stateful={statefulInfo?.enabled}, statefulPending={statefulPending === providerId} and its existing setProviderSwitch callback for each account or API provider row. Only daemon-reported capability values determine which switches appear: absent hosted/codeMode records or an undefined stateful value hide their switch, while stateful=false displays an off switch. Pending disables the corresponding switch during the existing provider-save mutation. The control performs no reads or persistence itself and adds no polling or public plugin API.
useCapabilityStatus (web/modules/settings/useCapabilityStatus.ts:29-59) owns each capability map, read failure and retry. It reads once on mount, again
when the provider list changes, and on an explicit post-save refresh. One generation counter per
capability discards superseded answers. An empty successful map offers no switches; a failed read
shows the shared retry state (BrainProvidersSection.tsx:271). These module boundaries add no polling; API probes remain debounced
and account catalogs load only while their dialog is open.
OAuthConnectDialog.tsx owns provider sign-in from start through completion. Claude (oauth-anthropic) offers
browser and copy-code methods before starting (OAuthConnectDialog.tsx:28); the existing brainOauthStart client sends the selected method as the
daemon's method query parameter. Other providers start with the default method (OAuthConnectDialog.tsx:51). All flows use the existing status and
manual-input endpoints, without a second OAuth implementation. Polling runs every 1.5 seconds, and five consecutive failed poll requests close the dialog as an error (OAuthConnectDialog.tsx:21, :79, :83-86).
The dialog shows the authorization URL and a labelled code field when input is required. Submission disables the
field and button, keeps the code when the request fails, and prevents a duplicate input after acceptance even if an
older poll still reports needsInput (OAuthConnectDialog.tsx:96-109, :135-137). Copy-code terminal failures stay visible with localized guidance, without
displaying provider error prose (OAuthConnectDialog.tsx:76, :123). Success refreshes account status and usage. A closed dialog discards late start,
input and polling answers. A start failure offers an explicit retry; a 409 start conflict shows the conflict message instead of the generic error (OAuthConnectDialog.tsx:56, :120-122).
Provider statistics
Counter-backed filters use utcDayWindow(range, now[, maxDays]) from web/lib/dateRange.ts,
also published as window.ElowenUiRuntime.utils.utcDayWindow at UI API 49 (the runtime exposes it at web/lib/pluginUi.tsx:412; the UI API 49 date is recorded in CHANGELOG.md; the current PLUGIN_UI_API_VERSION is 54, pluginUi.tsx:141). It returns inclusive
UTC day keys ending no later than today. Without a cap, All and a custom range with no start return
from: null, meaning omit the lower query bound; old custom dates remain available (dateRange.ts:115-125). A finite
maxDays gives provider and API-token views their bounded history (ProviderStatsModal.tsx:37, ApiTokenUsage.tsx:45). The Stats plugin converts these
keys to UTC milliseconds for model queries, daily chart clipping and origin framing; the dashboard
uses the same helper for the current UTC month. This pure helper performs no I/O and reads only the
supplied clock; unrelated local-calendar surfaces retain rangeBounds.
Account and API-key provider records use the shared shadcn Item parts. Their ItemActions groups are capped at the record content width and wrap right-aligned when touch-size controls no longer fit; they never widen the name band or the usage rail. The same composition handles connected, disconnected and API-key records without changing the shared primitive or its plugin contract.
Every connected account row and every API-key provider row in Settings → Elowen AI (BrainProvidersSection.tsx) carries a Statistics button (BrainProvidersSection.tsx:320, :433; label en.ts:1797) that opens ProviderStatsModal, a Modal with drawerWidth="wide" (ProviderStatsModal.tsx:48-53), which the house presentation rule makes a right-hand drawer on a roomy screen and the whole screen on a phone. The modal owns the range (DateRangeFilter, remembered under elowen.settings.providerStats.range, default 30 days, read as UTC days through utcDayWindow in lib/dateRange.ts; ProviderStatsModal.tsx:35-37) and the request; ProviderStatsBody only draws what GET /usage/by-provider returned (useProviderUsageStats, asking for the top ten users; queries.ts:181, ProviderStatsBody.tsx:33). The drawer mounts the query only while it is open (BrainProvidersSection.tsx:496), and a range change keeps the previous figures on screen until the next ones arrive (queries.ts:186).
Everything is admin-only on the server (src/api/routes/usage.ts:91-92), and Settings itself is admin-only in the web. The figures come from the daemon's durable per-provider counter (usage_by_provider_day, src/shared/wireContract.ts:1435-1439), so they outlive deleted conversations and start on trackedSince, which the drawer shows under the title (ProviderStatsModal.tsx:43-54). The per-provider counter records chat turns and embedding requests made with the provider; sessionless chat inference has no provider dimension (wireContract.ts:1435-1437). Whether /p/stats, the dashboard charts and the advisor statistics read the same counter is not verified here. Sections, top to bottom: four headline figures (tokens, list price, requests, cache hits), the allowance, who used it, the model split, the per-day trend and the token breakdown (ProviderStatsBody.tsx:176-258). A subscription account (billing === 'subscription' with reported time windows or recorded history) leads instead with the subscription: see "Subscription lead" below; every other provider, including every API-key provider, keeps this order (ProviderStatsBody.tsx:100-101).
- The allowance is drawn only from windows the provider reported (
OAuthUsageRail, the same rows as the account row, with a note when stale,OAuthUsageRail.tsx:16). A provider with no reported allowance, which includes every API-key provider, shows "No allowance reported" with a help tip, never a percentage (ProviderStatsBody.tsx:111-113). There is deliberately no per-user percentage of an allowance: the windows are account-wide and the counter is day-grained. - The cost is Elowen's own list-price arithmetic over the tokens; a cost no request carried reads "Not available", never $0 (
en.ts:1809). The help text depends onProviderBilling(ProviderStatsBody.tsx:20-29). The drawer picks the billing mode from the provider:reportedfor the provider with idopenrouter,subscriptionfor any connected account, andcomputedfor every other provider, including every API-key provider (BrainProvidersSection.tsx:503).subscriptionmeans an equivalent, not a charge;computedmeans Elowen's prices may differ from an invoice, and a model with no known price or a free one counts as $0.00, because the data cannot tell them apart (en.ts:1806-1807);reportedmeans the provider reports each request's cost, so it is the amount charged (en.ts:1808). useProviderUsageStatsreturns{ stats, window }: the data carries the UTC-day window it answers for (queries.ts:175,:184). While the next range loads the previous answer stays on screen dimmed (aria-busy) and is drawn on its own days, so the figures and the day axis never disagree (ProviderStatsModal.tsx:41,:73). A refresh that fails after a first success keeps the figures and shows an alert banner with Retry above them (ProviderStatsModal.tsx:67-72); only a drawer with nothing to show falls back to the full error state (ProviderStatsModal.tsx:62).- The drawer has no footnote on the counter's limits. The response carries
retentionDays(wireContract.ts:1447-1450), but the web does not render it. The counter holds at least that many days and always the whole current month, so a longer range shows only what is kept. - Who used it is a
PulseRingdonut (legend={false}) of the top ten users, the rest folded into "Others" (ProviderStatsBody.tsx:137-143,:190-201). The ring draws a slice for each user and has no separate list, so the per-user figures are reached through the hover card (PulseRing.tsx:58-60,:141-157). Models use a secondPulseRing(with its legend) when there are one to eight of them, and a plain list otherwise (ProviderStatsBody.tsx:204-231). - Days are the shared
TimeSeriesChart(tokens as bars, list price as a line on its own axis); a day without a row is drawn as zero (ProviderStatsBody.tsx:149-154,:235-246). - Names carry their marks, never a bare id. The header chip shows the provider's own mark, the one its row draws (a
ModelIconfor an account, the endpoint favicon for an API-key provider), passed in asmarkand drawn throughModal'siconNode(a pre-rendered brand mark instead of the Lucideicon;ProviderStatsModal.tsx:28,:50). Every model is aModelIconplus thebrainModelLabeldisplay name: in the plain list (ModelName,ProviderStatsBody.tsx:68-76), in the model ring's legend rows (RingSlice.mark,ProviderStatsBody.tsx:145) and in its hover card (CardHead'smark,ProviderStatsBody.tsx:213). No chart in the drawer prints a model name of its own. - Subscription lead. For a subscription account the drawer reorders: the section titled Subscription (
en.ts:1831; the current windows throughOAuthUsageRailand the account-wide note, or "The current windows could not be read right now" when only history exists,en.ts:1832), thenProviderQuotaSection(quotaof the response), then a "Tokens through Elowen" heading (en.ts:1846), then everything above unchanged (headline figures, who used it, models, trend, breakdown;ProviderStatsBody.tsx:118-126,:177).ProviderQuotaSectionshows two figures (the period's total and the busiest day, in percent of the primary window; 100 % is one whole window, so a period can exceed it;ProviderQuotaSection.tsx:69-75) and aTimeSeriesChart: bars for the primary (longest) window's consumption per day on the left axis, a line for the day's peak of the shortest window on the right axis (absent when the provider reports one window;ProviderQuotaSection.tsx:48-49,:80-84). A day the daemon has no reading for isnullin the chart, a gap and never a zero, and the chart starts on the first recorded day (ProviderQuotaSection.tsx:52-57). States: no readings yet says recording has begun and draws no chart or figure (ProviderQuotaSection.tsx:31-37); a range before the recording began says when it began (ProviderQuotaSection.tsx:39-46); days that followed a period without readings add a note that use during it is counted on the day it was next seen (ProviderQuotaSection.tsx:63,:90). AHelpTipcarries the honesty notes (only the current window is reported so earlier days cannot be recovered, account-wide including use outside Elowen, gaps when Elowen was not running;en.ts:1834). The people list under this layout says it is a share of the tokens Elowen sent, never of the subscription (en.ts:1814,en.ts:1847).KpiandSectionTitlelive inProviderStatsParts.tsx; the subscription figures use their owntestId(provider-stats-quota-kpi,ProviderQuotaSection.tsx:70-71) so a count of the token figures stays four. Real consumers:ProviderStatsBodyand the Playwright specsettings.provider-stats.e2e.ts.