request-level LLM configuration and the credential seam
请求级 LLM 配置与凭据 seam
The [settings seam](2026-07-28-user-settings-seam.md) shipped without a production consumer, and the LLM adapters were the motivating one: both froze `apiKey`/`baseURL`/catalog into adapter instances at plugin load, so a changed key or endpoint needed a process restart, and a missing key failed plugin load — the worst possible first-run posture for a personal config page ("store a key, then restart"). Secrets were al
English
Problem
The settings seam shipped without a production consumer, and the LLM adapters were the motivating one: both froze apiKey/baseURL/catalog into adapter instances at plugin load, so a changed key or endpoint needed a process restart, and a missing key failed plugin load — the worst possible first-run posture for a personal config page ("store a key, then restart"). Secrets were also headed the wrong way: the natural move (put apiKey in the settings document) would have forced masking, server-side backfill on replace, and dotfiles-sync warnings, a mitigation stack for a problem peer products simply do not have — Codex (env_key + auth.json), Reasonix (api_key_env + home .env), OpenCode/Pi (auth.json), Claude Code (apiKeyHelper) all keep secrets out of configuration files.
Decision
Per-request resolution, not fiber rebuilds. The adapters take an options thunk (and a per-stream credential resolver) instead of frozen construction facts, resolving once per operation — the Pi pattern, with its tested semantics: two requests straddling a change see two configurations, one request resolves exactly once, and an in-flight stream keeps the facts it started with. This deletes the entire swap machinery a rebuild design needs (DUPLICATE_ADAPTER ordering, NO_ADAPTER windows, a deferred-activation state machine) and makes a missing key a request-time actionable failure (MISSING_CREDENTIAL naming every entry point) while the route stays registered and the catalog stays browsable. The one registration-captured fact — the retry policy the ctx.llm registry snapshots at registerAdapter (plus pi-ai's route set) — re-registers the same adapter instance in one synchronous section when it changes.
Secrets are references, values live behind ctx.credentials. Configuration (both planes) carries apiKeyEnv: DEEPSEEK_API_KEY; the three-package credential seam resolves it per operation. credentials-local layers the live process environment (read-only, wins — a launch-time override is operator intent and must be visibly read-only, so shadowed writes reject instead of appearing to succeed) over the provider-managed document (writable, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). That document was $DSH_HOME/.env in dotenv form; the credentials document split later moved it to $DSH_HOME/.credentials.yaml and freed the old path to become the user's environment layer. Adapters resolve the reference through the seam, or — only without a mounted seam — through the environment layers.
Per-plugin namespaces, schema ≡ Config. Each adapter registers its own namespace (llm-deepseek, llm-pi-ai) with its plugin Config schema and its cordis.yml entry as the composition base — a settings section is the same YAML shape as the entry config, and resolveAdapterOptions/resolveProfiles stay the one explicit resolve step for both. A live snapshot failing a beyond-schema bound keeps the last good facts (the seam's last-good philosophy extended one level up); the entry config itself still fails load. pi-ai's providers became a dict keyed by route so base and user layers merge per provider and the route set is structural; the array shape fails loud with migration directions, and an empty dict is the valid dormant posture — a composition ships the adapter bare and every route stays a user-plane decision.
Alternatives considered
- A bridge plugin (
dsh-llm-models) owning one unifiedmodelsdict — with per-plugin namespaces there is nothing left to bridge, and the adapter-mapping rules it needed were pure invented indirection. - Secrets in settings.yaml under
role('secret')masking — deleting the problem (references) beats mitigating it (mask + backfill + sync warnings); the coding-agent cohort is unanimous. - Registry-level live retry policy — making
providerRetryPolicyre-read per call would silently change thectx.llmcapture contract every registration relies on; re-registering the route in place keeps that contract and stays observable.
Consequences
Onboarding is restart-free end to end (pinned by the missing-credential headless snapshot and the credentials-rotation composition tests): boot keyless, browse the catalog, store the key, prompt again. The demos mount settings-file + credentials-local by default and inline no !!js key plumbing. runLoaderSmoke gained expectedExitCode so a designed failure surface can be pinned rather than masked. Deferred: the wire/UI surface must redact role('secret') fields before any RPC exposes describe(), settings-layer arrays still replace wholesale (the deepseek models list), and a settings section cannot remove a composition-provided pi-ai route (only override or extend). A later decision reworked where the store lives and who may read it, made one request resolve one configuration generation, and made route replacement atomic (credential boundaries note).
中文
问题
settings seam 落地时没有生产消费方,而 LLM 适配器正是当初驱动该 seam 的那个消费方:两个适配器都在插件加载时把 apiKey/baseURL/catalog 冻结进适配器实例,改密钥或端点就要重启进程,密钥缺失则直接使插件加载失败——对个人配置页而言,这是最糟糕的首次运行姿态(「先存密钥,再重启」)。机密的走向也不对:顺理成章的做法(把 apiKey 放进设置文档)会被迫引入脱敏、replace 时的服务端回填与 dotfiles 同步告警,为一个同类产品根本没有的问题堆起一整摞缓解措施——Codex(env_key + auth.json)、Reasonix(api_key_env + 家目录 .env)、OpenCode/Pi(auth.json)、Claude Code(apiKeyHelper)全都把机密挡在配置文件之外。
决策
**按请求解析,而非重建 fiber。**适配器改为接收一个 options thunk(外加按流调用的凭据解析器),不再持有冻结的构造期事实,每个操作解析一次——即 Pi 的模式,连同其经测试固定的语义:跨越一次变更的两个请求看到两份配置,一个请求恰好解析一次,进行中的流保持其起始事实。这删掉了重建式设计所需的整套切换机制(DUPLICATE_ADAPTER 顺序问题、NO_ADAPTER 窗口、延迟激活状态机),并把密钥缺失变成请求时可据以处理的失败(MISSING_CREDENTIAL 点名每个配置入口),同时路由保持注册、catalog 保持可浏览。唯一在注册期捕获的事实——ctx.llm 注册表在 registerAdapter 时快照的重试策略(外加 pi-ai 的路由集合)——在其变化时于一个同步区段内原地重新注册同一适配器实例。
**机密是引用,值藏在 ctx.credentials 背后。**配置(两个面)携带 apiKeyEnv: DEEPSEEK_API_KEY;三包凭据 seam 按操作解析它。credentials-local 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须可见地只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在提供方管理的文档之上(可写、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。该文档当时是 dotenv 形式的 $DSH_HOME/.env;凭据文档拆分后来把它移到 $DSH_HOME/.credentials.yaml,并让旧路径转为用户的环境层。适配器通过 seam 解析该引用;仅在未挂载 seam 时,才通过各环境层解析。
**按插件划分 namespace,schema ≡ Config。**每个适配器注册自己的 namespace(llm-deepseek、llm-pi-ai),schema 用其插件 Config schema,组合 base 用其 cordis.yml 条目——settings 分节与 entry 配置是同一种 YAML 形状,resolveAdapterOptions/resolveProfiles 对两者仍是唯一的显式 resolve 步骤。实时快照若违反 schema 之外的约束,则保留最后可用事实(seam 的最后可用值哲学向上延伸一层);entry 配置本身仍会加载失败。pi-ai 的 providers 改为以路由为键的字典,base 层与用户层因此按提供方合并,路由集合也由结构直接表达;数组形状响亮失败并给出迁移指引,而空字典是合法的休眠姿态——组合可以裸挂该适配器,把每一条路由都留给用户面决定。
曾考虑的替代方案
- 由桥接插件(
dsh-llm-models)持有统一的models字典——有了按插件划分的 namespace,就没有什么可桥接的了;它所需的适配器映射规则纯属凭空发明的间接层。 - 把机密放进 settings.yaml 并靠
role('secret')脱敏——删除问题本身(引用)胜过缓解问题(脱敏 + 回填 + 同步告警);coding-agent(智能体)同类产品在这一点上口径一致。 - 注册表级的实时重试策略——让
providerRetryPolicy每次调用都重读,会静默改变所有注册都依赖的ctx.llm捕获约定;原地重新注册路由既保住该约定,又保持可观察。
后果
上手流程端到端免重启(由 missing-credential headless 快照与凭据轮换组合测试固定):无密钥启动、浏览 catalog、存入密钥、再次发起提示。demo 默认挂载 settings-file + credentials-local,不再内联任何 !!js 密钥接线。runLoaderSmoke 新增 expectedExitCode,使按设计出现的失败面可以被固定而非被掩盖。延后事项:wire/UI 面在任何 RPC 暴露 describe() 之前必须对 role('secret') 字段脱敏;settings 层的数组仍整体替换(deepseek 的 models 列表);settings 分节无法移除组合提供的 pi-ai 路由(只能覆盖或扩展)。后来的一项决策改造了存储的所在位置与谁可以读取它,让一个请求解析出一个配置世代,并使路由替换成为原子操作(credential boundaries note)。