Provider configuration from the UI #10
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Depends on
#3 Add configuration loader for sermones-runtime
lyssieth/sermones
#4 Implement SermonErrorStore + ErrorsPanel UI surface
lyssieth/sermones
#9 Settings screen
lyssieth/sermones
Reference
lyssieth/sermones#10
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Settings → provider section: make the provider configurable from the UI instead of env vars. All three blockers have landed: the config loader (#3), the settings screen (#9, panel exists), and the error store (#4).
Status: complete. The shape changed twice underneath this issue — the multi-provider cutover (
a60fa8c) replaced the singleprovider.*triple withConfig.providers/Config.modelsmaps, and the settings screen (63dd0b4) landed the CRUD over both. The last two items closed in the connection-test pass.Checklist
kind,base_url,api_key) and the model record (model_id_at_provider,display_name,context_window,max_output_tokens,temperature), bound to config via theget_config/set_configIPC from #3 —frontend/src/lib/Settings.svelte(add/remove providers and models, default-model select), wired throughApp.svelteEnv-var overrides shown as such:— obsolete. Those env vars were retired; config is file-or-default with no env override (SERMONES_BASE_URL/SERMONES_MODELwin during the dev loop — the UI says why a field is currently ineffective instead of silently losingdocs/components/runtime.md). Nothing can shadow a UI-set field, so there is nothing to explain.SERMONES_DATA_DIRstill exists but is a persistence path, not a provider knob.63dd0b4;Settings.svelterenders first-run copy in both the Providers and Models sections (what a slug is, what the<slug>|<model_id>key shape is)What landed for the connection test
Probe —
providers::llama::probe_provider(&ProviderRecord) -> Result<Option<u32>, SermonError>(crates/sermones-runtime/src/providers/llama.rs). 10s timeout; the cheapest request that exercises the whole path a chat request takes — DNS, TLS, the base URL's prefix, theAuthorizationheader — while generating nothing and spending no tokens.The endpoint is per kind, and that is the whole subtlety.
GET /modelslooks universal, and forLlamaandOpenAIit is. OpenRouter's/api/v1/modelsis a public catalogue — verified live: it answers 200 with all ~338 models to an anonymous request and to a garbage Bearer token. Probing it reported "connected" for a key that cannot spend a cent, on the one kind where the credential is billable. OpenRouter is therefore probed atGET /key, which 401s on both. HenceOption<u32>:/keysays nothing about models, so a success there carries no count — not a weaker answer, the stronger one for that kind.Status mapping: 401/403 →
ProviderAuth, 429 →ProviderRateLimited, any other non-2xx and every transport failure →ProviderUnavailable, a/modelsbody that is not a model list →ProviderProtocol. The last one is deliberate: a 200 that does not parse means the URL reached something that is not an OpenAI-compatible API — often abase_urlmissing its/v1— and reporting "connected" there would cost the user exactly the debugging session this button exists to save. A/keybody is deliberately not parsed: the 200-versus-401 carries the whole answer, and demanding a shape that cannot be exercised without a live key would risk failing the one case that matters most.What a success does not prove. A server that ignores the
Authorizationheader answers the same for any key or none — a barellama-serverwithout--api-keydoes exactly this. That is the server's semantics, not a gap: there is no credential to be wrong about. Same reason aLlamaprovider whosebase_urlomits/v1still passes (llama.cpp aliases both); the same omission against OpenAI 404s.Live verification (2026-08-04) after the per-kind fix: local llama.cpp →
OK, 4 models; OpenRouter with no key and with a junk key →ProviderAuth; OpenAI with a junk key →ProviderAuth.IPC —
TestProviderParams { provider: ProviderRecord },TestProviderResult { test_id },ProviderTestPayload { test_id, ok, model_count, detail }, all registered inIPC_TYPES. The record travels inline rather than by slug so an unsaved edit can be tested, which is the case that matters.ProviderTestPayloadis flat rather than an enum for thefacet-json-schemareason indocs/architecture/ipc-pipeline.md; it mirrorsStreamEndedPayload.Bridge — a
test_providerslot plus aprovider_test_finishedsignal (crates/sermones/src/bridge.rs). The slot validates the record and answers a bad one synchronously asConfigInvalidwith nothing sent over the network; the probe runs on the tokio runtime and its outcome arrives on the signal, correlated bytest_idfrom its own counter (not the subscription registry's — a probe has no cancellation and nothing to clean up, so minting from the same space would blur what asub_idmeans).Frontend —
testProvider/onProviderTestFinishedinlib/bridge.ts;App.sveltefolds the dispatch and the signal back into one promise per call, soSettings.svelteonly awaits an outcome. The Test connection button lives in each provider's open editor — before Save, since the question is whether these credentials work and answering it after committing them is too late. Results are per-row and transient, superseded by a per-row attempt counter so a slow probe cannot land on top of a later answer.On error surfacing. The failure rides
provider_test_finishedrather thanerror_raised— it has a requesting call to answer, the same split asstream_ended's owndetail— but the shell files every failure in the error store on arrival, pererror-visibility.md("No silent swallow"). The inline result beside the row is additional: the answer goes where the user is looking, the record goes where records go.What landed for validation
ProviderRecord::validateandConfig::validateincrates/sermones-core/src/config.rs, called by theset_configslot before anything is mutated (so a refused write leaves the running app on the config it already had) and bytest_providerbefore it dials.The rules mirror the panel's
validateDraft: provider keys that parse as aSlug, a non-emptybase_url, anapi_keywhereverProviderKind::requires_api_key(blank counts as absent), model keys that parse and name a provider that exists, a non-empty wire name, and anactive_modelthat is listed. The key checks matter most:Slug/ModelId/ModelKeyare#[facet(transparent)], so their constructors never run on the way in and a|in a slug round-trips through both the file and the IPC boundary unremarked, only biting later when aModelKeybuilt from it splits in the wrong place.Deliberately not run by the loader. A file that fails these rules still loads: falling back to
Config::default()would discard the user's whole config over one bad row, and orphan model references in particular are designed to be tolerated at request time by the resolver. Validation is for the write path, where the user is present to be told.setConfiginlib/bridge.tsgrew anonRejectedcallback for the synchronous refusal — it was previously fire-and-forget and would have dropped it. The panel pre-validates the same rules and shows the problem inline, so in practice the engine's copy fires only for a config the UI did not author.Tests
crates/sermones-core/src/config.rs— 9validatecases, including the transparent-newtype key built by deserialising a config withlo|calas a provider key.crates/sermones-runtime/src/providers/llama.rs— 11probe_providercases againstmockito: model count, trailing slash on thebase_url, the Bearer header, each status mapping, an HTML 200, a refused connection, plus three pinning the per-kind endpoint (OpenRouter hits/keyand not/models, a 401 there isProviderAuth, a 200 there succeeds whatever its body). The model-list fixture is realllama-serveroutput — its nestedstatusobject with an args array is what pinsfacet-jsonskipping nested unknown structures, which a flat fixture would have missed.frontend/src/lib/providerTest.test.ts— 13 cases over the projection: the record travelling inline (and surviving TypeBox validation), an absentapi_keyomitted rather than nulled, the correlation id, a refused record, drift folding, both signal outcomes, foreign envelopes ignored on the shared channel, disconnect-by-identity, andset_config's rejection path.Docs updated in lockstep:
docs/components/bridge-qobject.md(slot + signal + the validation placement),docs/components/runtime.md(the probe, and why validation is not a load-time step),docs/components/frontend-ui.md(the connection-test UX and the shifted enforcement point).Refs
frontend/src/lib/Settings.svelte— providers / models / default-model sectionsfrontend/src/App.svelte—onSettingsSave,onSettingsRestore,onTestProvidercrates/sermones-core/src/config.rs—Config,ProviderRecord,ModelConfig,lookup_model,validatecrates/sermones/src/bridge.rs—get_config/set_config/test_providerslotsdocs/components/runtime.md— config dir resolution, retired env overrides, validation placement#3— configuration loader (landed, closed)#4— error store (landed, closed)docs/designs/draft/frontend.md— settings scaffold