9.0 KiB
Agent Note: the web configuration plane
Status: implemented
English | 中文
Scope: the wire face and web UI deferred from the request-level LLM configuration note — the
settings.*/credentials.*/llm.*RPC domains with pushed invalidations, layered+redacteddescribe(), the llm configurable-provider directory and topology event, the standalonedsh-client-schema-formmodel layer, and the Models settings page with its hand-written provider editor. Thedeepseek→deepseek-officialprovider-route rename rides along as the enabling breaking change.
Problem
PR1 made LLM adapter configuration restart-free at the seam, but the only writer was a text editor on settings.yaml: the web client had no wire access to settings, credentials, or provider topology, so "store a key, prompt again" still meant leaving the product. Three gaps blocked a config page rather than one: describe() returned only the merged effective value (a form cannot tell a user override from a composition default, and serializing it would have shipped role('secret') values to every browser), nothing enumerated the providers an adapter could run (a bare-mounted llm-pi-ai was invisible until configured), and the two adapters both wanted a deepseek route key, so the directory could not attribute routes to owning namespaces unambiguously. Hand-maintaining a form per provider was rejected outright — the schemas already exist as schemastery Config values, and a second source of field truth drifts.
Decision
Wire domains on the compiled RPC map, rejections as codes, invalidations as frames. settings.describe/update/replace, credentials.describe/set/unset, llm.providers, and llm.models (claiming the reserved host.listModels surface) join RpcMethodMap, so the seven compiler-locked wiring sites keep contract, schema, handler, and client in lockstep. Seam rejections fold into settings-rejected {ns} / credential-rejected {ref} business errors (HTTP stays a carrier), and three HostFrames — host/settings-changed {ns}, host/credentials-changed {ref}, host/models-changed — follow the host/commands-changed shape so every client converges without polling. Writes join pickDirectory/openPath in the connection guard's privileged set: loopback + same-origin or 403, because a LAN-exposed dsh web must not accept config mutation from another origin.
describe() grows layers and structural secret redaction. SettingsDescriptor carries base/user beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override equal to the base is still an override). describe({ redactSecrets: true }) — mandatory at every wire face — strips role('secret') subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as {path, set}, so a page can render write-only inputs without ever receiving a value.
The llm seam declares configurability and announces topology. registerConfigurableProviders() is an all-or-nothing, fiber-scoped directory of {provider, displayName, settingsNs, settingsPath} — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; listConfigurableProviders() merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload 'llm/adapters-updated' event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. llm-deepseek's route renamed to deepseek-official because the pi-ai catalog legitimately owns deepseek as an aggregator entry; pre-release stance, no alias.
A hand-written editor over a schema model layer. dsh-client-schema-form rehydrates the wire's toJSON() envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the retryPolicy unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one API key input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (baseURL for both families, plus reasoningEffort for deepseek / reasoning for pi-ai), with every other field owned by settings.yaml. Validation still runs the rehydrated schema before writing, so a hand-coded field that drifts from its schema fails loud on save rather than silently.
The Models page is a three-domain join with seam-shaped apply semantics. Rows are configured providers; the add card's select is the dormant directory remainder; badges come from route liveness. The key path stays reference-shaped without ever showing a reference: a typed key stores write-only through credentials.set under the profile's apiKeyEnv, deriving <ROUTE>_API_KEY when none exists (the pi-ai profile records the derivation), so settings.yaml never carries a key value and the wholesale settings.replace a removal needs can never drop a sibling's secret. An edit without removals lands as a minimal settings.update merge patch; clearing a fold field back to inherited or deleting a row replaces the whole user section, because merge semantics cannot express removal.
Alternatives considered
- Serving JSON Schema over the wire — schemastery's
toJSON()envelope round-tripsrole()/meta and rehydrates into the validator the client already ships for drafts; converting to JSON Schema loses exactly the role annotations the credential control and secret redaction key on. - A generic schema-driven form renderer — implemented first, then replaced: field truth without visual hierarchy produced an ugly, unusable card, and making it good meant building a hint vocabulary (primary/advanced grouping, per-field descriptions, array item cards) rivaling the hand-written editor in cost while still fitting no mockup exactly. Two schemas exist today (the deepseek
Configand the shared pi-ai profile), so hand-writing is two thin namespace-keyed layouts; the drift risk is bounded by save-time schema validation and by unknown fields staying untouched in the document. - Masking secrets per-field with sentinel backfill on
replace— the PR1 decision (secrets are references) already deleted the stored-literal case for the product default; structural redaction plus a write-only credential path handles the residue without teaching every writer a sentinel protocol. - Storing the typed key as a literal
apiKeysetting — the v1 "one API key input" requirement could have written the literal into the profile, but every UI removal path rebuilds the user section from the redacted layers, so any reset or row deletion would silently drop stored sibling keys; deriving a reference keeps the input single-field while keepingsettings.yamlsecret-free and every replace safe. - A
modelsbridge plugin owning provider configuration — same rejection as PR1: per-plugin namespaces plus a four-field directory declaration give the UI everything it needs; the bridge's unified dict re-imports the adapter-mapping indirection. - Page-side polling instead of pushed frames — the mux already carries
host/commands-changed; three more frames cost one shape each and make a second tab, an externalsettings.yamledit, and a settings-born route converge at event speed.
Consequences
The whole loop is pinned keyless in the browser lane (apps/web/tests/models-settings.e2e.ts): the add card offers the dormant pi-ai catalog, adding minimax-cn with a typed key writes the reference-only profile into settings.yaml, stores the value into the harness home's .env under the derived MINIMAX_CN_API_KEY, registers the route live on the topology frame, and the customized fold merges reasoning beside the reference — zero model calls, ARIA goldens for the add-card and configured states, plus a scaffold harnessHome so tests never touch a real ~/.dsh (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and the documented reset edge — a settings.replace cannot re-supply a stored literal secret in the replaced subtree, which the reference-based default makes unreachable.