Models, accounts and provider usage
Instance conversation model picker
Settings → Models → Model roles (web/lib/i18n/dictionaries/en.ts:905, 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/system.ts:8). 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 (system.ts:15-23). A 409 conflict advances the cached snapshot to the server's current one, so an explicit retry uses the current revision (system.ts:26-33). Account's empty or stale personal selection reads effectiveChatRoute for its display and selected model capabilities (web/modules/account/CliSection.tsx:149, :160, :370). This route applies account permissions, unlike serverDefaultRoute, which describes the instance role (CliSection.tsx:408). 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.
Provider marks
web/lib/providerMarks.tsx owns the provider artwork used by Settings → Elowen AI and
Settings → Models. providerMarks(kinds.data) accepts the existing provider-kinds query snapshot
and returns oauthKindOf, keySpecOf and markOf. OAuth marks use the catalog's provider id;
pasted-key marks use the stored account kind rather than the shared wire type. The add-provider
menu uses oauthProviderIcon from the same presentation map.
ModelCatalogSection matches each structured catalog group id to config.brain.providers
before calling markOf(provider, 16). Custom endpoints, missing configured entries and kinds
not yet loaded use the same generic BrainCircuit glyph as provider records. Labels and endpoint
addresses never select a provider brand. Individual model marks still use ModelIcon with the
opaque model id, including ids containing slashes. Resolution only scans the supplied in-memory
catalog; it introduces no persistence or additional endpoint.
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
(web/components/ui/ChoiceField.tsx:19). The consumer is the account voice field: web/modules/account/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:15, :199). It filters on the label, the description, the group label and the badge text, ignoring case and
diacritics (keywordsOf and rowFilter, RowPicker.tsx:57-62, :76-77; 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:236-238). Width is the trigger's,
at least 16rem and at most 28rem; height is at most 24rem and the space available (RowPicker.tsx:230, :134).
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:206-210, :244-253). 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 (always, for any number of items), 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:385; packages/plugin-ui-kit/index.d.ts:1041-1042, :1060, :1514). 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 dropdown. 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:94).
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. Its SOURCE_BADGE covers only API-key and OAuth provenance; arbitrary provider ids do not define another authentication source. 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:179-182).
One model chooser
A single model is the anchored RowPicker dropdown described above: BrainModelField for the brain catalog
(web/components/ui/BrainModelField.tsx) and ModelCatalogField for a flat list of ids of one provider. The Account
model roles (ModelRoleRows.tsx), the memory maintenance model, Settings → Models, the plugin model and
embeddingModel config fields and the subagent pin all use them, and none of that changes.
Several models are always one pair, in every place: a SelectionSummary card on the page, with a count line such as
"Models: 2 · providers: 1", a row of brand marks and an outline "Manage" button. The row has one mark per selected model,
overlapping like a project's team and capped at six with a +N mark; each mark is named by its model on hover, focus and
tap. The card opens the ManageSelectionModal window. ModelSelection (web/components/ui/ModelSelection.tsx) is that pair for models, so
no surface draws its own trigger, its own count text or its own list. There is no multi-select dropdown. The one
exception is the provider row in Settings → Elowen AI (owner's decision, 14 Oct 2026): it has no card, see
"Provider OAuth sign-in" below.
itemscarry the provider groups and brand marks:brainModelSelectionbuilds them from the brain catalog,modelPickerItems(web/modules/settings/providerModelPicker.ts) from a probed endpoint. The card counts the picks and their providers (managePicker.modelsCount);flatis for a list with no providers (a probed endpoint's own models), which counts models only.emptyTextsays what an empty selection means for the caller, in the card and in the window's footer ("No model allowed", "Models listed automatically", "None"). An optionalallOptionis the exclusive "All models" switch above the list: while it is on the stored selection is exactly its id, the rows are inert,allTextis the card's line, and switching it off brings back the earlier picks.disabledkeeps the card when the catalog is empty (Fast mode with no Fast route); the record's status says why.- A stored id the catalog no longer lists is pinned into
itemsby the caller (a row of its own, labelled with its id), or under an "unavailable" group bymodelPickerItems, so a save never drops it silently. Fast mode keeps such ids in its stored list untouched and offers only models with a Fast route. - The window is
ManageSelectionModal(a drawer or centred window, the whole screen on a phone). The "All models" switch, the search with the count badge, the kind dropdown and the provider tab strip stay pinned; only the list scrolls, and it takes the rest of the height down to the footer (ModalBody fill). The tab strip is the nowrapSegmented, which scrolls sideways with an edge fade that appears only where content is hidden, and on a fine pointer an arrow on each hidden edge (seecomponents.md). Rows are one height (h-10, orh-14for a row that also carries asecondaryTextline;ManageSelectionModal.tsx:121,:134) with the brand icon, label, kind badges and a checkbox; group headings are sentence-case 13px muted text with the provider's brand mark.kindFilter({ options, label }, frommodelOutputFilterOptions) adds one compact single-choice dropdown, the Select surface throughSelectMenu, beside the search: right of it on a wide window, on its own line under it on a phone, so the search never loses its room. Only the kinds the catalog reports are options, led by "All"; a model whose kinds the endpoint does not report shows under All only. Picks made under one kind are kept when the kind changes. - Consumers: the provider dialog's Models (
ProviderModal.tsx, the window opens over the dialog), Fast mode (CliSection.tsx), the pluginmodelsconfig field (PluginConfigFieldPickers.tsx,ModelsField) and the Users allowed-models grants and presets (GrantSelections.tsx,ModelChips, withallOption). Manage in the Models row of the provider drawer opensProviderModelsModal.tsx, which uses the same window after loading all connected accounts' catalogs, with loading, failure and retry states and a kind dropdown. - Plugins get
SelectionSummaryandManageSelectionModalbut no model-specific component;ModalBody'sfilland the modal'skindFilterare host-only props. - Not a model chooser of this family: the chat composer's model menu (
web/modules/advisor/ModelOptionList.tsx) is a live switcher in the status line that also carries the reasoning scale.ManagedSelectionField(the summary card opening the same window) remains for selections that are not model catalogs: allowed skins and the plugin project, tool and option lists.
Row dialog controls compose RowPickerTrigger (popup="dialog", the default, which keeps
aria-haspopup="dialog") from the same module, including 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:46). 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 the shared edit projections in web/lib/providerAccounts.ts, the import-free browser mirror of src/shared/providerAccounts.ts (re-exported by web/lib/providerEdits.ts). stripProvider removes the public-only apiKeySet flag; upsertProvider takes the whole list, an entry and a mode, and replaces the matching id in place or appends a new entry — the web passes replace, CLI setup merge, which keeps the stored settings it did not ask for. 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 (the plugin models config field) renders ModelSelection over the chat catalog with each executable id as the key, so a plugin configuration model field is the same summary card and window as every other model selection. Saved values absent from the supplied catalog are pinned as rows of their own so users can remove stale selections. An empty catalog adds no available rows, and preparation performs no reads or provider calls.
Provider OAuth sign-in
The Providers section uses the standard SettingsGroup section icon. Its add action uses the existing
ActionMenu over shadcn DropdownMenu, with options from useProviderKinds. Selecting a kind opens
its existing key/endpoint form or OAuth flow after the menu restores focus. Creation uses the catalog
label and the shared first-free id allocator (uniqueSlug in web/lib/nameGrammar.ts); it never asks for a name. A repeated kind creates a distinct
logical provider with a unique id. Provider editing still supports renaming. Cancelling a key/endpoint
draft makes no write. OAuth requires a stored account before starting; a newly provisioned provider is
removed through the same provider-save mutation when its login is cancelled or fails. A failed cleanup
keeps the flow open and reports the save error so cancellation can be retried. Existing-account sign-ins
retain their provider.
Each provider record pictures its stored models with no SelectionSummary card (the owner's deliberate
exception to the summary-card rule above, 14 Oct 2026: the card made the provider block look busy); the
picker opens from the Models row of the provider drawer, which does use a SelectionSummary of model marks. The record carries a MarkStrip of kind provider-models on the
line under its name: one ModelIcon mark per stored model id, each
named by that id on hover, focus and tap, capped at six with a +N chip, and the count (brain.modelCount)
as its screen-reader label. A provider with no stored models (a live catalog) shows no strip and no text.
Account groups in the drawer carry credentials, access, the active switch and account actions, without a model
picker. Opening the shared picker concurrently reads each connected account through the existing OAuth
catalog or stored-key probe API and unions model ids and reported output kinds. Saved unavailable ids remain
removable; zero connected accounts offers only the saved selection. Any failed read shows a retry instead
of a partial catalog. The request count is linear in connected accounts and runs only while the picker opens
or retries. Saving replaces only that logical provider's models through useSaveBrainProviders; it does
not combine providers sharing a type or alter their connection records. For a custom OpenAI-compatible endpoint
whose catalog read fails or comes back empty, the modal offers a one-id-per-line field instead (with a retry
when the read failed), the manual list the old edit form held. Per-account catalog storage and
runtime semantics remain unchanged: a non-empty manual model list is currently decisive in
authorizedDispatch.modelOffered, while an empty list consults each account's catalog.
BrainProvidersSection owns the provider list and its existing provider-save mutation. Its
ProviderModal creates providers only and owns the new endpoint draft and debounced model probe (web/modules/settings/ProviderModal.tsx:96-107), KeyAccountDialog owns key-account
credentials and API format (KeyAccountDialog.tsx:20, :33, :36), and ProviderModelsModal owns the shared provider model selection. These
module-local dialogs compose shared Modal, Field, Button, state components and
ManageSelectionModal and ModelSelection; they add no overlay or persistence mechanism.
modelApiLabel(api, t.brain) in web/modules/settings/providerModelPicker.ts owns wire API
labels for ProviderModal, KeyAccountDialog, the provider drawer's ConnectionTab,
ProviderModelsModal and ModelLimitsModal. It maps the OpenAI Responses variants, Chat
Completions and Anthropic Messages to the existing translated brain labels; unknown IDs remain
verbatim. This pure formatter makes no requests and neither declares nor expands supported APIs.
Each editor keeps its own offered formats, ordering and separate Auto choice; key-account editors
use only the catalog's openai-responses / openai-completions subset.
The wire format belongs to the provider endpoint and applies to every connection. KeyAccountDialog
initializes its existing ChoiceField from the stored provider.api; absence is the existing Auto
choice. Renaming or adding a connection preserves an absent pin. Selecting Auto removes api from
the complete provider entry sent through useSaveBrainProviders; an explicit selection writes that
format. New key providers start on the catalog's first format. The server resolves the API for each model through
the resolver documented in Providers. The editor never derives support
from an endpoint URL. Compatibility remains available unless Responses is explicitly pinned;
temperature remains editable for all custom providers.
The next conversation turn records a localized wire-api event and respawns in place if that
model's resolved API or base URL changed; the current run retains its spawn-time wire and compatibility.
First use starts automatic detection in the background and immediately uses the default API.
A refused or deferred probe does not block a conversation or inference request.
The provider's Settings drawer opens ProviderModelsModal from General → Models → Manage,
which unions the connected
accounts' catalogs and reads GET /brain/providers/wire-api/status?providerId=…&modelId=… for
the offered and previously selected IDs. The client bounds encoded queries to 4 KiB and merges
sequential reads, so large OpenRouter catalogs stay below proxy request-line limits. The shared ManageSelectionModal shows the effective
Responses, Chat Completions or Anthropic API as muted secondaryText beneath each addressable
model name. Native Responses variants share the Responses label. Output badges, filtering,
selection and persistence use the existing picker seam. Catalog/status loading and errors use
the shared state components; a failed API status read blocks an incomplete picker and offers retry.
Custom endpoints retain the drawer's manual ID editor only when the account catalog fails or is empty.
The manual editor has no API rows and does not request unused API metadata.
ModelLimitsModal reads the same status route and offers Auto plus the model's server-reported
choices in the API field. Auto displays the effective API unless the server still reports a
model pin. The existing autosave carries ModelOverride.api with the other overrides. Auto
omits the pin; Use defaults clears it together with the other editable overrides.
Only Auto with a stored detection shows the ghost Reset API icon beside the field, with a
localized tooltip and accessible name. It sends { providerId, modelId } to
DELETE /brain/providers/wire-api/detection and reloads the effective default. A failed reset
keeps the detection and offers retry from the same icon, with the error in its tooltip.
The API field appears only once the status offers choices, so it stays hidden while loading and never shows for a route without choices (Claude, Codex). A reload after a reset keeps the current choices until the new status replaces them. A failed read shows the field with only a retry icon, without hint paragraphs.
Native models with no addressable choices have no API field.
There is no standalone Wire API window, manual probing action or provider-wide reset.
Automatic probes run only on actual model use; these settings reads add no paid inference or polling.
The drawer edits a provider through ProviderEdit functions over the stored entry (ProviderDrawerTabs.tsx),
so a change writes exactly its own field and keeps every other stored one; withField removes an optional
field when the control returns it to the daemon's default (temperature off, hosted search on, code mode and
stateful responses off). The Advanced tab renders only the tool-execution switches whose capability the
daemon reports (membership in its status answer is the capability): absent hosted/codeMode records or an
absent stateful record hide their switch. After a switch saves, the tab reads that capability status again,
so the switch shows the stored state rather than the click. The tab performs no persistence of its own 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. The Advanced tab mounts the three reads while it is open. An empty
successful map offers no switches; a failed read shows the shared retry state above the tab's rows. These module boundaries add no polling; API probes remain debounced
and account catalogs load only while their dialog is open.
web/modules/settings/OAuthConnectDialog.tsx owns provider sign-in from start through completion. Claude (oauth-anthropic) offers
browser and copy-code methods before starting (OAuthConnectDialog.tsx:32, :124-128); the existing brainOauthStart client sends the selected method as the
daemon's method query parameter (OAuthConnectDialog.tsx:58). Other providers start with the default method (OAuthConnectDialog.tsx:31-32). 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, :88, :89-97).
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:104-118, :145-149). Copy-code terminal failures stay visible with localized guidance, without
displaying provider error prose (OAuthConnectDialog.tsx:84-86, :133). 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:65, :129-132).
Provider test and catalog refresh
web/modules/settings/ProviderTest.tsx owns the test state and result line; the action is the Test
connection icon (ProviderTestButton) among each provider record's icon actions, disabled while any test runs or when the provider offers no chat
model. One test sends one real, billed request, and useProviderTests allows one at a time per open page. Each
provider keeps its result line, shown at the foot of its record, under providerTestKey(kind, id), so an account and an API-key provider that share an id
keep separate state. A save, removal or disconnect clears the line (BrainProvidersSection.tsx:382, :427, :454), and a
generation counter retires a request still in flight, so a late answer never paints a removed provider.
useModelCatalogRefreshItem (web/modules/settings/useModelCatalogRefresh.ts) is the "Refresh catalog" row of
the provider drawer's Connection tab (ProviderDrawerTabs.tsx:161, :218). The catalog is one instance-wide list, so the action is the same for every provider. A
successful pass stays silent. A failure shows only its first error line, cut to 160 characters, through
useRefreshModelCatalog (web/lib/mutations/brain.ts).
Provider statistics
Counter-backed filters use utcDayWindow(range, now[, maxDays]) from web/lib/dateRange.ts,
also exposed to plugin bundles as window.ElowenUiRuntime.utils.utcDayWindow (web/lib/pluginUi.tsx:455). 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:117-132). A finite
maxDays gives provider, API-token and paired-computer views their bounded history (ProviderStatsModal.tsx:37, ApiTokensSection.tsx:40, ElowenCliSection.tsx:226). 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.
The block is the 0.29.97 layout carried over to several accounts per provider (owner's decision, 14 Oct 2026: minimal, everything in icons). Two SettingsGroups split the providers: Linked accounts (brain.accounts, row anchor brain.accounts) holds every sign-in service and every key-account service such as Opencode Go (oauth-* type or a catalog account), and AI providers (brain.providers) holds the API-key/endpoint providers. Each group's "+" is a kebab ActionMenu of its catalog kinds (brain.addAccount lists the oauth and key kinds, brain.addProvider the custom ones). Each provider is one outline Item (data-provider) whose visible text is only its name (an ItemTitle with heading level 3): the brand mark, the name, the state icons, the icon actions and the kebab menu on one line, the model MarkStrip under it, then the usage rail. On a phone-narrow record the menu stays beside the name and the state and actions wrap onto the next line. State is an IconMark, a focusable icon named by its tooltip and aria-label (not connected CircleDashed, paused CirclePause; a connected account shows none); a single account's state sits beside the name, routing on adds the Network icon named brain.routing.on, and routing off, a running API provider, a stored key, the type, the endpoint and the id show nothing (they live in the provider drawer). The icon actions are statistics (ChartPie) and Test connection (ProviderTestButton), shown once an account is usable; a provider with no account shows an "Add account" icon and one whose single account is not signed in a "Connect" icon. The kebab holds Settings (the provider drawer, docs/modules/web/settings.md), Add account, Delete account and Remove provider; every account action and setting lives in the drawer. Delete account is offered only on an account record of a provider with more than one account, so a provider's only account is removed with Remove provider. With several accounts the shape follows routing. Routing off: each account is its own record (data-provider plus data-provider-account), exactly a single-account record titled with the account's name, its state, Connect icon, rail and the menu with Delete account. Routing on: one record whose rail is the daemon's combined entry routing:<provider id> and a ghost ChevronDown icon button (brain.accountUsage, aria-expanded) that opens, through the shared Collapsible (closed by default), a named list (data-provider-accounts, labelled by brain.accountCount): per account its name as a muted text-meta line, its state icon, a Connect icon while it is not signed in, and its usage rail, with no menu of its own. The surface uses the existing Settings, Item, ActionMenu, MarkStrip, tooltip and typography tokens; settings.provider-account-layout.e2e.ts checks the two groups, the absence of words, badges and addresses, the icon states, one/two/zero accounts, the keyboard order, alignment and both widths.
The Statistics icon on every provider record in Settings → Elowen AI (BrainProvidersSection.tsx, label brain.stats.button) 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:248, ProviderStatsBody.tsx:34). The drawer mounts the query only while it is open (BrainProvidersSection.tsx:521), and a range change keeps the previous figures on screen until the next ones arrive (queries.ts:253).
Everything is admin-only on the server (src/api/routes/usage.ts:90), 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:1929-1934), 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:1930-1931). 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:177-247). 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:94-95, :178).
- 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:105-107). 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:2292). The help text depends onProviderBilling(ProviderStatsBody.tsx:21-30). 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:528).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:2290);reportedmeans the provider reports each request's cost, so it is the amount charged (en.ts:2291). useProviderUsageStatsreturns{ stats, window }: the data carries the UTC-day window it answers for (queries.ts:242). 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:61).- The drawer has no footnote on the counter's limits. The response carries
retentionDays(wireContract.ts:1941-1944), 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:134-137,:189-203). The ring draws a slice for each user and has no separate list, so the per-user figures are reached through its hover card (PulseRing.tsx:58-60,:123-124). Models use a secondPulseRing, also without a legend, for API-key providers and subscription accounts alike: the first eight models get a slice each and the rest are folded into one neutral "Others" slice whose cost is unavailable when any folded model has no price (MODEL_RING_MAX,ProviderStatsBody.tsx). - 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:150-155,:235-245). - Names carry their marks, never a bare id. The header chip shows the provider's own mark, the one its row draws (a bundled
ModelIconfor an account, the localBrainCircuitglyph for a custom API-key provider), passed in asmarkand drawn throughModal'siconNode(a pre-rendered brand mark instead of the Lucideicon;ProviderStatsModal.tsx:28,:50). Every model slice carries itsModelIconas the slice mark and, with thebrainModelLabeldisplay name, in the model ring's hover card (CardHead'smark). No chart in the drawer prints a model name of its own. Custom endpoint URLs do not declare a brand asset, so their row and drawer use the same inline glyph without fetching or guessing external favicons. Account and model marks use the existing same-origin/models/assets. - Subscription lead. For a subscription account the drawer reorders: the section titled Subscription (
en.ts:2314; the current windows throughOAuthUsageRailand the account-wide note, or "The current windows could not be read right now" when only history exists,en.ts:2315), thenProviderQuotaSection(quotaof the response), then a "Tokens through Elowen" heading (en.ts:2329), then everything above unchanged (headline figures, who used it, models, trend, breakdown;ProviderStatsBody.tsx:112-120,:186).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:2317). The people list says its shares are of the tokens Elowen sent, not of the provider's allowance (en.ts:2297), and the token section says those figures are not the subscription percentages (en.ts:2330).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.
Custom endpoint marks
Custom API-key endpoints use the same inline BrainCircuit glyph in their provider row and statistics
drawer. Endpoint URLs do not declare a brand asset. OAuth account and individual model marks keep the
same-origin /models/ inventory and existing model-brand ordering. No endpoint hostname descriptor or
provider-brand rendering helper remains. OpenCode Go's declared opencode icon resolves through
modelIconSlug to opencode-color.svg, the favicon sourced from https://opencode.ai/favicon.svg
and shipped in that same inventory. ModelIcon renders its original colors in the provider row,
account rows and statistics drawer; model names such as opencode/deepseek-v4.1-flash retain their
model brand. This costs one cacheable same-origin asset request and does not fetch the upstream domain
when Settings opens. New pasted-key providers initialize their API pin from the catalog's first format in
providerDraftForKind; existing connection editors retain the provider's stored format or Auto.
Command's catalog icon resolves to the shipped command.svg from
https://commandcode.ai/favicon/2024/safari-pinned-tab.svg; its monochrome mark uses the same
ModelIcon theme treatment and exact account-name match, keeping hosted model brands unchanged.
A brand whose lobe colour asset draws its glyph in hard-coded white or black ships only its mono base,
so modelIconSlug falls back to it and ModelIcon masks it with the skin's currentColor. Kimi is such
a brand: kimi-color.svg (white K) is not shipped, and the Kimi account and models use kimi.svg.
web/tests/modules/settings/ProviderBrandRendering.test.tsx and
settings.provider-model-icons.e2e.ts exercise the local glyph and unchanged account/model assets.
Terminal model identity
The terminal modelMetaLine takes provider, providerLabel, model and explicit showProvider as structured fields. createChatComposition passes the active snapshot, including the drilled child's snapshot, and changes only showProvider when reducing footer width. Provider labels prefer the configured label and otherwise the provider id; a missing model renders the existing dash. Hiding a provider leaves the whole model id unchanged, including embedded slashes. The function does not parse a composed provider/model string and does not resolve a model route. The terminal start screen and parent/child footer rows are its real consumers; web model routing remains unchanged. ChatViewportState contains only viewport inputs that it reads; model presentation belongs to the structured modelMetaLine identity in createChatComposition and is not copied into viewport state.