Navigácia
ELOWEN · Dokumentácia

Posledná aktualizácia: 20. októbra 2026

Prechádzať dokumentáciu · Provider catalogs and model roles

Tento článok zatiaľ nie je preložený. Je dostupný v angličtine.

Developer reference

Provider catalogs and model roles

Provider model catalog

Configured endpoint transport, HTTP and malformed response failures are typed InstanceConditionError values. EmbeddingResponseCountError and EmbeddingVectorError identify validated endpoint shape failures before the endpoint owner wraps their audience; unknown parser exceptions retain their original software cause. The existing background-worker result carries reporting: local-only beside each such endpoint error; its strict parser rejects unknown marker values. The daemon reconstructs typed errors before aggregating model.provider_catalog_failed. Snapshot and accounting writes remain software failures; a mixed aggregate remains reportable. Embedding batch and per-item errors use the same existing worker marker and retain usage before propagating failure. See Problem report audiences.

src/brain/providerModelCatalog.ts is the shared OpenAI-compatible /models fetch and parser. It requests output_modalities=all unless the endpoint names its own filter (ProviderCatalogEndpoint.outputModalities, part of the snapshot key so a filtered list never shares an entry with an unfiltered one), and carries validated architecture.output_modalities through ReportedModel.outputs; endpoints that omit the field leave it absent. The provider probe returns { models: [{ id, outputs? }] }, and GET /brain/models (src/api/routes/brainProviders.ts:93) exposes the same optional outputs array on BrainModelOption. A provider's explicit manual model list stays authoritative, including non-chat kinds. With no manual list, discovery keeps only models whose outputs are unknown or include text.

src/shared/modelCatalog.ts owns modelSupportsOutput and chatModelCatalog. Core discovery, model-role validation and CLI chat picking, direct switching and autocomplete use this output-kind seam. The browser build-root projection in web/lib/modelCatalog.ts is byte-pinned by webMirrorContracts.test.ts; browser callers import these filters directly. web/lib/modelProvider.ts owns brain model identity and presentation, plus the shared embeddingModelCatalog consumed by the settings Model roles row (ModelRolesSection.tsx) and the plugin configuration editor. BrainModelOption, the complete BrainModelCatalogOption REST row and ProviderProbeResponse live in the importless wire contract. The CLI preserves all catalog metadata, validates provider probe object rows, turns their ids into prompt labels and distinguishes HTTP or malformed replies from an empty chat catalog. General catalogs and explicit provider allowlists remain all-kinds. Chat pickers use the latter helper, while embedding settings and plugin embeddingModel fields use the same output predicate for embeddings, retaining unknown-kind API-key endpoints. OAuth embedding choices require positively reported embeddings outputs, and the shared embeddingModelCatalog includes connected OpenRouter routes. The provider dialog passes reported kinds to the shared ManageSelectionModal filter options; if the endpoint reports no kinds, the additional filter is absent. For example, an OpenRouter catalog can expose an embeddings model in the provider selection while omitting it from chat menus and offering it to embedding-role selection.

Configured endpoint identity belongs to src/brain/providerEndpoint.ts. Its pure providerEndpointKind({ type, baseUrl }) returns openai, anthropic, azure-openai-responses or custom. CLI provenance imports this daemon module; web settings read effective wire APIs from the server rather than importing a transport resolver. The WHATWG URL parser retains host, non-default port and path, excludes non-HTTPS URLs and userinfo/query/fragment, and uses vendor defaults for empty custom-provider URLs. An HTTPS default port is official; proxy paths, lookalike hosts and non-default ports are not. OAuth accounts remain a separate credential-origin decision, not an endpoint classification. openAiApiFor lives beside this classifier and supplies only the default before detection. resolveWireApi(entry, modelId, facts) owns effective per-model API and base URL selection for OpenAI-compatible runtime registration, including model/provider pins, native PI descriptors and endpoint-fingerprinted detections. Anthropic and OAuth metadata remain owned by the prepared registry. First use starts detection in the background; model resolution and turn startup never await it. See the provider accounts module. Runtime registration, status descriptions and session consumers use the resolved model wire. The browser edits explicit pins and compatibility switches without guessing API support from URLs. supportsResponsesStateful in src/brain/responsesStateful.ts is that gate: an openai entry on the Responses wire or the xAI account. Config persistence keeps responsesStatefulEnabled only where it holds, the responses-stateful status route lists exactly those providers, and the spawner opts a session into chaining under the model's PI provider; web settings read the switch from that route and hold no type list of their own. A request continues the previous response only when every field except input, store, previous_response_id and the per-request max_output_tokens ceiling matches it, and its input still extends the previous input plus that response's own output items (src/brain/session/responsesChaining.ts); the ceiling is sized from the room left in the window, so including it would break every continuation. The browser never imports provider registry or credential code. Fast routing shares it. registerCustomProvider (src/brain/providers.ts) owns normal and ad-hoc catalog projection, including configured/discovered IDs, earlier explicit additions, pins and native descriptor provenance; adding a hand-typed model cannot drop discovered models or make a relay inherit native input limits. These are local parser/catalog reads, with no fetch or extra credential access. Vision fallback reads session.model.input, the resolved PI descriptor used to send or strip images, so endpoint reports and explicit capability overrides apply before decideVisionHop (src/brain/visionFallback.ts).

Shared model identity projections

elowen-plugin-shared/modelIdentity owns the pure string projections used by core and bundled Subagent. splitModelIdentity(spec) separates a complete provider/model at the first slash, retaining all later model slashes without trimming or decoding; an incomplete pair returns null. Subagent's owner model route keeps its separate legacy elowen| decoding path and calls the helper after elowen:/exec-prefix handling for canonical pairs. It still checks the parsed provider against its catalog. Type-model pins use the same split after their own trimming. elowen-plugin-shared/format's parseModelExec retains its separate exec syntax and bare-model contract.

modelIdentityLabel(identity) returns provider/model, an unqualified model when the provider is absent, or undefined when the model is absent. Subagent reasoning diagnostics and workflow node snapshots consume this projection; the reasoning caller retains the exact the sub-agent model fallback. These helpers do no catalog lookup, I/O or validation of reasoning choices, grants or routes; those remain caller-owned.

Instance conversation model role

brain.defaultModel is the persisted atomic { providerId, model } pair in ConfigRepository, or null before a provider is connected. brainDefaultSelection (src/brain/providers.ts) is the sole runtime resolver for an empty personal chat selection, catalog default flags and serverDefaultRoute. Config provider IDs are never replaced with registry IDs. Settings' Model roles row writes the pair through admin-only PUT /config; the API validates the complete pair against the offered text-chat catalog with isOfferedChatModel (src/brain/models.ts), which filters listBrainModels through the shared chatModelCatalog. The wider existence bound for stored model references stays isOfferableExec (src/shared/execs.ts:152). A provider list and role in one patch use the sanitized draft catalog, preserving omitted credentials. Config revision checks and the PostgreSQL settings row lock govern persistence through packages/db/src/config/configStore.ts and packages/db/src/config/locking.ts.

The existing ConfigRepository.migrateRetiredPluginConfig in packages/db/src/config/configStore.ts removes inert root customModels, hiddenPresets, and modelNotes under the PostgreSQL settings row lock. It also folds the retired per-model token maps into brain.modelOverrides, preserving current values. Unknown installation keys, nested plugin settings, executor allow-lists, and current brain/provider fields are preserved. A changed settings row advances its revision once; rerunning the cleanup performs no write. Only the authoritative daemon calls this boot cleanup; runner and worker repositories attach without it. The transaction performs no catalog fetch, credential refresh or model-runtime preparation.

At daemon boot, initializeBrainDefaultModel in src/brain/config.ts migrates only settings that lack the field, preserving the formerly effective first-provider model, including catalog-backed OAuth defaults. Only the authoritative daemon runs that migration. It prepares the offline runtime and selection before calling the conditional SQL initializer, so no network or credential preparation runs under a SQL lock. A fresh or providerless install stays null. Provider saves, OAuth connection completion and background catalog snapshot changes initialize a pending null role once a model is available. There are no network requests on reads or turn startup; initialization uses local PI descriptors and persisted provider snapshots. Empty custom OpenAI-compatible providers register the same discovered text catalog that the picker offers, filtered once by discoveredChatModels (src/brain/providerModelCatalog.ts), which keeps the shared chat catalog and drops :free variants; this lets delayed setup and stored discovered defaults resolve without ad-hoc positional routing. BackgroundWorkerDeps.onModelsChanged is an optional awaited hook after changed snapshots are restored, used by maintenance for this pending initialization; absent, refresh behavior is unchanged.

Provider updates prune references to models no provider offers in the same PostgreSQL transaction as the new provider settings. ConfigStore.pruneModelReferences in packages/db/src/config/configStore.ts uses durableModelOffer from packages/db/src/config/codecs/store/modelReferenceCascade.ts and the bound providerConnections.accountModelReferencePruner to clear affected instance, account and plugin selections. A boot sweep uses the same path; the daemon can seed a cleared default once a current model is available. Spawns retain the existing account authorization chain: the permitted instance role, then account allow-list pairs, then permitted configured models, including local registry descriptors for OAuth and automatically discovered providers with empty manual lists. ServerDeps.effectiveChatRoute projects the existing daemon accountChatRoute authority into GET/PATCH personal CLI settings, separately from the global serverDefaultRoute; absent wiring reports null, never a guessed route. Account's model label, reasoning controls and vision capabilities consume this projection without new routing logic. Provider connection tests name a concrete offered chat model, so testing a non-default provider never relies on a positional conversation default. Personal chat and compaction choices remain independent; without a distinct valid compaction choice, compaction uses the resolved chat model. For example, an explicit Codex role wins even if Claude/Fable is first in provider order.

PI model catalog overlay and provider model lists

buildBrainRegistry in src/brain/providers.ts is the sole descriptor preparation seam for the shared ModelRuntime. Spawning, inference, status and delegated-child identity all call it. It retains one read facade per runtime and compares provider settings and model overrides by value against a private snapshot, so fresh config objects and in-place edits have the same semantics. An unchanged generation only compares that small configuration and looks up descriptors; it never registers providers, refreshes availability or reads credentials. No providers means no custom endpoint registrations; the same native catalog and models.dev supplements remain readable by account pickers.

modelRegistryGeneration.ts carries explicit publication revisions. A successful PI catalog restore, a changed endpoint snapshot or retained-endpoint pruning, a changed file credential, and completed OAuth connect/disconnect invalidate preparation. Snapshot timestamps alone, failed reads, timers and PI's own registration-generated refreshes do not. Revisions and prepared facades are process-local, not shared between the daemon and runners. After a successful PI restore or effective endpoint-snapshot change, createBackgroundWorker.onModelsChanged flows through maintenance to SubagentRunnerPool.modelsChanged. The pool sends one typed modelsChanged IPC frame to each existing process, including a booting runner; with no pool or no runners it does nothing and forks nothing. RunnerModelCatalog serializes offline runtime.refresh, local generation invalidation and endpoint-snapshot reload. It restores the process's PUBLIC runtime AND every prepared per-account runtime the owner holds: an account's personalized catalog belongs to that account, so restoring the public view alone would leave a warm runner's prepared accounts on the list they were prepared with — which is how a model one account may run would look like a model every account of the provider offers. ONE account's unreadable catalog is reported against that account and keeps its last good list without withholding the publication from the others, and a later frame repairs it. Later turn frames await the publication tail before resolving their models; a failed PI restore of the public runtime is logged by the runner and its last good catalog stays in use, as in the daemon's own worker pass, until a later publication succeeds. Unreadable endpoint snapshots retain the last good copy and report the read problem. A runner booted afterwards restores current files as usual. No polling or per-progress work is added: publication costs one offline refresh per runner, only when descriptors change. IPC backpressure queues frames and is not mistaken for delivery failure. Provider additions, edits, deletes, connected-account changes and overrides are detected on the next call by their effective config values. Preparation removes deleted custom providers and departed OAuth overlays before applying current descriptors. A missing OAuth credential removes an account from the runnable set, but configuredBrainProviders still retains its explicit stored identity: revocation is not provider deletion and must not erase preferences or permissions. Request-time authentication still uses the fresh credential store, never a cached token.

An ad-hoc route registers through the same custom-provider projection, records only explicitly added ids, and advances the prepared generation immediately only when no earlier publication is pending. An ad-hoc registration updates one provider and must not consume unapplied changes to others. Later config/catalog preparation reapplies those ids with current metadata; discovered ids are not retained as ad-hoc additions, and provider deletion forgets them. For example, non-live delegated progress reads only the selected descriptor from this prepared source. Its thinking fallback uses transcript.journalContextState through src/brain/session/sessionIdentity.ts and PI's default off, then the existing model clamp, rather than rebuilding a session. Its cost is independent of transcript size, including branch moves and compaction. Real descriptor changes still incur PI registration/catalog work once per generation.

One runtime per account

A logical provider is an identity; its accounts are connections, and connectionRuntime.ts is the sole owner of the PI runtimes prepared for them. Each connection gets its own ModelRuntime whose credential store is a ScopedCredentialStore (src/brain/credentialStore.ts). OAuth accounts wrap the shared auth.json: the native pi-ai provider id PI asks for ('anthropic', 'openai-codex', …) resolves to THAT connection's key, and every other key stays invisible. API-key accounts use an empty in-memory credential store; their current connection-row key reaches PI through registerCustomProvider. They never consult auth.json, where a former OAuth login may still occupy the same connection id and would otherwise shadow the stored API key. The complete host categorization override and ordinary endpoint flow is verified in tests/daemon/openrouterHost.test.ts. Two Claude accounts are therefore two runtimes, two tokens and two independent rotations with the same native provider id and separate credentials.

ConnectionRuntimeOwner.prepare caches one runtime per connection id AND credential generation, and acquire(route) resolves an already authorized BrainConnectionRoute for a dispatch, refusing a connection that no longer exists, a route that names another provider's account, and a generation that moved on since the request was authorized. retire(connectionId) closes the account's credential views for WRITING (reads stay open for a request already in flight), which is what stops a login callback that arrives after a re-login or a disconnect from writing the old identity's token over the new one. invalidate(connectionId) drops only the cached runtime, for a setting change that did not touch the identity. Nothing else in core may build a second runtime for one connection: two runtimes against one key are two refreshers against one rotating token, and a provider refuses a refresh token it has already consumed. That cross-process half is verified against two REAL processes in tests/brain/connectionRuntimeAcrossProcesses.test.ts: the daemon's runtime and a forked runner rotate one account's expired token over one auth.json, and the local token endpoint holds the first request and rejects a second use of a refresh token, so a double spend, a disconnect or a re-login that the account moved on from fails that run instead of passing it.

What a prepared runtime CARRIES beyond that identity is the account's own configuration, re-read on every prepare: this one account's provider entry plus the operator's per-model overrides (brain.modelOverrides), which are what price and bound the model the dispatch resolves here. So a price pinned while an account is already prepared applies to its next request instead of waiting for a restart or a provider save, and clearing it lands the same way — the registry reconciles itself from the values it actually sees. The cache key is the IDENTITY (account id, credential generation, owning provider), never a snapshot of the settings, and the runtime still carries exactly ONE account: the rest of the provider list and the instance default are deliberately absent, because a dispatch registry holding a second account of the same service would let one account's pins overwrite the other's. The overrides arrive through the required ConnectionRuntimeOwnerDeps.modelOverrides callback. The daemon supplies () => config.get().brain.modelOverrides; callers without configuration explicitly return {}. tests/brain/connectionRuntimeConfig.test.ts pins the pricing at a cold prepare, an override pinned and then cleared on an already prepared identity, the account row being re-read on that identity, a stored API key reaching the wire through PI's own registration, and two accounts of one builtin keeping their own credential, entry and runtime.

resolveAuthorizedModelRoute (src/brain/providers.ts) is the dispatch seam: it turns an authorized route plus that connection's prepared runtime into the Model and the runtime that serve it, and it cannot switch or substitute an account because it never looks beyond the connection's own scoped config and registry. The credentialed-endpoint seam resolveConnectionCredentials (src/brain/config.ts) reads that route's account secret and nothing else; it picks no account and holds no pause branch, because admission is the selector's one job.

Each connection persists its OWN PI catalog (models-store.json for the shared public overlay, connections/<connection-stem>-<digest>/models-store.json per account), so an OpenRouter account's key-personalized list, its limits and its prices are never restored under another account. The shared public overlay stays shared for the credential-less readers: the settings pickers, listBrainModels and the grant checks. oauthBuiltinCatalog answers a service's public catalog for an account that has no credential yet, while oauthConnectionCatalog answers from that account's own registry and personalized catalog.

Provider OAuth is addressed per account as well. BrainOAuthManager (src/brain/oauth.ts) takes oauthConnectionTargets(config.brainProviders()) and runs PI's own login/logout on the account's runtime, so a credential lands in its own key; a flow records the generation it started from and reports an error instead of success when the account was re-authenticated or removed while it ran, only the administrator who started a flow may poll or answer it, and a second live login for one account — or a second browser login for one service, which would have to bind the same loopback listener — is refused with a typed reason. The maintenance loop walks oauthMaintenanceTargets, i.e. every configured account of the maintained service PAUSED OR NOT (pause is a dispatch decision, and a paused account keeps its subscription and its login), each through its own runtime and key, and one account's failure never stops the others. API-key changes increment the stored generation in that config commit; unchanged or omitted keys preserve it. The existing committed-provider event also renews bootstrap's per-account allowance services and retires removed/replaced runtime views. That event is emitted after OAuth generation commits too. Paused accounts keep their maintenance and allowance, while logout/removal withdraws the current quota identity. Daemon and warm runners read the same UsageRepositoryQuota.current row (provider_quota_current, packages/db/src/usage/quota.ts), with no catalog or allowance fetch on admission. tests/daemon/backgroundWorkerLoop.test.ts verifies that personal-catalog refresh calls the supplied runtime owner on the existing maintenance cadence.

Built-in providers (Claude and ChatGPT accounts, GitHub Copilot, Kimi Code, xAI, Meta and OpenRouter accounts and the rest of PI's catalog) list the models of the pinned PI release. PI also publishes its catalog to https://pi.dev/api/models/providers/<id>, and ModelRuntime layers that list over the built-in one, so a model PI adds appears without an Elowen release. Elowen keeps PI's descriptors authoritative. The worker also supplements new official Anthropic, xAI and Meta chat releases from models.dev while PI's curated remote catalog catches up. Codex is excluded: its subscription API differs from the OpenAI API catalog, and models.dev has no matching Codex section.

catalogSupplement.ts validates the models.dev boundary with TypeBox and persists only model identity, name, all token prices including context-length tiers, limits and a PI sibling id in catalog-supplement.json. buildBrainRegistry composes these through the existing OAuth descriptor overlay used by model overrides and OpenRouter additions. There is no second registry or transport definition. The sibling comes from the same provider, API and canonical family: Claude haiku/sonnet/opus/fable, Grok, or Muse Spark. Numeric versions are compared deterministically; only releases newer than the newest native sibling are added, and an earlier release date than that sibling's models.dev date is excluded when available. Dated snapshots, aliases, deprecated and non-text entries are excluded. A missing same-family sibling skips the model and logs its identity once per daemon lifetime instead of guessing transport settings.

For example, claude-haiku-5-5 inherits Anthropic request compatibility, thinking map, prompt cache, reasoning, modalities and input limits from claude-haiku-4-5; its prices and context/output limits come from models.dev. Every base and tier price must explicitly include input, output, cache read and cache write rates. The fetch validates the top-level provider/models envelope; each model row is validated independently by the supplement reducer. Raw rows may carry non-chat limits such as an output limit of zero; only supplement candidates require positive integer context and output limits. Invalid rows and incomplete or unknown prices skip only that model. Price objects and context tiers accept only explicitly supported keys; an unknown surcharge such as context_over_200k is not silently dropped. Skipped identities are logged at most once each (src/brain/modelCatalog.ts), capped at 1000 identities per daemon lifetime to bound both memory and log volume. Missing rates never become zero. Context tiers are validated at the row boundary and persisted in PI's cost.tiers shape with inputTokensAbove. PI selects the highest threshold exceeded by the full input usage, including cache reads and writes, and applies that tier to the entire request. Spend admission and settled usage continue through the existing PI pricing path. A native PI entry always wins by id, including while models.dev is down. The worker retains takeover descriptors in the supplement and returns their identities in private IPC retired; only the daemon removes them after its offline PI store restore succeeds. A failure of a different provider does not prevent restoring successful PI publications. Failed restores retain the takeover descriptors and the last restored store revision, so even an unchanged store is retried on the next pass. Failed fetches or invalid top-level documents preserve the last supplement and PI catalog and use the existing one-warning-per-outage provider-catalog reporting. Failed disk reads retain the process's last good copy; a corrupt supplement is reported independently and does not abort PI or provider refreshes. A valid models.dev reduction can atomically replace that corrupt file.

The fetch is shared with the Fast overlay: one multi-megabyte download on boot, every four hours and a forced refresh, bounded by the existing thirty-second fetch and sixty-second worker deadlines. PI_OFFLINE prevents it. Only the small reduction remains on disk and in memory; registry preparation does no network work. New supplement ids appear in the refresh result's added list. The daemon reloads the reduction and publishes effective changes through the existing model generation and runner notification seam. Its last announced supplement content is tracked independently of picker reloads, so a picker cannot consume a runner notification. Warm runners reload it after their offline PI restore. Fresh picker runtimes read the same instance file, including the credential-less OAuth catalog used in Settings. Existing account allowlists and model overrides still apply; no new picker marker is introduced.

Preview models

A vendor sometimes serves a model before PI lists it, and the models.dev reduction above cannot always supply it: it admits only a newer version of a family PI already has, and only with a complete price. A new family such as Muse Wonder, or a variant models.dev does not list, therefore needs a hand-written row. The operator writes preview-models.json next to catalog-supplement.json (the brain directory of the instance). It is a JSON array of the supplement's own row shape and is never written by Elowen. catalogSupplement.ts reads it with the same TypeBox schema and the same reload as the worker-written file, so the rows take the same path: the sibling's transport, compatibility and thinking settings, the row's name, limits and prices, and the existing registry overlay. Nothing else is added.

[
  {
    "provider": "meta",
    "id": "muse-wonder-1.0-contributor",
    "name": "Muse Wonder 1.0 Contributor",
    "sibling": "muse-spark-1.3-contributor",
    "contextWindow": 1048576,
    "maxTokens": 131072,
    "cost": { "input": 0.1, "output": 0.2, "cacheRead": 0.002, "cacheWrite": 0 }
  }
]

provider is anthropic, xai or meta. sibling must be a model of the same provider that PI already lists on the same API; a row without one is ignored. Prices are US dollars per million tokens. Pick the sibling from the same tier as the new model, because the request wire and the reasoning ladder are copied from it. Verify the new id first by calling the vendor's /models and one tiny request with the account's own credential. The change reaches new conversations when a picker is next created and running ones through the next worker pass, which is at most fifteen minutes; no restart is needed.

A native PI entry always wins by id, so once PI publishes the model the row is inert. Remove it at your leisure; leaving it does nothing. A file that is not valid JSON of this shape is reported once as a warning and the last good list stays in use. This file is the only manual list; there is no second registry.

Provider model snapshot and the background worker

A configured OpenAI-compatible provider is the other half of the same job: its own GET /models answers what it serves and with which limits (context window, output tokens, vision, output kinds, prices). src/brain/providerModelCatalog.ts owns that read model. No daemon path calls /models, and no conversation start waits on a provider's server.

  • Persistence. models-store.json in the brain directory, beside auth.json, never PI's default agent directory, and next to it fast-models.json (the Fast-mode overlay) and provider-models.json: one entry per endpoint, keyed by the endpoint's normalized base URL plus a SHA-256 of its API key (providerModelsKey) and never the key itself, holding the models that endpoint last reported and when. The worker replaces that file atomically (temp file + rename). buildBrainCore (src/daemon/brainCore.ts) points every runtime of the process at the overlay (useCatalogStore) and every reader at the snapshot (useProviderModelsSnapshot), and hands the daemon's maintenance loop every path. The daemon, every forked sub-agent runner and the credential-less runtime behind listBrainModels, oauthBuiltinCatalog and the account default (inMemoryModelRuntime) restore the same last good copies, so a picker offers what a session can run. A missing snapshot (a fresh install, the :memory: test database) is an EMPTY one: manual model lists and catalog limits answer, exactly as the previous in-memory cache did when it held nothing, and a read path still never calls the network.
  • Who fetches. A dedicated WORKER PROCESS (src/brain/backgroundWorker.ts), forked by the daemon for one request and nothing else. That is the point of the split: the network calls and PI's rebuild of the model runtime stay off the daemon's event loop, next to live turns. catalogRuntime never enables network, so creating a runtime restores the file and returns without waiting on pi.dev, and a runner, a picker or a test cannot reach the network. One pass does ALL its fetches — PI's catalog, every configured endpoint's /models list and, on the catalog's cadence, models.dev for Fast support and supplements (fast-models.json, catalog-supplement.json) — side by side. Supplement reduction waits for the refreshed PI descriptors so new PI entries take precedence. The daemon's createMaintenanceLoops (src/daemon/maintenance.ts) forks the worker in the background right after boot, then every WORKER_PASS_INTERVAL_MS (15 minutes, src/brain/modelCatalog.ts:81); it never fetches itself. PI's own four-hour freshness gate decides inside a pass when its catalog is actually refetched, which is what keeps that cadence from hammering pi.dev — the cadence exists for the provider lists. Provider settings publish onProvidersChanged only after the settings and model-reference cascade commit, and the daemon reconciles prepared account runtimes through that callback. Successful OAuth connect/disconnect calls retainProviderModels from src/daemon/brainCore.ts and requests a pass outside SQL. The worker registration carries its live backgroundWorkerTargets getter, and retainProviderModels uses that same target set so a synthetic connected OpenRouter snapshot survives unrelated provider edits and is removed on disconnect. Stopping the registered worker releases its pass requester and target getter; processes without a worker retain only the endpoints supplied by the caller. The OpenRouter account target (OPENROUTER_EMBEDDINGS_ENDPOINT) fetches only https://openrouter.ai/api/v1/models?output_modalities=embeddings without an authorization key; other OAuth accounts add no endpoint target. PI has no embeddings model type, so this list is the one OpenRouter kind Elowen still owns; OpenRouter's chat and image catalog is PI's. Connecting or disconnecting an account queues a forced pass first (src/daemon/brainCore.ts), because PI personalizes the OpenRouter catalog to the connected key only during a catalog refresh. The existing maintenance requester owns the shared forced pass, while the account runtime owner refreshes the login's personal catalog in the background. The actual host login and logout callbacks and their forced IPC requests are verified in tests/daemon/openrouterHost.test.ts. One pass runs at a time, and a request that lands while it runs is remembered and runs once after it. A pass that has not answered within 60 s is given up on and logged, a pause or shutdown gives up the same way, and the child gets SIGTERM first so it can abort its fetches and release PI's models-store lock, with SIGKILL only after a five-second grace. An admin can call POST /brain/models/refresh from Settings → Models: the same worker queues one forced pass behind any current pass, joins repeated manual requests, asks PI with force: true (bypassing PI's stored freshness gate), refreshes the provider /models lists and the Fast-mode overlay, and resets the daemon's four-hour gate. It reports added models and per-provider failures, then restores the shared runtime, provider snapshot and catalog supplement before replying when successful. It never overrides PI's PI_OFFLINE network switch; a request before worker startup returns 503 (src/api/routes/brainProviders.ts:115-116). :memory: has no paths and never forks.
  • Failure reporting. Every failed attempt remains locally logged. Worker transport, PI catalog refresh, and offline restore have independent third-failure counters. Endpoint refreshes count per endpoint, snapshot publication per snapshot, and supplement/models.dev work only on its actual catalog cadence. Success resets only the operation attempted: a fifteen-minute endpoint-only pass cannot reset the four-hour catalog sequence. The third failure reports once; later failures remain local until success. Malformed worker replies and invalid configured endpoint JSON/credential shape report immediately; other endpoint-state read failures use their own third-attempt sequence. Snapshot/overlay validation and supplement takeover persistence retain their immediate fault diagnostics.
  • Module ownership. modelCatalog.ts owns instance catalog paths, offline runtime restoration, the pass cadence and serialized probes. backgroundWorkerProtocol.ts owns request/reply types and their boundary parsers; it has no process or persistence side effects. backgroundWorkerFork.ts resolves the packaged/source worker entry and owns IPC send, reply/close/timeout settlement, cancellation and TERM/KILL escalation. embedBatchWorker.ts owns all live daemon-side embedding children: createBackgroundWorker resumes batch admission at startup and stops every active batch at pause/shutdown, refusing new batches until startup. buildBrainCore supplies the queue and owner reindex with embedBatchThroughWorker, which admits the account, resolves its endpoint and commits reported usage before judging vector count or shape. Without worker paths, maintenance creates no worker; missing embedding configuration leaves queue bodies pending. The pass, probe and batch bounds remain 60, 10 and 45 seconds (src/brain/modelCatalog.ts:90,95, src/brain/embedBatchWorker.ts:14), followed by the same five-second kill grace (src/brain/backgroundWorkerFork.ts:10). embeddings/embeddingResponse.ts is the pure response owner shared by direct requests and worker replies: it owns EmbeddingRequestUsage, parses reported usage and validates row ordering, finite nonempty vectors and configured dimensions. Service and IPC consumers import the usage type from this response boundary. EmbeddingService (src/embeddings/embeddingService.ts) retains HTTP, credentials, admission and accounting; parsing never performs I/O.
  • How it is asked. The request travels over the forked child's IPC CHANNEL, never as an argument: a pass carries the providers' API keys, and /proc/<pid>/cmdline is world-readable. A PROBE — the provider dialog testing an endpoint nobody has saved yet — is the same worker and the same parse code on a shorter leash, as a one-off request that stores nothing (probeProviderModels, reached through POST /brain/providers/probe). Probes are SERIALIZED (probeProviderModels in src/brain/modelCatalog.ts) — one child per daemon at a time, each caller waiting its turn for its own endpoint's answer — and the HTTP caller's own signal cancels its probe, so closing the dialog stops the fetch instead of leaving it running. The memory drain's embed batch is the same pattern on the same process: one request, one answer, nothing persisted by the child.
  • Memory embeddings. This is the worker's third job, and its only input that is not a file. The embedding provider may be a connected OpenRouter account, the one OAuth account allowed to serve embeddings (brainProviderSupportsEmbeddings in packages/db/src/config/document.ts). The drain (EmbeddingQueue, src/embeddings/embedQueue.ts:123) picks the memories that still need a vector out of the store, inside a body count AND a character budget, and the daemon hands the batch to one child (embedBatchInWorker), which posts it to the configured /v1/embeddings endpoint in a SINGLE request and answers ONE RESULT PER BODY over the IPC channel. The credential travels with the request — the child resolves none of its own — and the daemon validates every vector itself (width, finiteness, emptiness) before storing it, so a reply from a stray process cannot become a memory's embedding. Rows are placed by the index the endpoint sends when it sends one, so a provider that answers out of order cannot hand one body's vector to another. A request the provider REFUSES is narrowed to one body at a time inside the child, which is what keeps a memory over the model's limit from costing the memories batched beside it: it loses its own vector, is retried once per tick and never blocks the backlog behind it. Only a failure of the endpoint as a whole — a transport error, a timeout, a rate limit, a 5xx, a dead child — costs the tick, with its reason logged and the same bodies retried later, and every body of it counted against the budget. The narrowing is bounded too: a provider that refuses body after body is refusing the REQUEST, so after a handful of individually refused bodies (MAX_INDIVIDUAL_REFUSALS, 5, in src/brain/backgroundWorker.ts) the rest of the batch is reported with the batch's own reason instead of one paid request each, and the batch is retried as a whole on a later tick at one attempt per body. A pause or shutdown gives up on a running batch instead of waiting for its own timeout. Keeping this out of the daemon is what stops a slow or unreachable embeddings provider from holding the event loop a live turn runs on. The QUERY embedding of a turn (turn-start recall and live recall) stays a DIRECT call: a turn cannot wait for a forked child. The drain sends ONE BATCH PER USER (the caps still bound the whole tick), because the usage of a request can only be attributed to one account, and the child reports what each HTTP request it made consumed (usage in its reply, one entry per request, refused requests excluded); see "Embedding usage" under the usage tables below for where that goes. The owner reindex (MemoryMaintenanceService, behind POST /memory/maintenance/reindex) sends its snapshot through the same EmbeddingBatch, cut by the drain's body count and character budget, so a few hundred memories cost a handful of requests; a batch that failed as a whole counts against its own bodies and the job moves on to the next one. Its batches can be in flight beside the drain's, and a pause or shutdown gives up all of them. Maintenance progress and results live in memory_maintenance_runs, exposed through MemoryStore.maintenance. The existing additive schema migration creates this table on upgrade without advancing the numbered migration ladder. MemoryMaintenanceService checkpoints each completed embedding batch or categorized memory and publishes terminal counts. Only daemon bootstrap calls recoverInterruptedRuns(): attached runners share the same database but must not interrupt live daemon work. Unfinished runs become interrupted, retaining observed counts, and are never resumed or reported as successful. GET /memory/maintenance returns the latest two operation slots plus runs, a bounded twenty-row projection of the caller's UI-started running work and unacknowledged results from the last 24 hours. Full human browser credentials mark API starts as UI work; tools and scoped credentials do not. POST /memory/maintenance/:id/acknowledge acknowledges only a settled receipt owned by the caller, including earlier receipts after another start. No maintenance service means the existing routes report unavailable. Account removal deletes the receipts through the existing memory cleanup transaction. Reads make no model calls; starts keep the existing single-running-operation-per-owner lock and inference costs.
  • Read-only credentials. The worker opens auth.json through ReadOnlyCredentialStore (src/brain/credentialStore.ts): read/list delegate, modify hands back the credential on disk without running its callback, and delete is refused. Token rotation therefore stays with the daemon's own refresh loop. It is enough because PI consults a credential only to decide which providers are refreshable (resolveRefreshCredential) and the catalog request carries none, so an expired token still lets the fetch run.
  • Picking the file up. A pass that rewrote the store is handed to the daemon's own runtime (brainRuntime.refresh({ allowNetwork: false })) — a file read and a provider recompose, never a request to pi.dev, and nothing at all for a pass that wrote nothing (a gated boot pass, an air-gapped host, a provider without a credential). Which one it was is judged by the store file's identity around the pass, not by the added-models list: a descriptor that changed under an existing id never appears in that list, and a 304 that only moves the freshness marker is still a write to the file. Without the restore the shared runtime keeps the overlay it restored at boot, so a model the worker added would reach the settings pickers at once but a live session only after the next restart. A runner keeps the overlay it restored when it was forked, so it learns a model added later on its next start (idle runners are reaped after two minutes, src/subagent/sizing.ts:142). The provider snapshot is re-read the same way, by the file's identity around the pass, and reloaded even when the catalog fetch failed: the two jobs of one pass are independent.
  • Failure. A failed pass keeps the last overlay and logs one warning per outage, not one per tick; a pass that adds models logs them at info, and providers that failed are named while the rest of the pass still lands. An endpoint whose /models fetch failed keeps the list the last good pass stored, and the warning names the SET of endpoints that are failing, so a second one going down is logged while the first is still down and a pass that never answered (a timeout, a dead child) leaves that state alone. A snapshot the worker cannot WRITE is reported as one more endpoint failure rather than costing the pass its catalog result; the daemon's own re-read of the file keeps the lists it is answering from when the file cannot be read or parsed, and reports that once. An endpoint that is no longer configured leaves the snapshot on the next pass, and an unreadable provider list is that pass's failure rather than a rejected tick.
  • No cache to invalidate. buildBrainRegistry runs per spawn and per listing and reads the runtime's current models; the OAuth override pass unregisters its own earlier registration first, so a pinned account also sees new models on the next build. A running conversation keeps the descriptor it started with.
  • Air-gapped installs. PI_OFFLINE set to any value in the daemon's environment turns PI'S catalog fetch and the models.dev Fast fetch off entirely — the worker inherits that environment, PI reads the variable itself and the worker checks it before fetching models.dev — and the built-in catalog, the generated Fast baseline and whatever overlays are already persisted stay in use. It does not stop the provider lists: those endpoints are the ones the operator configured for their own conversations, so a host that can reach them still gets their model lists. See docs/DEPLOYMENT.md.

Custom openai-type endpoints still price and size a model from PI's built-in catalog (nativeListPrice, catalogLimits in providers.ts), not from the overlay, so a relay serving a model newer than the pinned release keeps the operator's own price and limit overrides as its source. A relay id that no catalog provider publishes (deepseek/deepseek-v4.1-flash on Command) is sized and priced by one rule, vendorCatalogRows: the vendor named by PI's OpenRouter rows answers with its official row, else its OpenRouter row. Only when no vendor is found does the name-wide agreement of all reseller rows decide, and a disagreement there leaves the 8192-token output placeholder. An anthropic-type entry and every OAuth account read the overlay descriptor. For example, claude-sonnet-5-5 comes from the overlay with its own name, price and limits.

Provider temperature remains configured on the provider entry. buildBrainRegistry publishes a runtime-local policy keyed by PI registry provider id; wrapModelStream passes the actual request model's value through PI's typed temperature stream option. Nested wrappers share policy updates without mutating PI runtimes or descriptors. Owner sessions, channels, delegated runners and connectivity probes use this seam. Unset temperature sends no provider-specific value. PI owns transport-specific temperature guards and thinking serialization; Elowen has no provider request-profile payload hook or Alibaba/Qwen budget rewrite. Policy lookup adds no provider request or model tokens.

BrainService.setThinkingLevel updates the live choice and publishes settings immediately; PI reads the current thinking level when preparing the next request, including within a tool loop. For Anthropic models with per-message effort, provider effectiveness starts at the next user-role message with new content. A continuation carrying only tool_result does not activate the new effort. That input may be a person's message, steering or an automatic note projected into the user role; it is not necessarily the next human message. PI owns historical effort serialization and the stable top-level effort configuration. Changing the displayed level mid-loop therefore does not promise an immediate change in provider effort or cost. Models without per-message effort retain their native PI transport behavior.

Custom OpenAI-compatible Chat Completions endpoints retain their explicit compatibility baseline. Base-URL catalog lookup supplies model limits, not native provider compatibility. A custom DashScope endpoint therefore does not inherit native Qwen thinking serialization or its token-budget field merely by sharing a native provider's URL.

OAuth model overrides preserve every PI model kind, keyed by kind and model id. Chat settings apply only to chat descriptors; image and classifier descriptors survive unchanged, including ids shared with chat models. OpenRouter snapshot additions remain chat-only.

catalogRuntime reads models.json only beside the instance's models-store.json, never from PI's global agent directory. Without a configured catalog store it uses modelsPath: null and keeps the runtime in memory. Missing instance configuration leaves PI's native catalog intact; runtime creation performs no network requests.

The independent CLI update coordinator uses the CoreHalf seam in src/update/transaction.ts: prepare downloads and verifies the candidate while services run. Bootstrap, update and the licensing release proof share scripts/releaseInstall.mjs: extract the release as a root package and run npm ci --omit=dev --engine-strict with native install scripts enabled, preparing Python, make and g++ first. install.sh downloads and verifies that release before provisioning Node, reads its engines.node with Python's JSON parser, and derives both the minimum runtime and NodeSource major from that manifest. The bootstrap contract accepts one canonical >=major.minor.patch requirement and fails closed on any other range; it has no hand-kept version copy and needs no checkout manifest when invoked directly from the repository. Missing Python is provisioned only after the licence and artifact gates. The activation key and installation identity are passed only in the provisioner's child environment, not exported to later release commit or recovery processes. Bootstrap argument resolution happens once before host checks: explicit argv wins over the word-split ELOWEN_INSTALL_ARGS, and selected help returns before preflight or installation. The sudo provisioning handover also preserves ELOWEN_LICENCE_SERVER, which the CLI revalidates through licenceServerFor; the installer container harness is the loopback consumer, and an absent override keeps the default server. Pack-time artifacts derive a production manifest without development dependencies or workspace discovery, and its production root lock, from the committed manifest and lock. The shipped shared workspace remains a locked local link; the unshipped UI workspace is excluded. apply moves the candidate under <prefix>/lib/elowen-releases/<version>-<transaction> and atomically replaces <prefix>/lib/node_modules/elowen. Stable command, systemd and sudoers paths use that link. src/shared/installResolution.ts owns the same install-kind resolution for the updater, provisioner and plugin host. src/plugins/hostModules.ts publishes the stable package's node_modules and package link for installed plugins, including the shared user-plugin node_modules/elowen link. Those links follow core-only swaps while marketplace housekeeping is locked, and survive retained-release pruning; developer checkouts keep their dependency-derived module layout. The existing core journal records intent before the switch, with a null previous version only for fresh bootstrap. Directory changes are synced; converge refreshes root-owned host files before start where the updater has root access. commit marks proven bytes before the journal becomes terminal and prunes only marked owned releases, retaining current and previous. Each retained backup keeps the existing package inventory beside it; cleanup checks those bytes before removal and leaves unrecorded backups untouched. Fresh-bootstrap recovery removes only its recorded package and command links, refuses unfamiliar replacements, and uses the candidate recovery module even before its live link exists. restore atomically selects the retained release and is followed by the same converge before restarting it. Without systemd, the updater resolves and imports the currently selected launcher's canonical path through that stable installation on every start, including rollback; it never starts from the coordinator's already-imported retained launcher. Developer checkouts continue using their own launcher. clean removes staging only when the core swap record is no longer applied. (updateCoreHalf in src/cli/updateTransactionIO.ts) supplies the writable-package and root-owned implementations, src/cli/update.ts decides writability and runs the root-owned refresh after every outcome (refreshAfterUpdate in src/cli/install/hostConvergence.ts, which reaches convergeRootOwnedUnits through the privileged worker); for a plugin-only transaction there is no core apply, converge, commit, or restore. For example, the root-owned implementation sends a token only with the staging request and re-executes the installed release's privileged refresh before its daemon boots. For a writable service-user installation, converge has no privileged write to perform and the existing drift report runs after restart. See docs/DEPLOYMENT.md for operator recovery commands and the worker's drain limit.

Plugin candidate validation uses MarketplaceServiceOptions.candidateManifest (src/plugins/marketplace.ts): the CLI (src/cli/updateTransactionIO.ts) binds the parseManifest export from the verified staged target's dist/plugins/manifest.js after core preparation, before extracting or validating plugin payloads. A plugin-only run and the daemon use their own parser. There is no API-version fallback or compatibility mode. A selected payload reported as cannot-stage aborts the entire transaction before stopping services; consent refusals remain explicit skipped candidates.

Endpoint and reasoning policy owners

OPENAI_DEFAULT_BASE_URL in src/brain/providerEndpoint.ts owns the official OpenAI v1 default. Runtime registration, catalog identity, Images API, voice routing and CLI setup consume it; explicit URLs retain their normalization and API pins. The browser holds no endpoint resolver: settings draw wire-API labels from the per-model status route and never import registry or credential code. This adds no requests or credential access. resolveVoiceEndpoint is a consumer; Azure retains deployment routes.

descriptorCapabilities returns either reasoning false without a thinking map or reasoning true with a complete ThinkingLevelMap. inferredModelCapabilities offers only canonical levels mapped to non-null values, retaining provider labels. Unknown non-reasoning models offer no levels; modelEntry uses the same map.

Offline catalog restore

restoreModelCatalog(runtime, abortLabel) is the internal shared offline restore and validation boundary beside modelRegistryGeneration. It awaits runtime.refresh({ allowNetwork: false }) and rejects aborted results with the caller's label or provider errors with their existing diagnostics. It does not publish a revision. createBackgroundWorker assigns the restored file revision and invalidates only after success; RunnerModelCatalog does the same invalidation behind its serialized publication fence. Each caller retains its endpoint/supplement reload and warning policies. A failed restore leaves the last usable catalog and publication revision in place, and a later valid pass retries and repairs it. No network, polling or additional refresh is introduced.