Merge pull request #884 from deepseek-harness/worktree-llm-dynamic-config

feat(llm): request-level dynamic configuration over ctx.settings + a credentials seam
This commit is contained in:
Yichen Jiang
2026-07-31 00:47:02 +08:00
committed by GitHub
130 changed files with 5133 additions and 670 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md
2026-07-29-request-level-llm-config-credentials.md: f12a2496a767decc3ce2b065f6be03009aec8992
2026-07-29-request-level-llm-config-credentials.zh.md: 99fd90013a24746962ca02a5f4f18cdccd53f71a
@@ -0,0 +1,29 @@
# Agent Note: request-level LLM configuration and the credential seam
Status: implemented
English | [中文](2026-07-29-request-level-llm-config-credentials.zh.md)
> Scope: the first production consumers of `ctx.settings` (the two LLM adapter plugins), the new `packages/credentials/` capability family, and the `packages/util/atomic-write` extraction. The follow-up wire surface (`settings.*`/`credentials.*` RPC, secret-role masking, the web settings form) is a separate PR and not part of this note's shipped scope.
## Problem
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 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 `$DSH_HOME/.env` (writable, byte-preserving line edits, a quoting ladder dotenv reads back verbatim, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). Resolution order in the adapters is literal `apiKey` first (preserving the historical `config.apiKey ?? env` observable semantics), then the seam, then — only without a mounted seam — the raw environment variable.
**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 unified `models` dict** — 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 `providerRetryPolicy` re-read per call would silently change the `ctx.llm` capture 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-local` + `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). Review of this seam later 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](2026-07-30-credential-boundaries-and-atomic-registration.md)).
@@ -0,0 +1,29 @@
# Agent Note:请求级 LLM 配置与凭据 seam
Status: implemented
[English](2026-07-29-request-level-llm-config-credentials.md) | 中文
> 范围:`ctx.settings` 的第一批生产消费方(两个 LLM 适配器插件)、新增的 `packages/credentials/` 能力族,以及 `packages/util/atomic-write` 的抽取。后续的 wire 面(`settings.*`/`credentials.*` RPC、secret 角色脱敏、web 设置表单)是单独的 PR,不在本 note 已交付范围内。
## 问题
[settings seam](2026-07-28-user-settings-seam.md) 落地时没有生产消费方,而 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` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 `$DSH_HOME/.env` 之上(可写、保字节行级编辑、dotenv 能逐字读回的引号阶梯、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。适配器内的解析顺序为:字面 `apiKey` 优先(保留历史 `config.apiKey ?? env` 的可观察语义),然后是 seam,最后——仅在未挂载 seam 时——原始环境变量。
**按插件划分 namespaceschema ≡ `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')` 脱敏**——删除问题本身(引用)胜过缓解问题(脱敏 + 回填 + 同步告警);编码 agent 同类产品在这一点上口径一致。
- **注册表级的实时重试策略**——让 `providerRetryPolicy` 每次调用都重读,会静默改变所有注册都依赖的 `ctx.llm` 捕获契约;原地重新注册路由既保住该契约,又保持可观察。
## 后果
上手流程端到端免重启(由 `missing-credential` headless 快照与凭据轮换组合测试固定):无密钥启动、浏览 catalog、存入密钥、再次发起提示。demo 默认挂载 `settings-local` + `credentials-local`,不再内联任何 `!!js` 密钥接线。`runLoaderSmoke` 新增 `expectedExitCode`,使按设计出现的失败面可以被固定而非被掩盖。延后事项:wire/UI 面在任何 RPC 暴露 `describe()` 之前必须对 `role('secret')` 字段脱敏;settings 层的数组仍整体替换(deepseek 的 `models` 列表);settings 分节无法移除组合提供的 pi-ai 路由(只能覆盖或扩展)。对该 seam 的评审随后改造了存储的所在位置与谁可以读取它,让一个请求解析出一个配置世代,并使路由替换成为原子操作([credential boundaries note](2026-07-30-credential-boundaries-and-atomic-registration.md))。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md
2026-07-30-credential-boundaries-and-atomic-registration.md: 6fe5f554acbfd804db9625fcaa794d513c8799c4
2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 3eb3b022064124aad2a389abba3063af4e2110fa
@@ -0,0 +1,36 @@
# Agent Note: credential boundaries, whole-snapshot requests, and atomic route registration
Status: implemented
English | [中文](2026-07-30-credential-boundaries-and-atomic-registration.zh.md)
> Scope: the third review round over the [request-level LLM configuration seam](2026-07-29-request-level-llm-config-credentials.md) — where a stored credential lives and who can read it, how one request's facts stay one generation, and how a route set changes without a window. Companion to the [settings write-path note](2026-07-30-settings-write-path-integrity.md), whose provider fixes this round applies to `credentials-local` and whose writer lock it promotes into `dsh-atomic-write`.
## Problem
Review found the credential path leaking across boundaries it had drawn. The shipped surfaces hoisted `$DSH_HOME/.env` into `process.env` before cordis booted, so on the next run `credentials-local` classified every key it had stored itself as a read-only ambient launch override: `describe()` reported `source: 'env'` with `writable: false`, `set`/`unset` rejected as shadowed, and a key stored from the web page or TUI became unrotatable and undeletable while the adapter kept using the value captured at launch. The store's own write path repeated the settings-local defects that same review round fixed (two independent chains, whole-file render from a stale cache), plus editor bugs of its own: a physical line inside another key's quoted multi-line value read as an assignment, CRLF endings degraded to LF, a multi-line entry reported `writable: true` while `set` always threw, and `credentials/updated` was emitted bare after the commit, so one broken observer made a durable write look failed. On the read side, the file's `0600` mode stops other OS users but not the model, whose bash and filesystem tools run as the same user.
Two request-path defects sat beside them. DeepSeek's per-request resolution kept connection facts in a last-good snapshot but re-read the literal `apiKey` from the raw configuration, so a settings generation the resolver rejected could still put its key on the previous generation's endpoint. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed. And its route swap disposed the old registration before creating the new one: a route another adapter owned dropped every existing route, after which the facts cache could equal the registry's, so restoring the working configuration never re-applied.
## Decision
**`$DSH_HOME/.env` belongs to the credential provider alone.** No surface loads it into `process.env`. The genuine launch environment and the invoking directory's `.env` (loaded by the bin) stay the read-only ambient layer, so a composition without the provider resolves keys exactly as before, while a stored key stays file-sourced and writable across restarts — proven by a real restart in the loader composition rather than by a unit assertion about `describe()`.
**The stored credential has no boundary against the model, and the READMEs say so.** `0600` under a `0700` directory stops other OS users; the model's bash and filesystem tools run as that same user, and the shipped default confines nothing. What the harness does hold to is narrower and stated as exactly that: no surface hoists the document into `process.env`, and the model is never handed a resolved path to it, so reaching the value takes a deliberate read of a path it was not given. An OS-keychain provider — a store the model's processes cannot read at all — is recorded as the real answer rather than implied by a partial one.
**One request, one generation.** DeepSeek's resolved snapshot carries the credential facts (literal key and reference) beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. A rejected generation now contributes nothing at all. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and the reference. The boot-time credential probe is deleted: it could run before the credentials service mounted and reported every failure as a missing key, while the first request already gives the accurate error.
**Route replacement is a registry operation, not a caller sequence.** `registerAdapter` returns a handle carrying `replace(providers)`: the candidate set is validated in full first (conflicts, names, provider metadata), then swapped in one synchronous section. A refused replacement leaves the previous routes registered and serving, and the caller's facts cache only advances after the registry actually holds the new set, so reverting to a working configuration re-applies. pi-ai's registration facts are sorted by provider, so a settings document that merely reorders its keys is no longer a route change.
**Contained publication for committed credential writes.** `Credentials.notifyUpdated` fans `credentials/updated` out one listener at a time; sync throws and async rejections are logged without changing the committed operation's outcome, and `INVARIANT`-coded failures rethrow after every listener ran — the same shape the settings seam uses for `settings/updated`. `installSettingsSection`'s cleanup now distinguishes its two triggers: a provider detaching still falls back to the composition entry and re-derives, while the consumer's own unload returns immediately instead of re-registering routes during teardown.
## Alternatives considered
- **A sandbox read-denial naming `$DSH_HOME/.env`** — implemented as a `readDenyPaths` policy field (a trailing SBPL `deny file-read* file-write*`, a `/dev/null` bwrap bind) and withdrawn on its own evidence. bwrap must create that bind's mount point inside a tree its profile has already made read-only, so it refuses the entire confinement whenever the parent directory is absent — every host that has not stored a credential yet, including a fresh install; Landlock cannot subtract from its own `/` read grant, so every confined call would report `partial` for a file it never hid. A protection that breaks confinement where it works and misreports it where it does not is worse than a documented absence. Denying the whole harness home was rejected earlier for a separate reason: it also covers `sessions/`, and `DSH_SESSION_JSONL` is a documented model-visible capability.
- **Removing `DSH_HOME` from the model's bash environment** — considered as defense in depth and rejected as theater with a real cost: the default home is a documented convention the agent can reconstruct, while the variable is how legitimate tooling finds harness state. There is no boundary here for it to complement; hiding the pointer would only make the absence harder to see.
- **Shipping the OS-keychain provider in this round** — it is the only design where the model's processes genuinely cannot read the secret, and it is a sibling package with three platform backends. Sizing it against the rest of this review round would have delayed every other fix; it is recorded as the deferred answer, not as a maybe.
- **A `replaceRegistration(previous, next)` service method** — the review's shape, but it makes the caller carry the previous handle and lets it pass a mismatched one. Hanging `replace` on the registration handle makes ownership structural: only the registration that holds routes can replace them.
## Consequences
`update()`-adjacent behavior gained documented failure modes: a credential write can now fail on the lock deadline or on an unparsable on-disk document, and `describe()` reports `writable: false` for multi-line entries it will not rewrite. `LlmAdapter` registrants keep working unchanged (the handle is still callable as the disposer), and `DeepSeekConnectionOptions` gained credential fields, so a programmatic constructor of the adapter must supply `apiKeyEnv`. Deferred: the OS-keychain credential provider, and per-value revision checks for two writers editing one reference (last-write-wins remains the documented resolution).
@@ -0,0 +1,40 @@
# Agent Note: 凭据边界、按整份快照发起的请求与原子路由注册
Status: implemented
[English](2026-07-30-credential-boundaries-and-atomic-registration.md) | 中文
> 范围:对[请求级 LLM(大语言模型)配置 seam](2026-07-29-request-level-llm-config-credentials.md)的第三轮评审——存下来的凭据落在哪里、谁能读到它,一次请求的事实如何保持为同一代,以及一组路由如何在不留空窗的前提下更换。本 note 与 [settings 写路径 note](2026-07-30-settings-write-path-integrity.md) 配套:本轮把那篇 note 的提供方修复套用到 `credentials-local`,并把其中的写锁提升进 `dsh-atomic-write`。
## 问题
评审发现,凭据路径正在越过它自己划下的边界泄漏。已交付的各个面在 Cordis 启动之前就把 `$DSH_HOME/.env` 提升进了 `process.env`,于是下一次运行时,`credentials-local` 会把它自己存下的每个键都判成来自环境的只读启动覆盖:`describe()` 报告 `source: 'env'``writable: false``set`/`unset` 以被遮蔽为由拒绝,从 web 页面或 TUI 存入的密钥既无法轮换也无法删除,而适配器还在继续使用启动时捕获的那个值。
存储自身的写路径重演了同一轮评审在 settings-local 修掉的那些缺陷(两条相互独立的链、从陈旧缓存渲染整份文件),还叠加了编辑器自己的缺陷:另一个键的带引号多行值内部的一条物理行会被读成赋值,CRLF 行尾会退化成 LF,多行条目报告 `writable: true``set` 总是抛错,`credentials/updated` 又在提交之后裸发,于是一个出错的观察者就能让一次已经落盘的写入看起来失败。
在读取一侧,文件的 `0600` 权限挡得住其他 OS 用户,却挡不住模型:它的 bash 与文件系统工具就以同一个用户身份运行。
与之并排的还有两个请求路径缺陷。DeepSeek 的按请求解析把连接事实保存在最后可用快照里,却仍从原始配置重新读取字面 `apiKey`,于是被 resolver 拒绝的那一代设置,照样能把自己的密钥送到上一代的端点上。配置了 `apiKeyEnv` 却解析不到值时,pi-ai 会把 `undefined` 交给 SDK,让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。而且它的路由替换是先释放旧注册、再创建新注册:只要有一条路由已被别的适配器占有,现有路由就会被全部丢掉,此后事实缓存可能与注册表中的事实相等,于是把配置改回可用状态也不会重新生效。
## 决策
**`$DSH_HOME/.env` 只归凭据提供方所有。**没有任何一个面会把它加载进 `process.env`。真正的启动环境,以及调用目录中由 bin 加载的 `.env`,仍然是那一层只读的环境来源,因此不挂载该提供方的组合,解析密钥的方式与从前完全一致,而存下的密钥跨重启仍然来源于文件、仍然可写——这一点由 Loader 组合中的一次真实重启来证明,而不是靠对 `describe()` 的单元断言。
**存下的凭据对模型没有边界,而 README 就是这么写的。**`0700` 目录下的 `0600` 挡得住其他 OS 用户;模型的 bash 与文件系统工具正是以同一用户身份运行,而已交付的默认值不约束任何东西。harness 真正守住的更窄,也就照这个宽度写下来:没有任何一个面会把该文档提升进 `process.env`,模型也从不会拿到它的解析后路径,因此要拿到这个值,需要刻意去读一条并未交给它的路径。OS 钥匙串(keychain)提供方——一个模型的进程根本读不到的存储——被记录为真正的答案,而不是靠一个残缺的方案去暗示它。
**一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据事实(字面密钥与引用),`resolveApiKey` 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时,pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名该路由与该引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。
**路由替换是注册表的操作,不是调用方的一串步骤。**`registerAdapter` 返回一个携带 `replace(providers)` 的句柄:候选集合先被完整校验(冲突、名称、提供方元数据),再在一个同步区段内完成替换。被拒绝的替换会让先前的路由保持注册并继续服务,而调用方的事实缓存只有在注册表确实持有新集合之后才会推进,因此改回可用配置时会重新生效。pi-ai 的注册事实按提供方排序,因此仅仅调换键顺序的设置文档不再算作路由变更。
**已提交的凭据写入采用收容式发布。**`Credentials.notifyUpdated` 逐个监听器扇出 `credentials/updated`;同步抛错与异步 rejection 都只记日志,不改变已提交操作的结果,而带 `INVARIANT` 代码的失败会在每个监听器都运行完之后重抛——与 settings seam 处理 `settings/updated` 的形状相同。`installSettingsSection` 的清理现在会区分它的两个触发来源:提供方脱离时仍回退到组合的 entry 配置并重新推导,而消费方自身卸载时立即返回,不再在拆卸过程中重新注册路由。
## 曾考虑的替代方案
- **用沙箱点名拒读 `$DSH_HOME/.env`**——已按 `readDenyPaths` 策略字段实现过(末尾一条 SBPL `deny file-read* file-write*`、一条 `/dev/null` 的 bwrap bind),又被它自己的证据推翻。bwrap 必须在自己 profile 已经置为只读的目录树内部创建该 bind 的挂载点,因此只要父目录不存在,它就会拒绝整次约束——那是每一台还没有存过凭据的主机,包括全新安装;Landlock 无法从它自己对 `/` 的读取授权中减去任何东西,于是每一次受限调用都会为一个它其实从未藏起的文件报 `partial`。一项在生效之处破坏约束、在不生效之处误报的保护,比一条写明的「没有保护」更糟。至于拒掉整个 harness home,早先另有理由被否:它同时覆盖 `sessions/`,而 `DSH_SESSION_JSONL` 是一项成文的、模型可见的能力。
- **把 `DSH_HOME` 从模型的 bash 环境中移除**——作为纵深防御考虑过,最终按「有真实代价的表演」不予采纳:默认 home 是 agent(智能体)能自行重建的成文约定,而这个变量正是正当工具链定位 harness 状态的途径。这里并不存在一条需要它来补强的边界,藏起指针只会让这份缺席更难被看见。
- **本轮就交付 OS 钥匙串提供方**——只有这个设计能让模型的进程真正读不到机密,而它是一个带三种平台后端的兄弟包(package)。把它与本轮评审的其余工作放在一起评估体量,会拖慢其他每一项修复;它被记录为那个延后的答案,而不是一个「也许」。
- **做成 `replaceRegistration(previous, next)` 服务方法**——这是评审给出的形状,但它要求调用方自行携带上一个句柄,也允许它传入一个不匹配的句柄。把 `replace` 挂在注册句柄上,让归属关系变成结构性的:只有持有路由的那一项注册才能替换它们。
## 后果
`update()` 邻近的行为多了成文的失败模式:凭据写入现在可能因锁截止时间到期、或磁盘文档无法解析而失败,`describe()` 对它不会改写的多行条目报告 `writable: false``LlmAdapter` 的注册方无需改动即可继续工作(句柄本身仍可当作释放器调用),`DeepSeekConnectionOptions` 则新增了凭据字段,因此以编程方式构造该适配器必须提供 `apiKeyEnv`。延后事项:OS 钥匙串凭据提供方,以及针对两个写方编辑同一引用的逐值修订号检查(后写胜出仍是成文的解决方式)。
+1
View File
@@ -32,6 +32,7 @@ packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
hooks/ Claude Code/Codex hook bridges + shared wire-protocol library
session-persistence/ persistence seam + JSONL/SQLite backends
settings/ user-settings seam + file-backed provider
credentials/ credential-reference seam + env-over-.env provider
acp/ automation-only Agent Client Protocol server
ui/ TUI/JSON-RPC bridges; boot, approval, interaction plugins
examples/ demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/README.md
README.md: 2bc36cce6205a4bfc3ba1d7ee15f0e0b2feab215
README.zh.md: 0e0771658cecb4bee0f3eadd0639ad531e64ea3c
README.md: d783d75cc9747d13887386fcf7609a6778e5dfb5
README.zh.md: 3f5ce7e7a3a302fd9e255c1042ccb7b03deb59d9
+2 -2
View File
@@ -11,9 +11,9 @@ The TUI surface:
- resumes a persisted session with `dsh --resume <session-id>` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, and a missing or unreadable id fails loud instead of creating a fresh session;
- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below);
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`.
`dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after both `.env` layers are loaded, so environment precedence is unchanged while the session cwd and HMR watch root move together. Meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume <id>` to resume a persisted session.
`dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after the environment is settled, so precedence is unchanged while the session cwd and HMR watch root move together. Meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume <id>` to resume a persisted session.
`dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:<name>`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume <id>` of the session is an ordinary TUI session with no re-injection.
+2 -2
View File
@@ -11,9 +11,9 @@ TUI 界面:
- 使用 `dsh --resume <session-id>` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id,并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话;
-**调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文);
- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它;
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`
`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在两层 `.env` 都加载之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume <id>`
`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在环境确定之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume <id>`
`dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:<name>`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config``-p``--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume <id>` 恢复该会话时是普通 TUI 会话,不会重复注入。
+6
View File
@@ -28,6 +28,10 @@ flowchart LR
cfg --> plugin_tui_tasks
plugin_tui_llm_retry["llm-retry<br/>@deepseek-ai/dsh-llm-retry"]
cfg --> plugin_tui_llm_retry
plugin_tui_settings["settings<br/>@deepseek-ai/dsh-settings-local"]
cfg --> plugin_tui_settings
plugin_tui_credentials["credentials<br/>@deepseek-ai/dsh-credentials-local"]
cfg --> plugin_tui_credentials
plugin_tui_llm_pi_ai["llm-pi-ai<br/>@deepseek-ai/dsh-llm-pi-ai"]
cfg --> plugin_tui_llm_pi_ai
plugin_tui_session_persistence_jsonl["session-persistence-jsonl<br/>@deepseek-ai/dsh-session-persistence-jsonl"]
@@ -114,6 +118,8 @@ flowchart LR
| `agent` | `@deepseek-ai/dsh-agent` |
| `tasks` | `@deepseek-ai/dsh-tasks-local` |
| `llm-retry` | `@deepseek-ai/dsh-llm-retry` |
| `settings` | `@deepseek-ai/dsh-settings-local` |
| `credentials` | `@deepseek-ai/dsh-credentials-local` |
| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` |
| `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` |
| `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` |
+24 -13
View File
@@ -56,16 +56,28 @@
- id: llm-retry
name: '@deepseek-ai/dsh-llm-retry'
# User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a
# `llm-deepseek:` or `llm-pi-ai:` section there overrides the adapter entries
# below without a restart, and is what the web Models page writes.
- id: settings
name: '@deepseek-ai/dsh-settings-local'
# Credential store: the live process environment over `$DSH_HOME/.env`
# (owner-only file, hot-reloaded). Adapters resolve their key references
# through it at each request, so no key is inlined in this file — and nothing
# hoists that document into the process environment, which would make every
# stored key read as an unrotatable ambient override.
- id: credentials
name: '@deepseek-ai/dsh-credentials-local'
# The pi-ai multi-provider twin, mounted dormant: zero routes (and no extra
# models in the picker) until a `llm-pi-ai:` settings section supplies provider
# profiles — then those routes register live, keys resolving per request
# through their apiKeyEnv references, and drop again when the section empties.
# Which adapters exist is composition; which providers run is the user's
# settings document.
- id: llm-pi-ai
name: '@deepseek-ai/dsh-llm-pi-ai'
config:
providers:
- provider: openai
apiKey: !!js process.env.OPENAI_API_KEY
baseURL: !!js process.env.OPENAI_BASE_URL
- provider: anthropic
apiKey: !!js process.env.ANTHROPIC_API_KEY
baseURL: !!js process.env.ANTHROPIC_BASE_URL
- id: session-persistence-jsonl
name: '@deepseek-ai/dsh-session-persistence-jsonl'
@@ -223,10 +235,9 @@
- id: fs-local
name: '@deepseek-ai/dsh-fs-local'
# The native DeepSeek adapter; reads the key/base-url the boot's layered .env
# loading left in the environment. Thinking defaults are a surface choice.
# The native DeepSeek adapter. No key or endpoint is inlined: both resolve per
# request from the `llm-deepseek:` settings section over this entry, with the
# key coming from the credential store below. Thinking defaults are a surface
# choice.
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
+3 -1
View File
@@ -51,6 +51,7 @@
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact-basic": "workspace:^",
"@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^",
"@deepseek-ai/dsh-credentials-local": "workspace:^",
"@deepseek-ai/dsh-frontend": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
@@ -73,7 +74,6 @@
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
@@ -83,6 +83,7 @@
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
"@deepseek-ai/dsh-settings-local": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-spill-local": "workspace:^",
@@ -100,6 +101,7 @@
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
"@deepseek-ai/dsh-tool-goal": "workspace:^",
+9 -14
View File
@@ -1,10 +1,12 @@
/**
* AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
* for the Web/headless surface.
* Everything here is what must exist before the Loader runs: layered env,
* the patch composition over the shipped base and surface overlay (profile json + CLI
* flags + the resolved frontend dist), and the fail-loud triple after the
* tree settles.
* Everything here is what must exist before the Loader runs: the patch
* composition over the shipped base and surface overlay (profile json + CLI
* flags + the resolved frontend dist), and the fail-loud triple after the tree
* settles. The environment is what the bin already loaded (ambient plus the
* invoking directory's `.env`); `$DSH_HOME/.env` belongs to the credential
* provider and is never hoisted here.
*/
import { readFileSync } from 'node:fs'
@@ -14,8 +16,7 @@ import { join, resolve } from 'node:path'
import { Context } from 'cordis'
import type { PatchOptions } from '@cordisjs/plugin-include'
import yaml from 'js-yaml'
import { boot, installFailLoud, loadEnv, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { boot, installFailLoud, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot'
// Empty type import carries the httpServer Context merge for the port read below.
import type {} from '@deepseek-ai/dsh-host-webserver'
@@ -144,12 +145,11 @@ export class AppCLIEntry {
constructor(private readonly options: AppCLIEntryOptions) {}
/**
* Run the boot chain: layered env → patch composition → Loader include
* boot (dev row before await) → fail-loud triple.
* Run the boot chain: patch composition → Loader include boot (dev row
* before await) → fail-loud triple.
* @returns the settled root context and the listening port.
*/
async run(): Promise<{ ctx: Context; port: number }> {
this.loadEnvLayers()
this.composePatches()
await this.bootTree()
this.assertBoot()
@@ -159,11 +159,6 @@ export class AppCLIEntry {
return { ctx: this.ctx, port }
}
/** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */
private loadEnvLayers(): void {
loadEnv('dsh', resolveDshHome())
}
/**
* Compose the patch set from profile json, CLI flags, and the resolved
* frontend dist. Patches replace a row's config wholesale, so each patched row's yml
+6 -7
View File
@@ -26,12 +26,10 @@ import {
addHarnessSourceSection,
boot,
installFailLoud,
loadEnv,
loadOverlayPatches,
loadPersonalPatches,
resolveConfigPath,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { SessionId } from '@deepseek-ai/dsh-session'
import { SESSION_QUERY_SQLITE_PATH_KEY } from '@deepseek-ai/dsh-session-query-sqlite'
import { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop'
@@ -124,11 +122,12 @@ export async function runTui(
process.exit(1)
}
installFailLoud(NAME)
// The bin already loaded the invoking directory's .env; the personal .env
// only fills what is still unset (process.loadEnvFile never overrides).
loadEnv(NAME, resolveDshHome())
// Both .env layers are loaded, so switching the workspace here cannot alter
// environment precedence. The cwd IS the workspace seam: the shipped config
// The bin already loaded the invoking directory's .env, and that is the
// whole environment: $DSH_HOME/.env is credentials-local's writable store,
// and hoisting it would make every stored key read as a read-only ambient
// override on the next run — unrotatable from the TUI or the web page.
// The environment is settled, so switching the workspace here cannot alter
// its precedence. The cwd IS the workspace seam: the shipped config
// resolves the session cwd and the HMR watch root from it, so one chdir moves
// both together. Sessions themselves live under the Harness home so `/resume`
// spans every workspace, and are unaffected by this chdir.
+16 -10
View File
@@ -353,34 +353,40 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => {
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('applies the personal overlay: config.yaml patches the tree and .env feeds its !!js', async () => {
// The whole personal-config chain in one boot: the personal .env supplies
// the variable, config.yaml patches the `tui` row — a row the SURFACE
// OVERLAY inserted, not one the base declares — with a `!!js` reference to
// it, and the banner renders the patched welcome verbatim. That proves a
// later patch list reaches a row an earlier one inserted.
it('applies the personal overlay: config.yaml patches an overlay-inserted row, the invoking directory\'s .env feeds its !!js, and the home .env stays out of the environment', async () => {
// The whole personal-config chain in one boot, plus the environment layer
// it deliberately excludes. config.yaml patches the `tui` row — a row the
// SURFACE OVERLAY inserted, not one the base declares — proving a later
// patch list reaches a row an earlier one inserted. The single `!!js`
// expression prefers the PERSONAL variable, so the welcome can only render
// the project value while the harness home's .env — the credential store
// of `dsh-credentials-local` — is NOT hoisted into `process.env`; hoisting
// it would make every stored key read as a read-only launch override on
// the next run and hand it to every subprocess the agent starts.
const output = await smoke({
label: 'dsh personal overlay',
tempDirPrefix: 'dsh-personal-overlay-',
binScript: dshBinScript,
configArgs: [],
prepare: seedWorkspace({
workspace: { '.env': 'DSH_PROJECT_WELCOME=PROJECT OVERLAY READY.\n' },
personal: {
'.env': 'DSH_PERSONAL_WELCOME=PERSONAL OVERLAY READY.\n',
'.env': 'DSH_PERSONAL_WELCOME=HOME ENV LEAKED.\n',
'config.yaml': [
'- id: workspace-context',
' disabled: true',
'- id: tui',
' config:',
" sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'",
' welcome: !!js process.env.DSH_PERSONAL_WELCOME',
' welcome: !!js process.env.DSH_PERSONAL_WELCOME ?? process.env.DSH_PROJECT_WELCOME',
'',
].join('\n'),
},
}),
actions: [{ waitFor: 'PERSONAL OVERLAY READY.', send: '/exit\r' }],
actions: [{ waitFor: 'PROJECT OVERLAY READY.', send: '/exit\r' }],
})
expect(output).toContain('PERSONAL OVERLAY READY.')
expect(output).toContain('PROJECT OVERLAY READY.')
expect(output).not.toContain('HOME ENV LEAKED.')
expect(output).toContain('\u001B[?2004l')
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/architecture.md
architecture.md: 1fd9bd128d1bcc0dd91d46131981ea4fc331bd74
architecture.zh.md: 8521f09c6e415f9f8d1c0a44f7534b59c876decc
architecture.md: bfea67b9f83958e16b58e63b99e326349f6eff15
architecture.zh.md: c2fd6cdd84ad2f6435faebffa0c4c1a6da0ade96
+2
View File
@@ -46,6 +46,8 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services,
| `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable session-log storage |
| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact/filter/trace queries over SQLite FTS, workspace-authorized model tools |
| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | log-backed fallbacks, one optional asynchronous provider |
| `ctx.settings` | [`settings/`](../packages/settings/README.md) | per-plugin user-settings namespaces layered over composition entries |
| `ctx.credentials` | [`credentials/`](../packages/credentials/README.md) | named secret references resolved per operation, never inlined in configuration |
| `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI-host directory picking (`native`/`browse` interactions) |
| `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | runtime registry for generated package reflection and live Zod schemas |
| `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry of package-owned runtime checks |
+2
View File
@@ -46,6 +46,8 @@
| `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | 会话日志的持久化存储 |
| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 基于 SQLite 全文搜索的实时优先精确检索/过滤/追踪、经工作区授权的模型工具 |
| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | 基于日志的回退标题和单个可选异步提供方 |
| `ctx.settings` | [`settings/`](../packages/settings/README.md) | 按插件划分的用户设置命名空间,分层叠加在装配条目之上 |
| `ctx.credentials` | [`credentials/`](../packages/credentials/README.md) | 具名密钥引用,按操作解析,绝不内联进配置 |
| `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI 宿主目录选取(`native``browse` 交互) |
| `ctx.typert` | [`typert/registry`](../packages/typert/registry/README.md) | 生成的包反射和实时 Zod schema 的运行时注册表 |
| `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名筛选包自有运行时检查的注册表 |
+11 -1
View File
@@ -41,6 +41,9 @@ flowchart LR
pkg_settings["settings"]
svc_settings["ctx.settings<br/>User-settings seam"]
pkg_settings_local["settings-local"]
pkg_credentials["credentials"]
svc_credentials["ctx.credentials<br/>Credential seam"]
pkg_credentials_local["credentials-local"]
pkg_session_telemetry["session-telemetry"]
svc_telemetry["ctx.telemetry<br/>Session telemetry seam"]
pkg_session_telemetry_otel["session-telemetry-otel"]
@@ -174,6 +177,8 @@ flowchart LR
pkg_compact --> svc_compact
pkg_compact_basic --> svc_compact
pkg_compact_tool_result_prune --> svc_toolResultPrune
pkg_credentials --> svc_credentials
pkg_credentials_local --> svc_credentials
pkg_directory_picker --> svc_directoryPicker
pkg_directory_picker_browse --> svc_directoryPicker
pkg_directory_picker_native --> svc_directoryPicker
@@ -258,6 +263,8 @@ flowchart LR
svc_codeRuntime --> pkg_tools
svc_commands --> pkg_tui
svc_compact --> pkg_compact_basic
svc_credentials --> pkg_llm_deepseek
svc_credentials --> pkg_llm_pi_ai
svc_directoryPicker --> pkg_apiproxy
svc_fs --> pkg_tool_fs
svc_httpServer --> pkg_connection
@@ -296,6 +303,8 @@ flowchart LR
svc_sessions --> pkg_session_query
svc_sessions --> pkg_session_query_sqlite
svc_sessions --> pkg_subagent_inprocess
svc_settings --> pkg_llm_deepseek
svc_settings --> pkg_llm_pi_ai
svc_skills --> pkg_tool_skill
svc_spillStore --> pkg_spill_policy
svc_storage --> pkg_storage_domain
@@ -346,7 +355,8 @@ flowchart LR
| `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. |
| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader) | - | Plugins register live zod contributions directly or through dsh-typert-loader; runtime consumers query schemas and reflection metadata at their own edges. |
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | - | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. No production consumer is migrated yet. |
| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section. |
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request. |
| `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/telemetry/session-telemetry) | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. |
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. |
+42 -15
View File
@@ -407,6 +407,24 @@ export interface ToolResultPruneConfig {
Source: [`packages/compact/compact-tool-result-prune/src/types.ts:4`](../packages/compact/compact-tool-result-prune/src/types.ts)
## `@deepseek-ai/dsh-credentials-local`
```ts config-catalog
/** Plugin config: file location and hot-reload behavior. */
export interface Config {
/** Credentials document path; defaults to `.env` under the harness home. */
path?: string
/** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */
dshHome?: string
/** Watch the document and hot-publish external edits; defaults to true. */
watch?: boolean
/** Watcher write-settle window in milliseconds; defaults to 100. */
debounceMs?: number
}
```
Source: [`packages/credentials/credentials-local/src/index.ts:26`](../packages/credentials/credentials-local/src/index.ts)
## `@deepseek-ai/dsh-fs-local`
```ts config-catalog
@@ -602,15 +620,18 @@ Requires: `llm`
```ts config-catalog
/**
* Plugin config, validated by the same-named schemastery schema. Every field
* is optional in yml: credentials/endpoint fall back to the environment (a
* missing API key fails plugin load, not the first call), omitted thinking
* mode uses the provider default, and omitted reasoning effort resolves to
* `high`.
* Plugin config, validated by the same-named schemastery schema and doubling
* as the `llm-deepseek` settings-section shape. Every field is optional in
* yml: a missing API key resolves through {@link Config.apiKeyEnv} at each
* request (a request without any key fails with `MISSING_CREDENTIAL`, not at
* plugin load), omitted thinking mode uses the provider default, and omitted
* reasoning effort resolves to `high`.
*/
export interface Config {
/** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */
/** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
apiKey?: string
/** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
apiKeyEnv?: string
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
baseURL?: string
/** Deployment thinking policy; `disabled` limits every conversation request to `off`. */
@@ -642,25 +663,29 @@ export interface DeepSeekCatalogModel {
Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm-deepseek/src/index.ts:36`](../packages/llm/llm-deepseek/src/index.ts)
Source: [`packages/llm/llm-deepseek/src/index.ts:50`](../packages/llm/llm-deepseek/src/index.ts)
## `@deepseek-ai/dsh-llm-pi-ai`
Requires: `llm`
```ts config-catalog
/** Plugin configuration: the non-empty provider profiles this instance owns. */
/** Plugin configuration: the provider routes this instance owns. */
export interface Config {
/** Non-empty set of pi-ai provider routes this adapter instance owns. */
providers: PiAiProviderProfile[]
/**
* pi-ai provider routes, keyed by provider. An empty (or omitted) dict is
* the dormant settings-driven posture: the adapter mounts with no routes
* and registers them the moment a settings section supplies profiles.
*/
providers?: Record<string, PiAiProviderProfile>
}
/** Configuration for one pi-ai provider route. */
/** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */
export interface PiAiProviderProfile {
/** pi-ai provider catalog name and Harness route key. */
provider: string
/** Provider credential; when absent pi-ai uses its provider-native ambient discovery. */
/** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */
apiKey?: string
/** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */
apiKeyEnv?: string
/** Override the selected catalog model's endpoint without changing its protocol metadata. */
baseURL?: string
/** Provider request headers; Harness attribution wins reserved names. */
@@ -686,7 +711,7 @@ export interface PiAiProviderProfile {
Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`)
Source: [`packages/llm/llm-pi-ai/src/config.ts:54`](../packages/llm/llm-pi-ai/src/config.ts)
Source: [`packages/llm/llm-pi-ai/src/config.ts:62`](../packages/llm/llm-pi-ai/src/config.ts)
## `@deepseek-ai/dsh-llm-replay`
@@ -2298,6 +2323,7 @@ Abstract service classes — a deployment loads a concrete implementation packag
- `@deepseek-ai/dsh-bash` — abstract `BashExecutor` ([`packages/bash/bash/src/index.ts`](../packages/bash/bash/src/index.ts))
- `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts))
- `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts))
- `@deepseek-ai/dsh-credentials` — abstract `Credentials` ([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts))
- `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts))
- `@deepseek-ai/dsh-host-directory-picker` — abstract `DirectoryPicker` ([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts))
- `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts))
@@ -2317,6 +2343,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
- `@deepseek-ai/dsh-acp-snapshot` ([`packages/support/acp-snapshot/src/index.ts`](../packages/support/acp-snapshot/src/index.ts))
- `@deepseek-ai/dsh-agent-loop-testkit` ([`packages/support/agent-loop-testkit/src/index.ts`](../packages/support/agent-loop-testkit/src/index.ts))
- `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts))
- `@deepseek-ai/dsh-atomic-write` ([`packages/util/atomic-write/src/index.ts`](../packages/util/atomic-write/src/index.ts))
- `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts))
- `@deepseek-ai/dsh-client-test-runtime` ([`packages/client/test-runtime/src/index.ts`](../packages/client/test-runtime/src/index.ts))
- `@deepseek-ai/dsh-client-ui-primitives` ([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts))
+26
View File
@@ -439,6 +439,32 @@ A command was registered or unregistered. This is an unfiltered registry notific
Source: [`packages/ui/commands/src/index.ts:154`](../../packages/ui/commands/src/index.ts)
## `credentials/*`
### `credentials/updated` — emit
Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Listener failures are contained and logged — a sync throw and an async rejection alike — without changing the committed operation's outcome, except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions.
```ts cordis-catalog
/**
* Committed change to a provider-managed credential source: a `set`, an
* `unset`, or an external edit observed in storage. Ambient
* process-environment changes are not observable and never emit. Listener
* failures are contained and logged — a sync throw and an async rejection
* alike — without changing the committed operation's outcome, except
* `INVARIANT`-coded failures, which rethrow after every listener ran;
* that rethrow reaches the emitter only from synchronous listeners, so
* invariant checks on this event must not be async functions.
* @param ref - the reference whose stored value changed.
* @mode emit
*/
'credentials/updated'(ref: CredentialRef): void
```
Types: [CredentialRef](../core-data-structures/credentials.md)
Source: [`packages/credentials/credentials/src/index.ts:67`](../../packages/credentials/credentials/src/index.ts)
## `domain/*`
### `domain/changed` — emit
+50 -4
View File
@@ -488,6 +488,52 @@ Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionT
Source: [`packages/compact/compact/src/index.ts:54`](../../packages/compact/compact/src/index.ts)
## `ctx.credentials` — `Credentials` (abstract seam)
Abstract credential service. Providers implement the four operations over their source layers; one seam-wide rule binds them all: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret.
```ts cordis-catalog
/**
* Resolve one reference to its current value. Resolution is per call:
* consumers re-resolve at each operation and must not cache across
* operations — that per-operation read is what makes a changed credential
* reach the next operation without a restart.
* @param ref - the reference to resolve.
* @returns the value and its source, or `undefined` while unconfigured.
*/
abstract resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined>
/**
* Describe one reference for configuration surfaces without exposing the
* value.
* @param ref - the reference to describe.
* @returns configured state, supplying source, and writability.
*/
abstract describe(ref: CredentialRef): Promise<CredentialInfo>
/**
* Durably store one value in the provider-managed writable source. Rejects
* while a read-only source shadows the reference — the write would appear
* to succeed while resolution keeps returning the shadowing value — and
* rejects an empty value (use {@link unset}).
* @param ref - the reference to store.
* @param value - the non-empty secret value.
*/
abstract set(ref: CredentialRef, value: string): Promise<void>
/**
* Remove one reference from the provider-managed writable source; removing
* an absent reference is a no-op. Rejects while a read-only source shadows
* the reference, like {@link set}.
* @param ref - the reference to remove.
*/
abstract unset(ref: CredentialRef): Promise<void>
```
Types: [CredentialInfo](../core-data-structures/credentials.md) · [CredentialRef](../core-data-structures/credentials.md) · [ResolvedCredential](../core-data-structures/credentials.md)
Source: [`packages/credentials/credentials/src/index.ts:77`](../../packages/credentials/credentials/src/index.ts)
## `ctx.directoryPicker` — `DirectoryPicker` (abstract seam)
Abstract directory-picking service. Subclass, implement `capability()`, and load the subclass as a plugin — it registers as `ctx.directoryPicker` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). The capability object must be stable for the service lifetime: consumers may capture it across calls.
@@ -744,9 +790,9 @@ The abstract `llm` service: an adapter registry plus a streaming model-call surf
* Disposed with the fiber.
* @param providers - every provider route this adapter should serve.
* @param adapter - the adapter that streams calls for those providers.
* @returns the disposer that unregisters all of them.
* @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}.
*/
registerAdapter(providers: string[], adapter: LlmAdapter): () => void
registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle
/**
* Describe provider routes with a registered adapter.
@@ -818,9 +864,9 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<Prepared
stream(options: GenerateOptions): AsyncIterable<StreamChunk>
```
Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
Source: [`packages/llm/llm/src/index.ts:191`](../../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm/src/index.ts:215`](../../packages/llm/llm/src/index.ts)
## `ctx.permission` — `PermissionService`
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/core-data-structures/core.md
core.md: a12e96b4156b4ccd8f6f0c453224ed57d6040966
core.zh.md: ac10c801bde8898ebd3393b05a94cc63627e1b89
core.md: 09b437a8483134230d4b941c20940c5655bc53f0
core.zh.md: 2025707db397203dbaec52c59172f83367f2033e
+29
View File
@@ -25,6 +25,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
| [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events |
| [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` |
| [settings.md](settings.md) | the user-settings seam: `SettingsNamespace` registration, layered resolution (defaults → composition `base` → user document), owner scopes, hot commits |
| [credentials.md](credentials.md) | the credential seam: `CredentialRef` references (never values) in configuration, per-operation resolution, UI-safe `CredentialInfo`, provider source layers |
| [session-query.md](session-query.md) | logical records, bounded exact-event reads, relationship traces, semantic filters/documents, and full-text result pages |
| [session-title.md](session-title.md) | durable title snapshots, source provenance, and the asynchronous provider contract |
| [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly |
@@ -182,6 +183,34 @@ Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts)
Provider and model discovery uses small provider-neutral descriptors. A model catalog is advisory: routing still keys on a registered provider, and an adapter may accept unlisted model ids.
Registering an adapter returns a handle: the disposer, plus the atomic route replacement a plugin whose route set is user-configurable needs.
```ts type-equiv
/**
* What {@link LlmService.registerAdapter} returns: the disposer, plus an
* atomic route replacement for the same adapter instance.
*/
interface AdapterRegistrationHandle {
/** Release every route this registration currently holds. */
(): void
/**
* Replace this registration's routes with `providers`, keeping the same
* adapter instance. The candidate set is validated in full first — a
* conflict with another adapter, an invalid name, or bad provider metadata
* throws and leaves the current routes untouched — and the swap itself is
* one synchronous section, so no request can observe a gap. An empty array
* is legal here (a settings section that emptied holds zero routes while
* staying registered), unlike an empty initial registration.
*
* Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration
* has been released: its routes are gone and its disposer has already run,
* so anything registered afterwards would have no owner left to release it.
* @param providers - the complete next route set for this registration.
*/
replace(providers: string[]): void
}
```
```ts type-equiv
/** Display metadata for one registered provider route. */
interface LlmProviderInfo {
+29
View File
@@ -25,6 +25,7 @@ harness 是一个微内核:一个极小的核心加上众多插件。大多数
| [session.md](session.md) | 完整的 `SessionEventMap` 变体目录、`TurnTrigger`/`TurnEndReason``deriveMessages()`、执行封闭与独立事件 |
| [persistence.md](persistence.md) | 持久性 seam`SessionPersistence`、JSONL + SQLite 后端、`session/flush`、崩溃恢复、`SessionHeader` |
| [settings.md](settings.md) | 用户设置 seam`SettingsNamespace` 注册、分层解析(默认值 → 组合 `base` → 用户文档)、owner scope、热提交 |
| [credentials.md](credentials.md) | 凭据 seam:配置中的 `CredentialRef` 引用(绝不含值)、按操作解析、对 UI 安全的 `CredentialInfo`、provider 来源层 |
| [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 |
| [session-title.md](session-title.md) | 持久标题快照、来源 provenance 与异步提供方契约 |
| [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 |
@@ -188,6 +189,34 @@ interface MessageSourceMap {
提供方与模型发现使用小型、提供方无关的描述符。模型目录仅供参考:路由仍以已注册提供方为键,适配器也可以接受未列出的模型 id。
注册适配器会返回一个句柄:既是释放器,也带有原子的路由替换——路由集合由用户配置决定的插件正需要它。
```ts type-equiv
/**
* What {@link LlmService.registerAdapter} returns: the disposer, plus an
* atomic route replacement for the same adapter instance.
*/
interface AdapterRegistrationHandle {
/** Release every route this registration currently holds. */
(): void
/**
* Replace this registration's routes with `providers`, keeping the same
* adapter instance. The candidate set is validated in full first — a
* conflict with another adapter, an invalid name, or bad provider metadata
* throws and leaves the current routes untouched — and the swap itself is
* one synchronous section, so no request can observe a gap. An empty array
* is legal here (a settings section that emptied holds zero routes while
* staying registered), unlike an empty initial registration.
*
* Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration
* has been released: its routes are gone and its disposer has already run,
* so anything registered afterwards would have no owner left to release it.
* @param providers - the complete next route set for this registration.
*/
replace(providers: string[]): void
}
```
```ts type-equiv
/** Display metadata for one registered provider route. */
interface LlmProviderInfo {
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/core-data-structures/credentials.md
credentials.md: 3f6fcd127d01e2c49e17c70c002bebe9f363e951
credentials.zh.md: b5d2d9e164a85ce090790635c438b768cae4c9ca
+50
View File
@@ -0,0 +1,50 @@
# User Credentials
English | [中文](credentials.zh.md)
The credential seam of [dsh-credentials](../../packages/credentials/credentials) keeps secrets out of configuration: settings sections and `cordis.yml` entries carry *references* (environment-variable names), providers such as [dsh-credentials-local](../../packages/credentials/credentials-local) own the values, and consumers resolve a reference once per operation — the LLM adapters resolve once per model request, so a rotated credential reaches the very next request without any restart. One seam-wide rule binds every provider: an empty stored value is absent everywhere.
Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credentials/credentials/src/index.ts)
## Identity
A reference names one credential as a POSIX-style environment-variable name. The brand keeps references from mixing with other cross-boundary strings; construction validates the shell-identifier shape.
```ts type-equiv
/** Nominal reference to one credential: a POSIX-style environment-variable name. */
type CredentialRef = Branded<'CredentialRef'>
```
## Resolution
`resolve(ref)` returns the value with the provider-defined source layer that supplied it, or `undefined` while unconfigured. Consumers re-resolve at each operation and never cache across operations — that per-operation read is the hot-update mechanism.
```ts type-equiv
/** One resolved credential value and the source layer that supplied it. */
interface ResolvedCredential {
/** The non-empty secret value. */
value: string
/** Provider-defined source layer id (the local provider uses `env` and `file`). */
source: string
}
```
## Description
`describe(ref)` answers configuration surfaces without ever exposing a value: whether the reference resolves, from which layer, and whether `set` would currently succeed. The local provider reports a reference supplied by the live process environment as `writable: false` — a write would appear to succeed while resolution kept returning the shadowing value, so the seam rejects it and the UI can render the reference read-only up front.
```ts type-equiv
/** Source and writability facts for one reference, safe for configuration UIs — never the value. */
interface CredentialInfo {
/** Whether {@link Credentials.resolve} would currently return a value. */
configured: boolean
/** Source layer currently supplying the value; absent while unconfigured. */
source?: string
/** Whether {@link Credentials.set} would currently succeed for this reference. */
writable: boolean
}
```
## Change commits
`credentials/updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration surfaces refreshing a "configured" badge.
@@ -0,0 +1,50 @@
# 用户凭据
[English](credentials.md) | 中文
[dsh-credentials](../../packages/credentials/credentials) 的凭据 seam 把机密挡在配置之外:settings 分节与 `cordis.yml` 条目携带的是*引用*(环境变量名),值归 [dsh-credentials-local](../../packages/credentials/credentials-local) 这类 provider 所有,消费方每个操作解析一次引用——LLM 适配器每次模型请求解析一次,因此轮换后的凭据无需任何重启即可作用于紧随其后的下一次请求。一条 seam 级规则约束每个 provider:空的存储值在任何地方都视为不存在。
Source: [`packages/credentials/credentials/src/index.ts`](../../packages/credentials/credentials/src/index.ts)
## 标识
引用以 POSIX 风格环境变量名命名一条凭据。brand 使引用不与其他跨边界字符串混用;构造时校验 shell 标识符形态。
```ts type-equiv
/** Nominal reference to one credential: a POSIX-style environment-variable name. */
type CredentialRef = Branded<'CredentialRef'>
```
## 解析
`resolve(ref)` 返回值,连同供出该值、由 provider 定义的来源层;未配置期间返回 `undefined`。消费方在每个操作中重新解析,绝不跨操作缓存——这次按操作进行的读取正是热更新机制。
```ts type-equiv
/** One resolved credential value and the source layer that supplied it. */
interface ResolvedCredential {
/** The non-empty secret value. */
value: string
/** Provider-defined source layer id (the local provider uses `env` and `file`). */
source: string
}
```
## 描述
`describe(ref)` 在绝不暴露值的前提下回应配置界面:引用当前是否可解析、来自哪一层、`set` 当前能否成功。本地 provider 把由活跃进程环境供值的引用报告为 `writable: false`——那样的写入会表面成功而解析持续返回遮蔽值,因此 seam 直接拒绝,界面也得以提前把该引用渲染为只读。
```ts type-equiv
/** Source and writability facts for one reference, safe for configuration UIs — never the value. */
interface CredentialInfo {
/** Whether {@link Credentials.resolve} would currently return a value. */
configured: boolean
/** Source layer currently supplying the value; absent while unconfigured. */
source?: string
/** Whether {@link Credentials.set} would currently succeed for this reference. */
writable: boolean
}
```
## 变更提交
`credentials/updated (ref)` 在 provider 管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境自身的变化不可观测,永不发出事件。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。
+1
View File
@@ -26,6 +26,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:406`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` |
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) |
| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials) |
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) |
+23 -3
View File
@@ -8,6 +8,7 @@ Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, deri
```mermaid
flowchart TD
subgraph group_util["packages/util"]
pkg_atomic_write["atomic-write"]
pkg_brand["brand"]
pkg_native_command["native-command"]
pkg_paths["paths"]
@@ -177,6 +178,10 @@ flowchart TD
pkg_tmux_context["tmux-context"]
pkg_workspace_context["workspace-context"]
end
subgraph group_credentials["packages/credentials"]
pkg_credentials["credentials"]
pkg_credentials_local["credentials-local"]
end
subgraph group_examples["packages/examples"]
pkg_acp_demo["acp-demo"]
pkg_agent_spine_demo["agent-spine-demo"]
@@ -261,6 +266,7 @@ flowchart TD
subgraph group_workspace["packages/workspace"]
pkg_workspace["workspace"]
end
pkg_atomic_write --> pkg_invariants
pkg_brand --> pkg_invariants
pkg_native_command --> pkg_invariants
pkg_paths --> pkg_invariants
@@ -315,6 +321,8 @@ flowchart TD
pkg_client_ui_workspace --> pkg_client_ui_primitives
pkg_client_ui_workspace --> pkg_client_ui_slots
pkg_client_ui_workspace --> pkg_invariants
pkg_credentials --> pkg_brand
pkg_credentials --> pkg_invariants
pkg_helper --> pkg_brand
pkg_helper --> pkg_invariants
pkg_helper --> pkg_subprocess
@@ -333,11 +341,15 @@ flowchart TD
pkg_subprocess_local --> pkg_subprocess
pkg_typert_loader --> pkg_invariants
pkg_typert_loader --> pkg_typert_registry
pkg_llm_deepseek --> pkg_credentials
pkg_llm_deepseek --> pkg_invariants
pkg_llm_deepseek --> pkg_llm
pkg_llm_deepseek --> pkg_settings
pkg_llm_deepseek --> pkg_timeout
pkg_llm_pi_ai --> pkg_credentials
pkg_llm_pi_ai --> pkg_invariants
pkg_llm_pi_ai --> pkg_llm
pkg_llm_pi_ai --> pkg_settings
pkg_llm_pi_ai --> pkg_timeout
pkg_session --> pkg_brand
pkg_session --> pkg_invariants
@@ -371,6 +383,10 @@ flowchart TD
pkg_client_ui_theme --> pkg_client_ui_primitives
pkg_client_ui_theme --> pkg_client_ui_slots
pkg_client_ui_theme --> pkg_invariants
pkg_credentials_local --> pkg_atomic_write
pkg_credentials_local --> pkg_credentials
pkg_credentials_local --> pkg_invariants
pkg_credentials_local --> pkg_paths
pkg_host_directory_picker_browse --> pkg_client_locale
pkg_host_directory_picker_browse --> pkg_client_runtime
pkg_host_directory_picker_browse --> pkg_client_ui_primitives
@@ -386,6 +402,7 @@ flowchart TD
pkg_lsp --> pkg_llm
pkg_sandbox --> pkg_invariants
pkg_sandbox --> pkg_llm
pkg_settings_local --> pkg_atomic_write
pkg_settings_local --> pkg_invariants
pkg_settings_local --> pkg_paths
pkg_settings_local --> pkg_settings
@@ -999,6 +1016,7 @@ flowchart TD
| Package | Group | Depends on |
| --- | --- | --- |
| [`invariants`](../packages/support/invariants) | `support` | — |
| [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/support/invariants) |
| [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/support/invariants) |
| [`native-command`](../packages/util/native-command) | `util` | [`invariants`](../packages/support/invariants) |
| [`paths`](../packages/util/paths) | `util` | [`invariants`](../packages/support/invariants) |
@@ -1033,6 +1051,7 @@ flowchart TD
| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
| [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
@@ -1041,8 +1060,8 @@ flowchart TD
| [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
| [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) |
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) |
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) |
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
@@ -1051,11 +1070,12 @@ flowchart TD
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`settings-local`](../packages/settings/settings-local) | `settings` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) |
| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) |
| [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`agent`](../packages/core/agent) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) |
+6
View File
@@ -8,6 +8,10 @@ The headless demo combines the real DeepSeek adapter and coding capabilities wit
```mermaid
flowchart LR
cfg["examples/headless-agent<br/>cordis.yml"]
plugin_headless_settings["settings<br/>@deepseek-ai/dsh-settings-local"]
cfg --> plugin_headless_settings
plugin_headless_credentials["credentials<br/>@deepseek-ai/dsh-credentials-local"]
cfg --> plugin_headless_credentials
plugin_headless_llm_deepseek["llm-deepseek<br/>@deepseek-ai/dsh-llm-deepseek"]
cfg --> plugin_headless_llm_deepseek
plugin_headless_subprocess["subprocess<br/>@deepseek-ai/dsh-subprocess-local"]
@@ -55,6 +59,8 @@ flowchart LR
| Plugin id | Package / module |
| --- | --- |
| `settings` | `@deepseek-ai/dsh-settings-local` |
| `credentials` | `@deepseek-ai/dsh-credentials-local` |
| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` |
| `subprocess` | `@deepseek-ai/dsh-subprocess-local` |
| `bash` | `@deepseek-ai/dsh-bash-local` |
+18 -7
View File
@@ -1,16 +1,27 @@
# One-shot coding agent with format-pure stdout. The app bin loads the
# gitignored root `.env`; this file reads `DEEPSEEK_API_KEY` and optional
# `DEEPSEEK_BASE_URL` through `!!js`.
# gitignored root `.env` into the process environment; entry configs here are
# the composition base, while user-plane values resolve per request through
# the two providers below.
# User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a
# `llm-deepseek:` section there overrides the adapter entry below without a
# restart.
- id: settings
name: '@deepseek-ai/dsh-settings-local'
# Credential store: the live process environment over `$DSH_HOME/.env`
# (owner-only file, hot-reloaded). The adapter resolves `DEEPSEEK_API_KEY`
# through it at each request, so no key is inlined in this file.
- id: credentials
name: '@deepseek-ai/dsh-credentials-local'
# The DeepSeek adapter. Swap to '@deepseek-ai/dsh-llm-pi-ai' for the pi-ai-backed
# twin (same config shape; `reasoning: high` replaces thinking/reasoningEffort).
# Shipped default: full thinking at max effort on every request (wire-only
# defaults; they never enter the request header).
# twin (a `providers` dict keyed by route; `reasoning: high` replaces
# thinking/reasoningEffort). Shipped default: full thinking at max effort on
# every request (wire-only defaults; they never enter the request header).
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
thinking: enabled
reasoningEffort: max
models:
@@ -0,0 +1,18 @@
# Keyless dynamic-configuration composition: the base settings and credentials
# providers see only the isolated run home, no API key exists anywhere, and
# the deepseek route still registers — so the prompt fails with the actionable
# MISSING_CREDENTIAL guidance this snapshot pins as first-run UX.
- id: base
name: '@cordisjs/plugin-include'
config:
path: ./cordis.yml
patches:
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
disabled: true
- insert:
# The endpoint is never dialed: credential resolution fails first.
- id: llm-deepseek-keyless
name: '@deepseek-ai/dsh-llm-deepseek'
config:
baseURL: 'http://127.0.0.1:9'
@@ -27,6 +27,8 @@ const goalScenarioDir = join(snapshotsDir, 'goal-tools')
const goalConfigPath = fileURLToPath(new URL('../goal.cordis.snapshot.yml', import.meta.url))
const retryScenarioDir = join(snapshotsDir, 'provider-retry')
const retryConfigPath = fileURLToPath(new URL('../retry.cordis.snapshot.yml', import.meta.url))
const credentialsScenarioDir = join(snapshotsDir, 'missing-credential')
const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snapshot.yml', import.meta.url))
const ralphScenarioDir = join(snapshotsDir, 'ralph-loop')
const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url))
const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url))
@@ -168,6 +170,40 @@ describe('headless stream-json snapshots', () => {
expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('surfaces actionable missing-credential guidance through the one-shot app', async () => {
const streamExpected = join(credentialsScenarioDir, 'stream-json.expected.jsonl')
let runCwd = ''
const result = await runLoaderSmoke({
label: 'missing-credential headless stream-json snapshot',
tempDirPrefix: 'headless-snapshot-missing-credential-',
binScript,
configPath: credentialsConfigPath,
binArgs: ['--config', credentialsConfigPath, '--output-format', 'stream-json', 'say pong'],
tsconfigPath,
env: {
// First-run posture: no key in the environment, none under ./.dsh.
DEEPSEEK_API_KEY: '',
DEEPSEEK_BASE_URL: '',
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
},
// The designed failure surface: the one-shot app reports the failed turn.
expectedExitCode: 1,
prepare: (cwd) => { runCwd = cwd },
})
// The guidance leads with the credential store — the path that keeps the
// secret out of configuration files — and offers a literal key last.
expect(result.stderr).toBe(
'dsh-cli-demo: turn 1 failed at step 1: llm-deepseek: no API key for provider route "deepseek";'
+ ' store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it),'
+ ' export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal'
+ ' "apiKey" in the llm-deepseek settings section\n',
)
const normalized = normalizeHeadlessStream(result.stdout, runCwd)
if (refreshing) await writeFile(streamExpected, normalized)
expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
it('logs the model default and a dynamic next-step reasoning effort', async () => {
const result = await runLoaderSmoke({
label: 'reasoning effort headless stream-json snapshot',
@@ -0,0 +1,8 @@
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"say pong","messageSeqs":[1],"source":{"kind":"fallback"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":5,"time":0,"data":{"turn":1,"step":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":6,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}}}}
{"type":"result","success":false,"sessionId":"{{sessionId}}","turn":1,"result":"","reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}}
+2
View File
@@ -22,6 +22,7 @@
"@deepseek-ai/dsh-commands": "workspace:*",
"@deepseek-ai/dsh-compact-basic": "workspace:*",
"@deepseek-ai/dsh-compact-tool-result-prune": "workspace:*",
"@deepseek-ai/dsh-credentials-local": "workspace:*",
"@deepseek-ai/dsh-fs-local": "workspace:*",
"@deepseek-ai/dsh-fs-policy": "workspace:*",
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
@@ -54,6 +55,7 @@
"@deepseek-ai/dsh-session-telemetry-otel": "workspace:*",
"@deepseek-ai/dsh-session-title": "workspace:*",
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*",
"@deepseek-ai/dsh-settings-local": "workspace:*",
"@deepseek-ai/dsh-skill": "workspace:*",
"@deepseek-ai/dsh-skill-local": "workspace:*",
"@deepseek-ai/dsh-spill-local": "workspace:*",
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/README.md
README.md: 11179cf6676d1b4382816e34285529b51152fe8d
README.zh.md: 100d918287613973604b2f85060572b8ee41d132
README.md: 0c729f781151fcc0bda81899e51227e71c7b8d2b
README.zh.md: 660a24eeea5f1a36841654626d94412371a2f462
+1
View File
@@ -40,6 +40,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service and opt-in LLM providers | Product — stable surface |
| [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable surface |
| [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable surface |
| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface |
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |
+1
View File
@@ -40,6 +40,7 @@
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 |
| [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务与选用 LLM 提供方 | 产品:稳定表面 |
| [`settings/`](settings/README.md) | 用户设置 seam + 文件 provider | 产品:稳定表面 |
| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境叠加 `.env` provider | 产品:稳定表面 |
| [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 |
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 |
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 |
+47 -2
View File
@@ -264,6 +264,28 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'credentials',
summary: 'Abstract credential service.',
methods: [
{
signature: 'abstract resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined>',
jsDoc: '/**\n * Resolve one reference to its current value. Resolution is per call:\n * consumers re-resolve at each operation and must not cache across\n * operations — that per-operation read is what makes a changed credential\n * reach the next operation without a restart.\n * @param ref - the reference to resolve.\n * @returns the value and its source, or `undefined` while unconfigured.\n */',
},
{
signature: 'abstract describe(ref: CredentialRef): Promise<CredentialInfo>',
jsDoc: '/**\n * Describe one reference for configuration surfaces without exposing the\n * value.\n * @param ref - the reference to describe.\n * @returns configured state, supplying source, and writability.\n */',
},
{
signature: 'abstract set(ref: CredentialRef, value: string): Promise<void>',
jsDoc: '/**\n * Durably store one value in the provider-managed writable source. Rejects\n * while a read-only source shadows the reference — the write would appear\n * to succeed while resolution keeps returning the shadowing value — and\n * rejects an empty value (use {@link unset}).\n * @param ref - the reference to store.\n * @param value - the non-empty secret value.\n */',
},
{
signature: 'abstract unset(ref: CredentialRef): Promise<void>',
jsDoc: '/**\n * Remove one reference from the provider-managed writable source; removing\n * an absent reference is a no-op. Rejects while a read-only source shadows\n * the reference, like {@link set}.\n * @param ref - the reference to remove.\n */',
},
],
},
{
key: 'directoryPicker',
summary: 'Abstract directory-picking service.',
@@ -383,8 +405,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
summary: 'The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.',
methods: [
{
signature: 'registerAdapter(providers: string[], adapter: LlmAdapter): () => void',
jsDoc: '/**\n * Register an adapter for the given provider routes. Throws `LlmError` with code\n * `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing).\n * Disposed with the fiber.\n * @param providers - every provider route this adapter should serve.\n * @param adapter - the adapter that streams calls for those providers.\n * @returns the disposer that unregisters all of them.\n */',
signature: 'registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle',
jsDoc: '/**\n * Register an adapter for the given provider routes. Throws `LlmError` with code\n * `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing).\n * Disposed with the fiber.\n * @param providers - every provider route this adapter should serve.\n * @param adapter - the adapter that streams calls for those providers.\n * @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}.\n */',
},
{
signature: 'listProviders(): LlmProviderInfo[]',
@@ -1271,6 +1293,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
jsDoc: '/**\n * A command was registered or unregistered. This is an unfiltered registry\n * notification because a global or scoped change may affect any UI view.\n * Observer failures are contained and cannot veto the registry mutation.\n * @mode emit\n */',
summary: 'A command was registered or unregistered.',
},
{
name: 'credentials/updated',
mode: 'emit',
signature: '\'credentials/updated\'(ref: CredentialRef): void',
jsDoc: '/**\n * Committed change to a provider-managed credential source: a `set`, an\n * `unset`, or an external edit observed in storage. Ambient\n * process-environment changes are not observable and never emit. Listener\n * failures are contained and logged — a sync throw and an async rejection\n * alike — without changing the committed operation\'s outcome, except\n * `INVARIANT`-coded failures, which rethrow after every listener ran;\n * that rethrow reaches the emitter only from synchronous listeners, so\n * invariant checks on this event must not be async functions.\n * @param ref - the reference whose stored value changed.\n * @mode emit\n */',
summary: 'Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage.',
},
{
name: 'domain/changed',
mode: 'emit',
@@ -1492,6 +1521,10 @@ export const EVENT_API: readonly EventApiEntry[] = [
/** Shapes of every exported type the SERVICE_API signatures reference (transitively), sorted by name. */
export const TYPE_API: readonly TypeApiEntry[] = [
{
name: 'AdapterRegistrationHandle',
declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}',
},
{
name: 'Agent',
declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}',
@@ -1720,6 +1753,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'CreateSessionOptions',
declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n}',
},
{
name: 'CredentialInfo',
declaration: 'export interface CredentialInfo {\n configured: boolean;\n source?: string;\n writable: boolean;\n}',
},
{
name: 'CredentialRef',
declaration: 'export type CredentialRef = Branded<\'CredentialRef\'>;',
},
{
name: 'DiffCallView',
declaration: 'export interface DiffCallView {\n card: \'diff\';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n}',
@@ -2148,6 +2189,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ResolvedAlwaysRetryPolicy',
declaration: 'export interface ResolvedAlwaysRetryPolicy extends ResolvedRetryBackoff {\n readonly mode: \'always\';\n}',
},
{
name: 'ResolvedCredential',
declaration: 'export interface ResolvedCredential {\n value: string;\n source: string;\n}',
},
{
name: 'ResolvedNormalRetryPolicy',
declaration: 'export interface ResolvedNormalRetryPolicy extends ResolvedRetryBackoff {\n readonly mode: \'normal\';\n readonly maxRetries: number;\n readonly retryableCodes: readonly string[];\n}',
+6
View File
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/credentials/README.md
README.md: 1d450cbeef84750fa57ca0151563c496aed0ce12
README.zh.md: 843230c3cebf35f234d3ad812165b16ea734678b
+14
View File
@@ -0,0 +1,14 @@
# credentials/
English | [中文](README.zh.md)
The credential capability seam, as three-package shape dictates (interface / implementation / consumers):
| Package | Role |
|---|---|
| [`credentials/`](credentials/README.md) | Abstract `ctx.credentials`: branded `CredentialRef` references, per-operation `resolve`, UI-safe `describe`, fail-loud `set`/`unset`, the `credentials/updated` commit event |
| [`credentials-local/`](credentials-local/README.md) | File/environment provider: the live process environment (read-only, wins) layered over `$DSH_HOME/.env` (writable, byte-preserving line edits, hot-reloaded) |
Configuration files carry *references* to secrets (`apiKeyEnv: DEEPSEEK_API_KEY`), never the secrets: the settings document stays safe to sync and render, and rotating a value touches no configuration. The LLM adapters are the first consumers — they resolve their reference once per model request, which is what makes a key stored moments ago reach the very next request without restarting anything.
The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers.
+14
View File
@@ -0,0 +1,14 @@
# credentials/
[English](README.md) | 中文
凭据能力 seam,按三包形态的要求组织(接口/实现/消费方):
| 包 | 角色 |
|---|---|
| [`credentials/`](credentials/README.md) | 抽象 `ctx.credentials`:品牌化 `CredentialRef` 引用、按操作 `resolve`、对 UI 安全的 `describe`、响亮失败的 `set`/`unset`,以及 `credentials/updated` 提交事件 |
| [`credentials-local/`](credentials-local/README.md) | 文件/环境 provider:活跃进程环境(只读、优先)叠加在 `$DSH_HOME/.env`(可写、保字节行级编辑、热重载)之上 |
配置文件携带的是对机密的*引用*`apiKeyEnv: DEEPSEEK_API_KEY`),绝不携带机密本身:设置文档可以放心同步与渲染,轮换值不触碰任何配置。LLM 适配器是第一批消费方——它们每次模型请求解析一次引用,正因如此,片刻前存入的密钥无需重启任何组件即可作用于紧随其后的下一次请求。
seam 形状为 keyring、辅助命令与 KMS 后端的 provider 留有余地。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/credentials/credentials-local/README.md
README.md: 126140b10719dc6f7bc458a118ba1feb1f440270
README.zh.md: c22575115ab44b5e86a847ffe8f1fa1a795b580d
@@ -0,0 +1,54 @@
# dsh-credentials-local
English | [中文](README.zh.md)
File-backed [credentials](../credentials/README.md) provider: two layers, one honest precedence.
| Layer | Source id | Writable | Wins |
|---|---|---|---|
| Live process environment | `env` | no | always |
| `$DSH_HOME/.env` document | `file` | yes (`set`/`unset`) | otherwise |
The environment wins because a launch-time override (`DEEPSEEK_API_KEY=… dsh`, CI secrets, a dev shell sourcing the repo `.env`) is operator intent for this run — and because it cannot be edited from inside, it must be *visibly* read-only: `describe()` reports `source: 'env', writable: false`, and `set`/`unset` reject instead of writing a change the reader would never see. Resolution reads `process.env` live and never writes it back.
## Config
| Field | Default | Meaning |
|---|---|---|
| `path` | `<harness home>/.env` | Credentials document location. |
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home used when `path` is omitted. |
| `watch` | `true` | Hot-publish external edits. |
| `debounceMs` | `100` | Watcher write-settle window. |
## The document
dotenv format, parsed with `dotenv` and edited by a physical-line editor that preserves every byte it does not own: `set` rewrites the first assignment of its key in place with that line's own ending (dropping later duplicates, which dotenv's last-wins reading would otherwise let override the edit), `unset` removes only the owning line, and comments, unrelated lines, CRLF endings, and the continuation lines of another key's quoted multi-line value all survive verbatim. Every write first re-reads the document under the cross-process writer lock of [`dsh-atomic-write`](../../util/atomic-write/README.md) and publishes anything it had not observed, then commits atomically with mode `0600` under an owner-only (`0700`) directory — so a concurrent writer or an external edit inside the watcher's debounce window is folded in rather than overwritten.
Values are rendered in the narrowest style dotenv reads back verbatim — bare, then single-quoted (fully literal), then double-quoted (only without backslashes, which double-quote reading expands). A value no style can represent, and any entry that already spans multiple physical lines, fails loud instead of being corrupted silently. An empty stored value is absent, per the seam rule.
## Hot reload
External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable file at boot fails loud. Keys that are not POSIX identifiers are preserved file content the seam cannot address.
## Security boundary
The document is `0600` under a `0700` directory, which stops other OS users — **not** the model. Tool processes (bash, the filesystem tools) run as the same user, so under the shipped `danger-full-access` default they can read this file exactly like any other file the user owns, and no sandbox mode singles it out. What the harness does hold to is narrower: it never hands the model a resolved path to the document, and never loads it into the process environment (see [app-boot's Personal config](../../ui/app-boot/README.md#personal-config)), so reaching the value takes a deliberate read of a path the agent was not given.
That is discretion, not a boundary. A deployment that must keep provider keys away from its own agent cannot get there with file permissions; an OS-keychain provider — a store the model's processes cannot read at all — is the deferred answer and belongs beside this provider as a sibling package.
## Model Experience
Indirectly, through the consuming LLM adapters: stored values authorize their provider requests, and the adapter owns every model-visible surface.
#### KV Cache effect
No direct invalidation; credentials never enter a request prefix.
## Known Limitations and Deferred Work
- **Multi-line entries refuse `set`/`unset`** — the line editor will not rewrite an entry it would corrupt; `describe` reports them `writable: false` and edits must go to the file directly.
- **Same-reference concurrent writes are last-write-wins** — the writer lock and the read-modify-write keep concurrent writers from dropping each other's entries, but two writers editing one reference still resolve to the later write; there is no revision check.
- **A same-UID process can read the document** — see [Security boundary](#security-boundary): only a confining sandbox mode denies it, and an OS-keychain provider is deferred.
- **Unrepresentable values fail loud** — control characters, or a mix of both quote styles with backslashes, cannot round-trip the dotenv line format.
- **Environment changes are invisible** — `process.env` is read live per resolution, but no event can announce a change there.
- **Atomic, not crash-durable** — inherited from `dsh-atomic-write`; the store re-reads on boot.
@@ -0,0 +1,54 @@
# dsh-credentials-local
[English](README.md) | 中文
文件型[凭据](../credentials/README.md) provider:两层来源,一条诚实的优先级。
| 层 | 来源 id | 可写 | 优先 |
|---|---|---|---|
| 活跃进程环境 | `env` | 否 | 恒定优先 |
| `$DSH_HOME/.env` 文档 | `file` | 是(`set`/`unset` | 其余情况 |
环境优先,因为启动时覆盖(`DEEPSEEK_API_KEY=… dsh`、CI 机密、加载了仓库 `.env` 的开发 shell)代表本次运行的操作者意图——而它无法从进程内部修改,就必须*可见地*只读:`describe()` 报告 `source: 'env', writable: false``set`/`unset` 直接拒绝,而不是写下一个读取方永远看不到的变更。解析实时读取 `process.env`,绝不写回。
## 配置
| 字段 | 默认值 | 含义 |
|---|---|---|
| `path` | `<harness home>/.env` | 凭据文档位置。 |
| `dshHome` | `$DSH_HOME``~/.dsh` | `path` 缺省时使用的 harness home。 |
| `watch` | `true` | 热发布外部编辑。 |
| `debounceMs` | `100` | watcher 写入稳定窗口。 |
## 文档本身
dotenv 格式,用 `dotenv` 解析;写回用物理行级编辑器,保留一切不属于本次编辑的字节:`set` 原位改写该键的第一条赋值行、沿用该行自身的行尾(丢弃后续重复行——dotenv 按最后一条生效,重复行会反过来覆盖这次编辑),`unset` 只删除所属行,注释、无关行、CRLF 行尾,以及另一个键的引号多行值的续行,都逐字保留。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。
值按 dotenv 能逐字读回的最窄样式渲染——裸值,其次单引号(完全字面),再次双引号(仅限无反斜杠,双引号读取会展开转义)。任何样式都无法表示的值,以及已经跨越多个物理行的条目,都会响亮失败而不是被静默破坏。空的存储值等于不存在(seam 规则)。
## 热重载
外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。provider 自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读时保留最后可用快照并告警;文件不存在即空存储;启动时不可读则响亮失败。非 POSIX 标识符的键属于被保留的文件内容,seam 无法寻址。
## 安全边界
文档在 `0700` 目录下以 `0600` 权限存放,这挡得住其他 OS 用户,**挡不住**模型。工具进程(bash、文件系统工具)以同一用户身份运行,因此在出厂默认的 `danger-full-access` 下,它们读这个文件与读该用户拥有的任何其他文件毫无二致,也没有任何沙箱模式会把它单独挑出来。harness 真正守住的更窄:它绝不把该文档的解析后路径交给模型,也绝不把它载入进程环境(见 [app-boot 的个人配置](../../ui/app-boot/README.md#personal-config)),因此要拿到这个值,需要刻意去读一条并未交给 agent 的路径。
这是审慎,不是边界。必须让提供方密钥远离自身 agent 的部署无法靠文件权限做到;OS 钥匙串 provider——一个模型的进程根本读不到的存储——才是延后的答案,它应当作为平级包与本 provider 并列。
## Model Experience
经由消费它的 LLM 适配器间接生效:存储的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。
#### KV Cache effect
无直接失效;凭据绝不进入请求前缀。
## Known Limitations and Deferred Work
- **多行条目拒绝 `set`/`unset`**——行编辑器不改写会被它破坏的条目;`describe` 把它们报为 `writable: false`,编辑必须直接落到文件上。
- **同一引用的并发写入是后写胜出**——写锁加读-改-写让并发写入者不会丢掉彼此的条目,但两个写入者编辑同一个引用时仍以较后的写入为准;没有修订检查。
- **同 UID 进程可以读取该文档**——见[安全边界](#security-boundary):只有受限沙箱模式会拒绝它,OS 钥匙串 provider 仍是延后项。
- **无法表示的值响亮失败**——控制字符,或同时混用两种引号又含反斜杠的值,无法在 dotenv 行格式中往返。
- **环境变化不可见**——每次解析实时读取 `process.env`,但那里的变化不可能发出事件。
- **原子但不保证崩溃持久**——继承自 `dsh-atomic-write`;存储在启动时重新读取。
@@ -0,0 +1,48 @@
{
"name": "@deepseek-ai/dsh-credentials-local",
"description": "File-backed credentials provider ($DSH_HOME/.env under the live process environment) for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-atomic-write": "^0.0.1",
"@deepseek-ai/dsh-credentials": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"chokidar": "^4.0.3",
"dotenv": "^17.2.0",
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-atomic-write": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
@@ -0,0 +1,464 @@
/**
* File-backed credentials provider layering the live process environment over
* a `$DSH_HOME/.env` document. The environment is authoritative and read-only
* (a launch-time override must win, and must be visibly read-only rather than
* silently shadow writes); the file is the provider-managed writable source:
* every write re-reads the document under a cross-process writer lock before
* rewriting only its own line — preserving every other byte, physical line
* endings and quoted multi-line values included — external edits hot-publish
* through the seam, and each reload replaces the snapshot wholesale so a
* deleted entry never lingers in memory.
* @module @deepseek-ai/dsh-credentials-local
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { watch as chokidarWatch } from 'chokidar'
import { mkdir, readFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { parse } from 'dotenv'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { Credentials, credentialRef } from '@deepseek-ai/dsh-credentials'
import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials'
/** Plugin config: file location and hot-reload behavior. */
export interface Config {
/** Credentials document path; defaults to `.env` under the harness home. */
path?: string
/** Harness home used when `path` is omitted; defaults to `$DSH_HOME` or `~/.dsh`. */
dshHome?: string
/** Watch the document and hot-publish external edits; defaults to true. */
watch?: boolean
/** Watcher write-settle window in milliseconds; defaults to 100. */
debounceMs?: number
}
/** Fully resolved provider parameters; defaulting happens here, never inline. */
interface ResolvedSpec {
filename: string
watch: boolean
debounceMs: number
}
/**
* Resolve the runtime spec from plugin config: an explicit `path` wins,
* otherwise the document lives at `<harness home>/.env`.
* @param config - raw plugin config.
* @returns the resolved file location and watch behavior.
*/
export function resolveSpec(config: Config): ResolvedSpec {
return {
filename: resolve(config.path ?? join(resolveDshHome(config.dshHome), '.env')),
watch: config.watch ?? true,
debounceMs: config.debounceMs ?? 100,
}
}
/** Whether a filesystem error means absence; every non-ENOENT failure must surface. */
function isENOENT(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
}
/** Values that survive a dotenv round-trip without quoting. */
const BARE_VALUE = /^[A-Za-z0-9_@%+:,./-]+$/
/** Whether a value contains C0 control characters (newlines included) no dotenv style reads back. */
function hasControlCharacters(value: string): boolean {
for (const char of value) {
if (char.charCodeAt(0) < 0x20) return true
}
return false
}
/**
* Render one `KEY=value` line in the narrowest style dotenv reads back
* verbatim: bare, then single quotes (fully literal), then double quotes
* (safe only without backslashes, which double-quote reading expands).
* A value no style can represent fails loud instead of corrupting silently.
*/
function renderLine(ref: CredentialRef, value: string): string {
if (BARE_VALUE.test(value)) return `${ref}=${value}`
if (hasControlCharacters(value)) {
throw new Error(`credentials-local: the value for "${ref}" contains control characters the .env line format cannot represent`)
}
if (!value.includes('\'')) return `${ref}='${value}'`
if (!value.includes('"') && !value.includes('\\')) return `${ref}="${value}"`
throw new Error(`credentials-local: the value for "${ref}" mixes quoting no .env style can represent; edit the file directly`)
}
/** Split text into physical lines with their terminators attached. */
function physicalLines(text: string): string[] {
return text.length === 0 ? [] : text.split(/(?<=\n)/)
}
/** One physical line's content without its terminator. */
function lineContent(line: string): string {
if (line.endsWith('\r\n')) return line.slice(0, -2)
if (line.endsWith('\n')) return line.slice(0, -1)
return line
}
/** One physical line's terminator (empty on a final unterminated line). */
function lineTerminator(line: string): string {
return line.slice(lineContent(line).length)
}
/** An assignment line: optional export, a POSIX identifier, `=`, the value part. */
const ASSIGNMENT = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/
/** Quote characters dotenv reads across physical lines. */
const MULTILINE_QUOTES = ['\'', '"', '`']
/**
* The quote character an assignment's value part opens without closing on its
* own line — the following physical lines are that value's continuation, not
* assignments — or `undefined` for a single-line value.
*/
function opensMultiline(valuePart: string): string | undefined {
const trimmed = valuePart.trimStart()
const quote = trimmed[0]
if (quote === undefined || !MULTILINE_QUOTES.includes(quote)) return undefined
const rest = trimmed.slice(1)
const body = quote === '"' ? rest.replaceAll('\\"', '') : rest
return body.includes(quote) ? undefined : quote
}
/** Whether a continuation line closes the given quote. */
function closesQuote(content: string, quote: string): boolean {
const body = quote === '"' ? content.replaceAll('\\"', '') : content
return body.includes(quote)
}
/**
* Replace, insert, or delete one reference's assignment while preserving
* every other byte: untouched lines keep their exact content and terminators
* (CRLF included), and the physical lines inside another key's quoted
* multi-line value are never mistaken for assignments. The first matching
* assignment is rewritten in place with its own line ending; later duplicates
* drop (dotenv reads the last one, so a surviving duplicate would override
* the edit); an insert appends in the document's dominant ending style.
*/
function upsertLine(text: string | undefined, ref: CredentialRef, rendered: string | undefined): string {
const lines = physicalLines(text ?? '')
const dominant = lines.some(line => line.endsWith('\r\n')) ? '\r\n' : '\n'
const out: string[] = []
let placed = false
let pendingQuote: string | undefined
for (const line of lines) {
const content = lineContent(line)
if (pendingQuote !== undefined) {
// Inside a quoted multi-line value: never an assignment, always kept.
if (closesQuote(content, pendingQuote)) pendingQuote = undefined
out.push(line)
continue
}
const match = ASSIGNMENT.exec(content)
if (match === null) {
out.push(line)
continue
}
const [, key, valuePart] = match
if (key !== ref) {
/* v8 ignore next -- the value group is `(.*)`, which always participates; the fallback only satisfies noUncheckedIndexedAccess */
pendingQuote = opensMultiline(valuePart ?? '')
out.push(line)
continue
}
// The write path refuses multi-line targets before rendering, so the
// matched assignment is single-line and drops or rewrites wholesale.
if (rendered !== undefined && !placed) {
out.push(`${rendered}${lineTerminator(line) === '' ? dominant : lineTerminator(line)}`)
placed = true
}
}
if (rendered !== undefined && !placed) {
const last = out[out.length - 1]
if (last !== undefined && lineTerminator(last) === '') {
out[out.length - 1] = `${last}${dominant}`
}
out.push(`${rendered}${dominant}`)
}
return out.join('')
}
/** File-backed credentials provider (`$DSH_HOME/.env`). */
export class CredentialsLocal extends Credentials {
/* jscpd:ignore-start -- deliberate config-surface and lifecycle symmetry with
settings-local (prefer symmetry for parallel values); extracting the shared
shape would couple the two providers' teardown semantics across packages. */
static Config: z<Config> = z.object({
path: z.string(),
dshHome: z.string(),
watch: z.boolean().default(true),
debounceMs: z.number().min(0).default(100),
})
private readonly spec: ResolvedSpec
/**
* Raw text of the last read or persisted document; `undefined` while the
* file is absent. Watcher events whose content equals this cache are no-ops,
* which is also the self-write suppression.
*/
private text: string | undefined
/** Parsed document snapshot; replaced wholesale on every reload. */
private values = new Map<string, string>()
/**
* Single exclusive operation chain: watcher reloads and line edits run one
* at a time in queue order (settled tail), so an edit can never render from
* text a concurrent reload is busy replacing.
*/
private operations: Promise<void> = Promise.resolve()
/** Set at dispose: refuse new writes and let in-flight work no-op. */
private closed = false
/** Opaque read of {@link closed}: control flow cannot narrow it across awaits. */
private isClosed(): boolean {
return this.closed
}
/* jscpd:ignore-end */
constructor(ctx: Context, public config: Config) {
super(ctx)
// Programmatic construction may bypass Schemastery normalization; resolve
// the same defaults in one explicit step either way.
this.spec = resolveSpec(config)
}
async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
yield async () => {
// Drain: refuse new operations, then settle the queued ones so disposal
// completes only once storage is quiescent.
this.closed = true
await this.operations
}
await this.loadInitial()
if (!this.spec.watch) return
/* jscpd:ignore-start -- same watcher discipline as settings-local by design:
the serialized-refresh and quiesce-on-dispose shape is the reviewed
lifecycle contract, not accidental repetition. */
const watcher = chokidarWatch(this.spec.filename, {
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: this.spec.debounceMs,
pollInterval: Math.max(1, Math.min(this.spec.debounceMs, 10)),
},
})
watcher.on('all', () => {
if (this.closed) return
this.queueRefresh()
})
watcher.on('ready', () => {
// The initial load raced the watcher's own setup: a change written
// between that read and the watcher becoming active never fires an
// event. One reconcile at ready closes the gap.
if (this.closed) return
this.queueRefresh()
})
watcher.on('error', (error) => {
this.ctx.logger.warn('credentials-local: watcher error on %s', this.spec.filename)
this.ctx.logger.warn(error)
})
yield async () => {
// Quiesce: stop accepting events, close the watcher, then wait out any
// queued or in-flight operation so nothing publishes after disposal.
this.closed = true
await watcher.close()
await this.operations
}
/* jscpd:ignore-end */
}
override resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> {
const env = process.env[ref]
if (env !== undefined && env.length > 0) return Promise.resolve({ value: env, source: 'env' })
const stored = this.values.get(ref)
if (stored !== undefined && stored.length > 0) return Promise.resolve({ value: stored, source: 'file' })
return Promise.resolve(undefined)
}
override describe(ref: CredentialRef): Promise<CredentialInfo> {
const env = process.env[ref]
if (env !== undefined && env.length > 0) {
return Promise.resolve({ configured: true, source: 'env', writable: false })
}
const stored = this.values.get(ref)
if (stored !== undefined && stored.length > 0) {
// A quoted multi-line value resolves fine but the line editor refuses to
// rewrite it, so writability must say what set() would actually do.
return Promise.resolve({ configured: true, source: 'file', writable: !stored.includes('\n') })
}
return Promise.resolve({ configured: false, writable: true })
}
override async set(ref: CredentialRef, value: string): Promise<void> {
if (value.length === 0) {
throw new Error(`credentials-local: an empty value cannot be stored for "${ref}"; use unset`)
}
await this.write(ref, value)
}
override async unset(ref: CredentialRef): Promise<void> {
await this.write(ref, undefined)
}
/* jscpd:ignore-start -- the operation-chain and reload lifecycle is the same
reviewed contract as settings-local, deliberately mirrored (prefer symmetry
for parallel values); the two providers own different documents and
failure policies, so extracting the shape would couple their teardown
semantics across packages for a handful of lines. */
/** Queue one exclusive document operation behind every earlier one. */
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
const task = this.operations.then(operation)
this.operations = task.then(() => undefined, () => undefined)
return task
}
/** Queue a reload; only an invariant violation escaping the fan-out can reject it. */
private queueRefresh(): void {
void this.enqueue(() => this.refresh()).catch((error: unknown) => {
// Only an invariant violation escaping the update fan-out can reject a
// refresh; keep the operation queue alive and surface it as an error so
// one poisoned commit cannot silently end hot reloading forever.
this.ctx.logger.error('credentials-local: reload commit failed at %s', this.spec.filename)
this.ctx.logger.error(error)
})
}
/* jscpd:ignore-end */
/** Queue one line edit; entry checks reject early, the queue re-judges them at run time. */
private async write(ref: CredentialRef, value: string | undefined): Promise<void> {
const verb = value === undefined ? 'unset' : 'set'
if (this.isClosed()) {
throw new Error(`credentials-local is disposed: cannot ${verb} "${ref}"`)
}
this.assertUnshadowed(ref, verb)
return this.enqueue(async () => {
if (this.isClosed()) {
throw new Error(`credentials-local was disposed before the queued "${ref}" ${verb} ran`)
}
// Re-judged at run time: the environment may have changed while queued.
this.assertUnshadowed(ref, verb)
// The writer lock's exclusive create needs the parent to exist; 0700
// because the harness home holds user-private data.
await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
await withFileLock(this.spec.filename, async () => {
// Read-modify-write: fold in any on-disk state this process has not
// observed yet — an external edit still inside the watcher debounce
// window, a change the watcher missed, or another process's write —
// so the line edit below can never resurrect a stale document.
await this.reconcileFromDisk()
const existing = this.values.get(ref)
if (value === undefined && existing === undefined) return
if (existing !== undefined && existing.includes('\n')) {
throw new Error(
`credentials-local: "${ref}" is a multi-line entry this line editor would corrupt; edit ${this.spec.filename} directly`,
)
}
const nextText = upsertLine(this.text, ref, value === undefined ? undefined : renderLine(ref, value))
// 0600: a document holding secrets is never world-readable.
await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 })
this.text = nextText
if (value === undefined) this.values.delete(ref)
else this.values.set(ref, value)
// After the commit: a broken observer must never make the durable
// write look failed (an INVARIANT failure still rethrows).
this.notifyUpdated(ref)
}, {
onStaleBreak: (lockPath) => {
this.ctx.logger.warn('credentials-local: breaking a stale writer lock at %s', lockPath)
},
})
})
}
/** Reject a write the live environment would shadow into apparent no-effect. */
private assertUnshadowed(ref: CredentialRef, verb: 'set' | 'unset'): void {
const env = process.env[ref]
if (env !== undefined && env.length > 0) {
throw new Error(
`credentials-local: "${ref}" is supplied read-only by the process environment, so ${verb} would be`
+ ' shadowed; change the launching environment instead',
)
}
}
/** Boot read: an absent file is an empty store; any other failure is loud. */
private async loadInitial(): Promise<void> {
let text: string
try {
text = await readFile(this.spec.filename, 'utf8')
} catch (error) {
if (!isENOENT(error)) throw error
return
}
this.text = text
this.values = new Map(Object.entries(parse(text)))
}
/* jscpd:ignore-start -- same deliberate mirror of settings-local's reload and
reconcile policy: warn-and-keep on a reload, throw on a write, invariant
failures propagate. */
/**
* Re-read the document after a watcher event. Unchanged content (including
* this provider's own writes) is a no-op; an unreadable document keeps the
* last good snapshot and warns — a live hot-reload must never take the
* process down. An invariant violation escaping the fan-out is not a reload
* failure and propagates to the queue's error surface.
*/
private async refresh(): Promise<void> {
if (this.closed) return
try {
await this.reconcileFromDisk()
} catch (error) {
if ((error as { code?: unknown } | null)?.code === 'INVARIANT') throw error
this.ctx.logger.warn('credentials-local: reload failed at %s; keeping the last good document', this.spec.filename)
this.ctx.logger.warn(error)
}
}
/**
* Compare the on-disk text against the cache and publish any difference
* into the seam. Absence publishes the empty store; an unreadable file
* throws, so each caller picks its policy — a reload warns and keeps the
* last good snapshot, a write fails loud. dotenv parsing is lenient by
* design and cannot fail.
*/
private async reconcileFromDisk(): Promise<void> {
let text: string | undefined
try {
text = await readFile(this.spec.filename, 'utf8')
} catch (error) {
if (!isENOENT(error)) throw error
text = undefined
}
if (text === this.text || this.isClosed()) return
const next = text === undefined ? new Map<string, string>() : new Map(Object.entries(parse(text)))
const changed = this.changedRefs(this.values, next)
this.text = text
this.values = next
for (const ref of changed) this.notifyUpdated(ref)
}
/* jscpd:ignore-end */
/** Seam-addressable entries whose effective (non-empty) value changed. */
private changedRefs(prev: Map<string, string>, next: Map<string, string>): CredentialRef[] {
const changed: CredentialRef[] = []
for (const key of new Set([...prev.keys(), ...next.keys()])) {
const before = prev.get(key)
const after = next.get(key)
const effectiveBefore = before !== undefined && before.length > 0 ? before : undefined
const effectiveAfter = after !== undefined && after.length > 0 ? after : undefined
if (effectiveBefore === effectiveAfter) continue
try {
changed.push(credentialRef(key))
} catch (_unaddressableKey) {
// A key that is not a POSIX identifier is preserved file content the
// seam cannot address, so no observer could ever see it change.
}
}
return changed
}
}
export default CredentialsLocal
@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-credentials-local`.
* @module @deepseek-ai/dsh-credentials-local/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-credentials-local'
/** Cordis companion plugin name. */
export const name = 'credentials-local-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the seam companion (`dsh-credentials/invariant`) owns the
* `credentials/updated` lifecycle contract; this provider's file/environment layering is
* asynchronous I/O pinned by its unit suite.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { CredentialsLocal } from '../src/index.ts'
// The atomic write is the gated asynchronous hold point inside a queued
// write; gating it makes the dispose-versus-queued-write race fully
// deterministic. The lock helper passes through so the gated operation still
// runs inside its real acquire/release cycle.
vi.mock('@deepseek-ai/dsh-atomic-write', async (importOriginal) => {
const actual = await importOriginal<typeof import('@deepseek-ai/dsh-atomic-write')>()
let gate: Promise<void> = Promise.resolve()
return {
...actual,
writeFileAtomic: vi.fn(() => gate),
__setGate: (next: Promise<void>) => {
gate = next
},
}
})
async function setGate(next: Promise<void>): Promise<void> {
const mocked = await import('@deepseek-ai/dsh-atomic-write') as unknown as { __setGate: (next: Promise<void>) => void }
mocked.__setGate(next)
}
const KEY = credentialRef('DSH_CRED_DRAIN_A')
const OTHER = credentialRef('DSH_CRED_DRAIN_B')
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
await setGate(Promise.resolve())
while (cleanups.length > 0) await cleanups.pop()!()
})
describe('write-drain teardown', () => {
it('lets the in-flight write land and fails the queued one after disposal', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-drain-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
const ctx = new Context()
const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
await fiber
const service = ctx.credentials
let release!: () => void
await setGate(new Promise<void>((resolveGate) => {
release = resolveGate
}))
const first = service.set(KEY, 'one')
// Let the first task pass its liveness checks and park on the gate, so it
// is genuinely in-flight when disposal begins.
await new Promise(resolvePause => setTimeout(resolvePause, 5))
// Attach the rejection handler up front: the queued write fails while the
// drain is still awaited, before any later `await expect` could run.
const secondRejects = expect(service.set(OTHER, 'two')).rejects.toThrow(/disposed before the queued/)
const disposal = fiber.dispose()
// Give the drain disposer its first turn (set closed) before opening the gate.
await new Promise(resolvePause => setTimeout(resolvePause, 10))
release()
await disposal
await expect(first).resolves.toBeUndefined()
await secondRejects
expect(await service.resolve(KEY)).toEqual({ value: 'one', source: 'file' })
expect(await service.resolve(OTHER)).toBeUndefined()
})
})
@@ -0,0 +1,244 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
import { CredentialsLocal, resolveSpec } from '../src/index.ts'
const KEY = credentialRef('DSH_CRED_TEST')
const OTHER = credentialRef('DSH_CRED_OTHER')
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
vi.unstubAllEnvs()
while (cleanups.length > 0) await cleanups.pop()!()
})
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-local-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
async function boot(config: ConstructorParameters<typeof CredentialsLocal>[1]): Promise<Context> {
const ctx = new Context()
const fiber = ctx.plugin(CredentialsLocal, config)
cleanups.push(async () => {
await fiber.dispose()
})
await fiber
return ctx
}
function updates(ctx: Context): CredentialRef[] {
const seen: CredentialRef[] = []
ctx.on('credentials/updated', (ref) => {
seen.push(ref)
})
return seen
}
describe('resolveSpec', () => {
it('defaults to .env under the harness home with watching on', () => {
const spec = resolveSpec({ dshHome: '/custom/home' })
expect(spec).toEqual({ filename: resolve('/custom/home/.env'), watch: true, debounceMs: 100 })
})
it('lets an explicit path win over the home', () => {
const spec = resolveSpec({ path: '/etc/dsh/creds.env', dshHome: '/ignored', watch: false, debounceMs: 5 })
expect(spec).toEqual({ filename: resolve('/etc/dsh/creds.env'), watch: false, debounceMs: 5 })
})
})
describe('layering and reads', () => {
it('treats an absent file as an empty writable store', async () => {
const dir = await tempDir()
const ctx = await boot({ path: join(dir, '.env'), watch: false })
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true })
})
it('serves file entries, including export-prefixed and quoted values', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, '# notes\nexport DSH_CRED_TEST=plain\nDSH_CRED_OTHER="with space"\n')
const ctx = await boot({ path, watch: false })
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'plain', source: 'file' })
expect(await ctx.credentials.resolve(OTHER)).toEqual({ value: 'with space', source: 'file' })
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'file', writable: true })
})
it('lets a non-empty process environment win read-only over the file', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_TEST=from-file\n')
const ctx = await boot({ path, watch: false })
vi.stubEnv('DSH_CRED_TEST', 'from-env')
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-env', source: 'env' })
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: true, source: 'env', writable: false })
})
it('treats empty values as absent in both layers', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_TEST=\n')
const ctx = await boot({ path, watch: false })
vi.stubEnv('DSH_CRED_TEST', '')
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
expect(await ctx.credentials.describe(KEY)).toEqual({ configured: false, writable: true })
})
it('fails boot loud when the document exists but cannot be read', async () => {
const dir = await tempDir()
const path = join(dir, 'occupied')
await mkdir(path)
const ctx = new Context()
await expect(ctx.plugin(CredentialsLocal, { path, watch: false })).rejects.toThrow()
})
})
describe('line-editing writes', () => {
it('appends a missing key to a fresh 0600 document and emits the commit', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, watch: false })
const seen = updates(ctx)
await ctx.credentials.set(KEY, 'sk-fresh')
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=sk-fresh\n')
expect((await stat(path)).mode & 0o777).toBe(0o600)
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'sk-fresh', source: 'file' })
expect(seen).toEqual([KEY])
})
it('rewrites one line in place, preserving every other byte and dropping duplicates', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, '# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=old\nTRAILING=x\nDSH_CRED_TEST=older')
const ctx = await boot({ path, watch: false })
await ctx.credentials.set(KEY, 'new value!')
expect(await readFile(path, 'utf8')).toBe('# deployment notes\nFIRST=one\n\nDSH_CRED_TEST=\'new value!\'\nTRAILING=x\n')
})
it('quotes hostile values so they round-trip through a fresh provider', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, watch: false })
const singleQuoted = 'with "quote", back\\slash and space'
const doubleQuoted = "it's got an apostrophe"
await ctx.credentials.set(KEY, singleQuoted)
await ctx.credentials.set(OTHER, doubleQuoted)
const reread = await boot({ path, watch: false })
expect(await reread.credentials.resolve(KEY)).toEqual({ value: singleQuoted, source: 'file' })
expect(await reread.credentials.resolve(OTHER)).toEqual({ value: doubleQuoted, source: 'file' })
})
it('fails loud on values no .env quoting style reads back verbatim', async () => {
const dir = await tempDir()
const ctx = await boot({ path: join(dir, '.env'), watch: false })
await expect(ctx.credentials.set(KEY, 'line one\nline two')).rejects.toThrow(/control characters/)
await expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/)
})
it('unsets only the owning line and keeps an absent unset silent', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, '# keep\nDSH_CRED_TEST=gone\nDSH_CRED_OTHER=stays\n')
const ctx = await boot({ path, watch: false })
const seen = updates(ctx)
await ctx.credentials.unset(KEY)
expect(await readFile(path, 'utf8')).toBe('# keep\nDSH_CRED_OTHER=stays\n')
await ctx.credentials.unset(KEY)
expect(seen).toEqual([KEY])
})
it('rejects empty values, shadowed writes, and multi-line entries', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_TEST="line one\nline two"\n')
const ctx = await boot({ path, watch: false })
await expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/)
await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/multi-line/)
await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/multi-line/)
vi.stubEnv('DSH_CRED_TEST', 'shadowing')
await expect(ctx.credentials.set(KEY, 'next')).rejects.toThrow(/shadowed/)
await expect(ctx.credentials.unset(KEY)).rejects.toThrow(/shadowed/)
})
it('leaves an empty document after unsetting the only entry', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_TEST=only\n')
const ctx = await boot({ path, watch: false })
await ctx.credentials.unset(KEY)
expect(await readFile(path, 'utf8')).toBe('')
})
it('chains past a rejected write so one bad value cannot poison the queue', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, watch: false })
const bad = expect(ctx.credentials.set(KEY, 'both \' and "')).rejects.toThrow(/mixes quoting/)
const good = ctx.credentials.set(OTHER, 'lands')
await bad
await good
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_OTHER=lands\n')
})
it('serializes concurrent writes so both land in the one document', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, watch: false })
await Promise.all([
ctx.credentials.set(KEY, 'one'),
ctx.credentials.set(OTHER, 'two'),
])
expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST=one\nDSH_CRED_OTHER=two\n')
})
it('refuses writes after disposal', async () => {
const dir = await tempDir()
const ctx = new Context()
const fiber = ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
await fiber
// Capture the handle first: disposal also removes the ctx.credentials service.
const service = ctx.credentials
await fiber.dispose()
await expect(service.set(KEY, 'late')).rejects.toThrow(/disposed/)
})
})
describe('real hot reload', () => {
it('publishes external edits, replaces the snapshot wholesale, and suppresses self-writes', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
// Watching starts on an existing document: creation racing watcher setup
// is a chokidar readiness gap, not the reload contract under test.
await writeFile(path, 'DSH_CRED_TEST=boot\n')
const ctx = await boot({ path, debounceMs: 10 })
const seen = updates(ctx)
await writeFile(path, 'DSH_CRED_TEST=live\nDSH_CRED_OTHER=extra\n')
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'live', source: 'file' })
})
// Wholesale replacement: an entry deleted on disk never lingers in memory.
await writeFile(path, 'DSH_CRED_TEST=live\n')
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(OTHER)).toBeUndefined()
})
const before = seen.length
await ctx.credentials.set(KEY, 'self-written')
await new Promise(resolvePause => setTimeout(resolvePause, 200))
// Exactly the committed write's own event: the watcher echo of our own
// content is recognized by the text cache and publishes nothing extra.
expect(seen.length).toBe(before + 1)
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'self-written', source: 'file' })
})
})
@@ -0,0 +1,202 @@
// Third-review behaviors: read-modify-write under the writer lock (external
// edits survive an API write), the contained credentials/updated fan-out (a
// broken observer never fails a committed write), and the physical-line
// editor's multi-line and CRLF discipline.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { CredentialsLocal } from '../src/index.ts'
const ALPHA = credentialRef('DSH_REVIEW_ALPHA')
const BETA = credentialRef('DSH_REVIEW_BETA')
const INNER = credentialRef('DSH_REVIEW_INNER')
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!()
})
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-cred-review-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
async function boot(config: ConstructorParameters<typeof CredentialsLocal>[1]): Promise<Context> {
const ctx = new Context()
const fiber = ctx.plugin(CredentialsLocal, config)
cleanups.push(async () => { await fiber.dispose() })
await fiber
return ctx
}
describe('read-modify-write', () => {
it('folds an unobserved external edit into a write instead of overwriting it', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, watch: false })
const seen: string[] = []
ctx.on('credentials/updated', (ref) => { seen.push(ref) })
await ctx.credentials.set(ALPHA, 'one')
// The external edit has landed on disk but no watcher reported it (watch
// is off — the same blind spot as a debounce window or a missed event).
await writeFile(path, `${ALPHA}=one\n${BETA}=external\n`)
await ctx.credentials.set(ALPHA, 'two')
const text = await readFile(path, 'utf8')
expect(text).toContain(`${BETA}=external`)
expect(text).toContain(`${ALPHA}=two`)
// The fold published the unobserved entry before the write's own commit.
expect(seen).toEqual([ALPHA, BETA, ALPHA])
expect(await ctx.credentials.resolve(BETA)).toEqual({ value: 'external', source: 'file' })
})
it('keeps both refs when two providers write the same document concurrently', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const first = await boot({ path, watch: false })
const second = await boot({ path, watch: false })
await Promise.all([
(async () => { for (const value of ['1', '2', '3'] as const) await first.credentials.set(ALPHA, value) })(),
(async () => { for (const value of ['1', '2', '3'] as const) await second.credentials.set(BETA, value) })(),
])
const third = await boot({ path, watch: false })
expect(await third.credentials.resolve(ALPHA)).toEqual({ value: '3', source: 'file' })
expect(await third.credentials.resolve(BETA)).toEqual({ value: '3', source: 'file' })
})
it('breaks a stale writer lock with a warning and writes through', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, watch: false })
await writeFile(`${path}.lock`, 'crashed-holder\n')
const past = (Date.now() - 60_000) / 1000
await utimes(`${path}.lock`, past, past)
await ctx.credentials.set(ALPHA, 'nine')
expect(await readFile(path, 'utf8')).toContain(`${ALPHA}=nine`)
})
it('creates the credentials directory owner-only', async () => {
const dir = await tempDir()
const home = join(dir, 'home')
const ctx = await boot({ path: join(home, '.env'), watch: false })
await ctx.credentials.set(ALPHA, 'one')
expect((await stat(home)).mode & 0o777).toBe(0o700)
})
})
describe('contained update fan-out', () => {
it('does not fail a committed set when a listener throws, and later listeners still run', async () => {
const dir = await tempDir()
const ctx = await boot({ path: join(dir, '.env'), watch: false })
ctx.on('credentials/updated', () => {
throw new Error('observer boom')
})
const second = vi.fn()
ctx.on('credentials/updated', second)
await expect(ctx.credentials.set(ALPHA, 'one')).resolves.toBeUndefined()
expect(second).toHaveBeenCalledWith(ALPHA)
expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'one', source: 'file' })
})
it('contains an async listener rejection', async () => {
const dir = await tempDir()
const ctx = await boot({ path: join(dir, '.env'), watch: false })
// An unknown-returning function keeps the typed surface legal while the
// runtime value is still the rejected promise the containment must handle.
const boom = (): unknown => Promise.reject(new Error('async observer boom'))
ctx.on('credentials/updated', boom)
await expect(ctx.credentials.set(ALPHA, 'one')).resolves.toBeUndefined()
await new Promise(resolve => setTimeout(resolve, 10))
})
it('rethrows an invariant-coded failure after the commit and the remaining listeners', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, watch: false })
ctx.on('credentials/updated', () => {
throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
})
const second = vi.fn()
ctx.on('credentials/updated', second)
await expect(ctx.credentials.set(ALPHA, 'one')).rejects.toThrow(/forged relation/)
// Harness-fatal by design — but the write itself committed first.
expect(second).toHaveBeenCalledWith(ALPHA)
expect(await readFile(path, 'utf8')).toContain(`${ALPHA}=one`)
expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'one', source: 'file' })
})
})
describe('physical-line editor', () => {
it('never mistakes a quoted multi-line continuation for an assignment', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const wrapped = `DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=a\n`
await writeFile(path, wrapped)
const ctx = await boot({ path, watch: false })
await ctx.credentials.set(ALPHA, 'b')
// The wrapped value survives byte-for-byte; only ALPHA's line changed.
const afterAlpha = await readFile(path, 'utf8')
expect(afterAlpha).toBe(`DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=b\n`)
// Setting the inner-looking ref appends a real assignment; the
// continuation line inside the quoted value stays untouched.
await ctx.credentials.set(INNER, 'real')
const afterInner = await readFile(path, 'utf8')
expect(afterInner).toBe(`DSH_REVIEW_WRAPPED="line1\n${INNER}=looks-like-one\nline3"\n${ALPHA}=b\n${INNER}=real\n`)
expect(await ctx.credentials.resolve(INNER)).toEqual({ value: 'real', source: 'file' })
})
it('preserves CRLF line endings on untouched and edited lines', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, `# note\r\n${ALPHA}=a\r\n${BETA}=keep\r\n`)
const ctx = await boot({ path, watch: false })
await ctx.credentials.set(ALPHA, 'b')
expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n`)
await ctx.credentials.set(INNER, 'new')
expect(await readFile(path, 'utf8')).toBe(`# note\r\n${ALPHA}=b\r\n${BETA}=keep\r\n${INNER}=new\r\n`)
})
it('terminates a final unterminated line before appending', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, `${ALPHA}=a`)
const ctx = await boot({ path, watch: false })
await ctx.credentials.set(BETA, 'b')
expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=a\n${BETA}=b\n`)
})
it('rewrites a final unterminated assignment in the dominant ending style', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, `${ALPHA}=a`)
const ctx = await boot({ path, watch: false })
await ctx.credentials.set(ALPHA, 'b')
expect(await readFile(path, 'utf8')).toBe(`${ALPHA}=b\n`)
})
it('tracks a single-quoted multi-line value through its continuation', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, `DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n`)
const ctx = await boot({ path, watch: false })
await ctx.credentials.set(ALPHA, 'x')
expect(await readFile(path, 'utf8'))
.toBe(`DSH_REVIEW_SQ='line1\n${INNER}=shadow\nline3'\n${ALPHA}=x\n`)
})
it('reports a multi-line entry as unwritable and refuses to edit it', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, `${ALPHA}="line1\nline2"\n`)
const ctx = await boot({ path, watch: false })
expect(await ctx.credentials.describe(ALPHA)).toEqual({ configured: true, source: 'file', writable: false })
await expect(ctx.credentials.set(ALPHA, 'flat')).rejects.toThrow(/multi-line entry/)
await expect(ctx.credentials.unset(ALPHA)).rejects.toThrow(/multi-line entry/)
// Resolution still serves the multi-line value.
expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'line1\nline2', source: 'file' })
})
})
@@ -0,0 +1,223 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { CredentialsLocal } from '../src/index.ts'
// chokidar is the nondeterministic OS boundary: faking it lets these tests
// drive the event pipeline (error events, races with unreadable files)
// deterministically. Real end-to-end watching stays covered by local.spec.ts.
vi.mock('chokidar', async () => {
const { EventEmitter } = await import('node:events')
class FakeWatcher extends EventEmitter {
close = vi.fn(() => Promise.resolve())
}
const instances: Array<{ path: string; options: unknown; watcher: InstanceType<typeof FakeWatcher> }> = []
return {
watch: vi.fn((path: string, options: unknown) => {
const watcher = new FakeWatcher()
instances.push({ path, options, watcher })
return watcher
}),
__instances: instances,
}
})
interface FakeChokidar {
__instances: Array<{
path: string
options: { awaitWriteFinish: { stabilityThreshold: number; pollInterval: number } }
watcher: import('node:events').EventEmitter
}>
}
async function fakeInstances(): Promise<FakeChokidar['__instances']> {
const chokidar = await import('chokidar') as unknown as FakeChokidar
return chokidar.__instances
}
const KEY = credentialRef('DSH_CRED_PIPE')
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!()
;(await fakeInstances()).length = 0
})
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-watch-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
async function boot(config: ConstructorParameters<typeof CredentialsLocal>[1]): Promise<Context> {
const ctx = new Context()
const fiber = ctx.plugin(CredentialsLocal, config)
cleanups.push(async () => {
await fiber.dispose()
})
await fiber
return ctx
}
describe('watcher pipeline', () => {
it('clamps the write-settle poll interval for a zero debounce', async () => {
const dir = await tempDir()
await boot({ path: join(dir, '.env'), debounceMs: 0 })
const [instance] = await fakeInstances()
expect(instance!.options.awaitWriteFinish).toEqual({ stabilityThreshold: 0, pollInterval: 1 })
})
it('survives a watcher error and keeps publishing later edits', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, debounceMs: 5 })
const [instance] = await fakeInstances()
instance!.watcher.emit('error', new Error('watch backend failure'))
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
await writeFile(path, 'DSH_CRED_PIPE=arrived\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'arrived', source: 'file' })
})
})
it('keeps the last good snapshot when the file turns unreadable at runtime', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_PIPE=good\n')
const ctx = await boot({ path, debounceMs: 5 })
await chmod(path, 0o000)
cleanups.push(() => chmod(path, 0o600))
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'change', path)
// The warn-and-keep path is asynchronous; give the serialized refresh a turn.
await new Promise(resolve => setTimeout(resolve, 50))
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'good', source: 'file' })
})
it('keeps the reload queue alive after an invariant violation escapes the fan-out', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, debounceMs: 5 })
let arm = true
ctx.on('credentials/updated', () => {
if (!arm) return
throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
})
const [instance] = await fakeInstances()
await writeFile(path, 'DSH_CRED_PIPE=first\n')
instance!.watcher.emit('all', 'change', path)
// The snapshot commits before the fan-out, so the value lands even though
// the listener threw out of the refresh.
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'first', source: 'file' })
})
arm = false
await writeFile(path, 'DSH_CRED_PIPE=second\n')
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'second', source: 'file' })
})
})
it('quiesces the refresh pipeline before dispose completes', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_PIPE=initial\n')
const ctx = new Context()
const fiber = ctx.plugin(CredentialsLocal, { path, debounceMs: 5 })
await fiber
let disposed = false
let postDisposeCommits = 0
ctx.on('credentials/updated', () => {
if (disposed) postDisposeCommits += 1
})
await writeFile(path, 'DSH_CRED_PIPE=changed\n')
const [instance] = await fakeInstances()
// Two queued refreshes: dispose interrupts one mid-flight and the other
// before it starts, so both closed guards must hold.
instance!.watcher.emit('all', 'change', path)
instance!.watcher.emit('all', 'change', path)
await fiber.dispose()
disposed = true
instance!.watcher.emit('all', 'change', path)
instance!.watcher.emit('ready')
await new Promise(resolve => setTimeout(resolve, 100))
expect(postDisposeCommits).toBe(0)
})
it('empties the snapshot when the document is deleted and emits the removals', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'DSH_CRED_PIPE=doomed\n')
const ctx = await boot({ path, debounceMs: 5 })
const seen: string[] = []
ctx.on('credentials/updated', (ref) => {
seen.push(ref)
})
await rm(path)
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'unlink', path)
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
})
expect(seen).toEqual([KEY])
})
it('publishes only seam-addressable keys and preserves the rest untouched', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, 'BAD-KEY=1\nDSH_CRED_PIPE=a\n')
const ctx = await boot({ path, debounceMs: 5 })
const seen: string[] = []
ctx.on('credentials/updated', (ref) => {
seen.push(ref)
})
await writeFile(path, 'BAD-KEY=2\nDSH_CRED_PIPE=b\n')
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'change', path)
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'b', source: 'file' })
})
// The dash-named key is preserved file content the seam cannot address:
// its change publishes nothing and breaks nothing.
expect(seen).toEqual([KEY])
})
it('treats an event for a still-absent file as a no-op', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
const ctx = await boot({ path, debounceMs: 5 })
const [instance] = await fakeInstances()
instance!.watcher.emit('all', 'add', path)
await new Promise(resolve => setTimeout(resolve, 50))
expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
})
it('reconciles at watcher ready so a change during setup is not missed', async () => {
const dir = await tempDir()
const path = join(dir, '.env')
await writeFile(path, `${KEY}=a\n`)
const ctx = await boot({ path, debounceMs: 5 })
// Written after the initial load but before the watcher became active:
// no 'all' event will ever fire for it.
await writeFile(path, `${KEY}=written-before-ready\n`)
const [instance] = await fakeInstances()
instance!.watcher.emit('ready')
await vi.waitFor(async () => {
expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'written-before-ready', source: 'file' })
})
})
})
@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/atomic-write"
},
{
"path": "../../util/paths"
},
{
"path": "../credentials"
},
{
"path": "../../support/invariants"
}
]
}
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/credentials/credentials/README.md
README.md: 1c18c4762360ad081227b7097cd82ddab4fcdefc
README.zh.md: 751fb7c1e8326cef91b925c5f8b9f40d92e1bba6
@@ -0,0 +1,48 @@
# dsh-credentials
English | [中文](README.zh.md)
Abstract credential seam (`ctx.credentials`). One doctrine, three consequences:
**Configuration carries references to secrets, never the secrets.** A settings section or `cordis.yml` entry says `apiKeyEnv: DEEPSEEK_API_KEY`; the value behind that reference lives with a credential provider. So the settings document stays safe to sync and to render in a configuration UI, `describe()` can answer "is this configured, where from, can I write it" without ever holding a value, and rotating a secret touches no configuration file.
**Consumers resolve per operation.** `resolve(ref)` is called at the start of each operation (the LLM adapters resolve once per model request) and never cached across operations — that read is what makes a changed credential reach the very next request without restarting any plugin.
**An empty stored value is absent.** Everywhere: `resolve` skips it, `describe` reports it unconfigured. A blank can never masquerade as a configured secret.
## Surface
```ts
import type { Context } from 'cordis'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
declare const ctx: Context
const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded
const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined
const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value
await ctx.credentials.set(ref, 'sk-…') // rejects while a read-only source shadows the ref
await ctx.credentials.unset(ref) // no-op when absent; same shadowing rule
```
`credentials/updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration UIs refreshing a "configured" badge.
The shadowing rule on `set`/`unset` is deliberate fail-loud: when a read-only source (the live process environment, in the local provider) currently supplies the reference, a write would appear to succeed while resolution keeps returning the shadowing value — the seam rejects instead, and `describe().writable` lets a UI render the reference read-only up front.
## Providers
[`dsh-credentials-local`](../credentials-local/README.md) layers the live process environment over a `$DSH_HOME/.env` file. The seam shape leaves room for keyring-, helper-command-, and KMS-backed providers; a remote settings provider never needs to carry secrets.
## Model Experience
Indirectly, through the consuming LLM adapters: a resolved value authorizes their provider requests, and the adapter owns every model-visible surface.
#### KV Cache effect
No direct invalidation; credentials never enter a request prefix.
## Known Limitations and Deferred Work
- **No enumeration** — the seam answers questions about references it is given; configuration surfaces learn the references from settings schemas, so a `list()` has no current consumer.
- **References are environment-variable-shaped** — one flat POSIX-identifier namespace until a provider needs richer addressing.
- **Process-environment changes are invisible** — no event can fire for them; a UI only re-reads `describe()` on its own navigation.
@@ -0,0 +1,48 @@
# dsh-credentials
[English](README.md) | 中文
抽象凭据 seam`ctx.credentials`)。一条准则,三个推论:
**配置只携带对机密的引用,绝不携带机密本身。** settings 分节或 `cordis.yml` 条目写 `apiKeyEnv: DEEPSEEK_API_KEY`,引用背后的值归凭据 provider 所有。于是设置文档可以放心同步、放心渲染进配置界面;`describe()` 无需持有值就能回答「配置了吗、来自哪层、能否写入」;轮换机密不触碰任何配置文件。
**消费方按操作解析。** `resolve(ref)` 在每个操作开始时调用(LLM 适配器每次模型请求解析一次),绝不跨操作缓存——正是这次读取让改过的凭据无需重启任何插件就作用于下一次请求。
**空的存储值等于不存在。**处处如此:`resolve` 跳过它,`describe` 报告未配置。空白永远不会伪装成已配置的机密。
## 接口面
```ts
import type { Context } from 'cordis'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
declare const ctx: Context
const ref = credentialRef('DEEPSEEK_API_KEY') // POSIX shell identifier, branded
const hit = await ctx.credentials.resolve(ref) // { value, source } | undefined
const info = await ctx.credentials.describe(ref) // { configured, source?, writable } — never the value
await ctx.credentials.set(ref, 'sk-…') // rejects while a read-only source shadows the ref
await ctx.credentials.unset(ref) // no-op when absent; same shadowing rule
```
`credentials/updated (ref)` 在 provider 管理的来源发生已提交变更后触发——`set``unset` 或在存储中观察到的外部编辑。进程环境变量的变化不可观测,永不触发。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。
`set`/`unset` 的遮蔽规则是刻意的响亮失败:当只读来源(本地 provider 中即活跃进程环境)正在提供该引用时,写入会表面成功而解析仍返回遮蔽值——seam 选择直接拒绝,并通过 `describe().writable` 让界面提前把该引用渲染为只读。
## Providers
[`dsh-credentials-local`](../credentials-local/README.md) 把活跃进程环境叠加在 `$DSH_HOME/.env` 文件之上。seam 形状为 keyring、辅助命令、KMS 后端的 provider 留好了位置;远端 settings provider 永远不必携带机密。
## Model Experience
经由消费它的 LLM 适配器间接生效:解析出的值为适配器的提供方请求授权,每个模型可见面都归适配器所有。
#### KV Cache effect
无直接失效;凭据绝不进入请求前缀。
## Known Limitations and Deferred Work
- **不提供枚举**——seam 只回答被问到的引用;配置界面从 settings schema 得知引用集合,`list()` 没有当前消费方。
- **引用限定为环境变量形状**——在有 provider 需要更丰富寻址前,保持单一扁平的 POSIX 标识符命名空间。
- **进程环境变化不可见**——不可能为其发事件;界面只能在自身导航时重新读取 `describe()`
@@ -0,0 +1,39 @@
{
"name": "@deepseek-ai/dsh-credentials",
"description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
@@ -0,0 +1,162 @@
/**
* Credential seam (`ctx.credentials`). Settings and composition files carry
* *references* to secrets — environment-variable names — while providers own
* the actual values and their storage. Consumers resolve a reference once per
* operation, so a changed credential reaches the next operation without any
* plugin restart, and configuration surfaces describe a reference without
* ever seeing its value.
* @module @deepseek-ai/dsh-credentials
*/
import { Context, Service } from 'cordis'
import type { Branded } from '@deepseek-ai/dsh-brand'
/** Nominal reference to one credential: a POSIX-style environment-variable name. */
export type CredentialRef = Branded<'CredentialRef'>
const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
/**
* Brand a raw string as a {@link CredentialRef}.
* @param value - candidate reference; a POSIX shell identifier such as `DEEPSEEK_API_KEY`.
* @returns the branded reference.
*/
export function credentialRef(value: string): CredentialRef {
if (!REF_PATTERN.test(value)) {
throw new TypeError(`credential ref "${value}" must match ${String(REF_PATTERN)}`)
}
return value as CredentialRef
}
/** One resolved credential value and the source layer that supplied it. */
export interface ResolvedCredential {
/** The non-empty secret value. */
value: string
/** Provider-defined source layer id (the local provider uses `env` and `file`). */
source: string
}
/** Source and writability facts for one reference, safe for configuration UIs — never the value. */
export interface CredentialInfo {
/** Whether {@link Credentials.resolve} would currently return a value. */
configured: boolean
/** Source layer currently supplying the value; absent while unconfigured. */
source?: string
/** Whether {@link Credentials.set} would currently succeed for this reference. */
writable: boolean
}
declare module 'cordis' {
interface Context {
credentials: Credentials
}
interface Events {
/**
* Committed change to a provider-managed credential source: a `set`, an
* `unset`, or an external edit observed in storage. Ambient
* process-environment changes are not observable and never emit. Listener
* failures are contained and logged — a sync throw and an async rejection
* alike — without changing the committed operation's outcome, except
* `INVARIANT`-coded failures, which rethrow after every listener ran;
* that rethrow reaches the emitter only from synchronous listeners, so
* invariant checks on this event must not be async functions.
* @param ref - the reference whose stored value changed.
* @mode emit
*/
'credentials/updated'(ref: CredentialRef): void
}
}
/**
* Abstract credential service. Providers implement the four operations over
* their source layers; one seam-wide rule binds them all: an empty stored
* value is absent everywhere — `resolve` skips it, `describe` reports it
* unconfigured — so a blank never masquerades as a configured secret.
*/
export abstract class Credentials extends Service {
constructor(ctx: Context) {
super(ctx, 'credentials')
}
/**
* Resolve one reference to its current value. Resolution is per call:
* consumers re-resolve at each operation and must not cache across
* operations — that per-operation read is what makes a changed credential
* reach the next operation without a restart.
* @param ref - the reference to resolve.
* @returns the value and its source, or `undefined` while unconfigured.
*/
abstract resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined>
/**
* Describe one reference for configuration surfaces without exposing the
* value.
* @param ref - the reference to describe.
* @returns configured state, supplying source, and writability.
*/
abstract describe(ref: CredentialRef): Promise<CredentialInfo>
/**
* Durably store one value in the provider-managed writable source. Rejects
* while a read-only source shadows the reference — the write would appear
* to succeed while resolution keeps returning the shadowing value — and
* rejects an empty value (use {@link unset}).
* @param ref - the reference to store.
* @param value - the non-empty secret value.
*/
abstract set(ref: CredentialRef, value: string): Promise<void>
/**
* Remove one reference from the provider-managed writable source; removing
* an absent reference is a no-op. Rejects while a read-only source shadows
* the reference, like {@link set}.
* @param ref - the reference to remove.
*/
abstract unset(ref: CredentialRef): Promise<void>
/* jscpd:ignore-start -- deliberate symmetry with the settings seam's commit
fan-out: the contained-dispatch shape is the reviewed listener-lifecycle
contract, and extracting it would couple the two seams' event semantics. */
/**
* Fan `credentials/updated` out with contained listener failures: every
* listener runs, and a sync throw or async rejection is logged without
* changing the committed operation's outcome — except `INVARIANT`-coded
* failures, which rethrow after every listener ran (the rethrow reaches the
* caller only from synchronous listeners, so invariant checks on this event
* must not be async functions). Providers call this only after the write or
* reload actually committed, so a broken observer can never make a durable
* change look failed.
* @param ref - the reference whose stored value changed.
*/
protected notifyUpdated(ref: CredentialRef): void {
let invariantFailure: unknown
const args = ['credentials/updated', ref]
for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) {
try {
const returned = listener(ref)
if (returned != null && typeof (returned as PromiseLike<unknown>).then === 'function') {
void Promise.resolve(returned as PromiseLike<unknown>).then(undefined, (error: unknown) => {
this.warnListenerFailure(ref, error)
})
}
} catch (error) {
if ((error as { code?: unknown } | null)?.code === 'INVARIANT') {
invariantFailure ??= error
continue
}
this.warnListenerFailure(ref, error)
}
}
if (invariantFailure !== undefined) throw invariantFailure as Error
}
/* jscpd:ignore-end */
/** Contained-listener diagnostic shared by the sync and async failure paths. */
private warnListenerFailure(ref: CredentialRef, error: unknown): void {
this.ctx.logger.warn('credentials: a credentials/updated listener for "%s" failed', ref)
this.ctx.logger.warn(error)
}
}
export default Credentials
@@ -0,0 +1,38 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-credentials`.
* @module @deepseek-ai/dsh-credentials/invariant
*/
import type { Context } from 'cordis'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-credentials'
/** Cordis companion plugin name. */
export const name = 'credentials-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* Install the commit-event lifecycle contract: `credentials/updated` names a
* committed provider-source change, so it can only fire while a credentials
* service is live — an emission after disposal means a provider leaked work
* past its teardown quiescence. The value relation itself (`describe`
* agreeing with `resolve`) is asynchronous provider I/O and stays pinned by
* each provider's own suite.
*/
const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => {
ctx.on('credentials/updated', (ref) => {
if (ctx.get('credentials') === undefined) {
fail(`credentials/updated for "${ref}" emitted without a live credentials service`)
}
})
}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { credentialRef } from '../src/index.ts'
import type { CredentialRef } from '../src/index.ts'
import { MemoryCredentials } from './memory.ts'
const REF = credentialRef('DEEPSEEK_API_KEY')
async function boot(seed: Record<string, string> = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(MemoryCredentials, seed)
return ctx
}
describe('credentialRef', () => {
it('brands POSIX shell identifiers', () => {
expect(credentialRef('DEEPSEEK_API_KEY')).toBe('DEEPSEEK_API_KEY')
expect(credentialRef('_private')).toBe('_private')
expect(credentialRef('lower_case9')).toBe('lower_case9')
})
it('rejects every other shape', () => {
for (const invalid of ['', '9LEADING', 'WITH-DASH', 'WITH SPACE', 'ns:key']) {
expect(() => credentialRef(invalid)).toThrow(TypeError)
}
})
})
describe('the credentials seam through the memory provider', () => {
it('mounts as ctx.credentials and resolves a seeded reference with its source', async () => {
const ctx = await boot({ DEEPSEEK_API_KEY: 'sk-seeded' })
expect(await ctx.credentials.resolve(REF)).toEqual({ value: 'sk-seeded', source: 'memory' })
expect(await ctx.credentials.describe(REF)).toEqual({ configured: true, source: 'memory', writable: true })
})
it('treats an empty stored value as absent everywhere', async () => {
const ctx = await boot({ DEEPSEEK_API_KEY: '' })
expect(await ctx.credentials.resolve(REF)).toBeUndefined()
expect(await ctx.credentials.describe(REF)).toEqual({ configured: false, writable: true })
})
it('stores through set, removes through unset, and emits the committed change', async () => {
const ctx = await boot()
const events: CredentialRef[] = []
ctx.on('credentials/updated', ref => void events.push(ref))
await ctx.credentials.set(REF, 'sk-live')
expect(await ctx.credentials.resolve(REF)).toEqual({ value: 'sk-live', source: 'memory' })
await ctx.credentials.unset(REF)
expect(await ctx.credentials.resolve(REF)).toBeUndefined()
expect(events).toEqual([REF, REF])
})
it('rejects an empty set and keeps an absent unset silent', async () => {
const ctx = await boot()
const events: CredentialRef[] = []
ctx.on('credentials/updated', ref => void events.push(ref))
await expect(ctx.credentials.set(REF, '')).rejects.toThrow(/empty value/)
await ctx.credentials.unset(REF)
expect(events).toEqual([])
})
it('removes the service with its fiber', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(MemoryCredentials)
expect(ctx.get('credentials')).toBeDefined()
await fiber.dispose()
expect(ctx.get('credentials')).toBeUndefined()
})
})
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { credentialRef } from '../src/index.ts'
import * as CredentialsInvariant from '../src/invariant.ts'
import { MemoryCredentials } from './memory.ts'
const REF = credentialRef('DEEPSEEK_API_KEY')
describe('credentials invariant companion', () => {
it('accepts a committed change emitted by a live service', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService)
await ctx.plugin(CredentialsInvariant)
await ctx.plugin(MemoryCredentials)
await expect(ctx.credentials.set(REF, 'sk-live')).resolves.toBeUndefined()
})
it('fails an update event emitted without a live service', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService)
await ctx.plugin(CredentialsInvariant)
expect(() => { ctx.emit('credentials/updated', REF) }).toThrow(/invariant violated by "@deepseek-ai\/dsh-credentials"/)
})
it('reserves the package name against duplicate registration', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService)
await ctx.plugin(CredentialsInvariant)
expect(() => {
ctx.invariants.register('@deepseek-ai/dsh-credentials', () => {})
}).toThrow(/already registered/)
})
})
@@ -0,0 +1,49 @@
import type { Context } from 'cordis'
import { Credentials } from '../src/index.ts'
import type { CredentialInfo, CredentialRef, ResolvedCredential } from '../src/index.ts'
/**
* In-memory credentials provider for interface and consumer tests: one
* always-writable `memory` source seeded from plugin config.
*/
export class MemoryCredentials extends Credentials {
private readonly store = new Map<string, string>()
constructor(ctx: Context, seed: Record<string, string> = {}) {
super(ctx)
for (const [key, value] of Object.entries(seed)) this.store.set(key, value)
}
override resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> {
const value = this.store.get(ref)
return Promise.resolve(value === undefined || value.length === 0
? undefined
: { value, source: 'memory' })
}
override describe(ref: CredentialRef): Promise<CredentialInfo> {
const value = this.store.get(ref)
const configured = value !== undefined && value.length > 0
return Promise.resolve({
configured,
...configured ? { source: 'memory' } : {},
writable: true,
})
}
override set(ref: CredentialRef, value: string): Promise<void> {
if (value.length === 0) {
return Promise.reject(new Error('memory credentials: an empty value cannot be stored; use unset'))
}
this.store.set(ref, value)
this.ctx.emit('credentials/updated', ref)
return Promise.resolve()
}
override unset(ref: CredentialRef): Promise<void> {
if (this.store.delete(ref)) {
this.ctx.emit('credentials/updated', ref)
}
return Promise.resolve()
}
}
@@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/brand"
},
{
"path": "../../support/invariants"
}
]
}
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md
README.md: 7a2314ea2ca0fb6606310a4961fcbc658240a7b8
README.zh.md: d73145f8b8a32d8a515b6b4c0e916f0a11cd4771
README.md: ab44b61e300ca65cc4dd3507ad7262cd08edcfce
README.zh.md: 4ecaf361fdb396f9f8079476240b5e9353a73f5e
+15 -3
View File
@@ -14,8 +14,9 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback
baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com
apiKeyEnv: DEEPSEEK_API_KEY # default; resolved per request via ctx.credentials, then the environment
# apiKey: … # literal escape hatch; prefer the reference so no secret enters this file
baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted
thinking: enabled # optional; provider default is enabled
reasoningEffort: high # optional; off | high | max — omitted ⇒ high
streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default
@@ -44,6 +45,15 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und
`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers the configured policy as provider metadata, and `dsh-llm-retry` separately executes it at durable agent-step boundaries.
## Dynamic configuration (settings + credentials)
Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk:
- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load.
- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between.
The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek')` always reports the current policy.
## App attribution
Every request carries the shared attribution header from dsh-llm's `attributionHeaders()` - the mandatory `User-Agent` baseline identifying the harness (see [dsh-llm § App attribution](../llm/README.md#app-attribution-attributionts)). Direct DeepSeek requests and OpenAI-compatible gateway requests get no provider-specific app-attribution headers under this adapter contract; OpenRouter app attribution is deferred to a future explicit OpenRouter adapter or mode. A request whose `GenerateOptions.purpose` is `compaction` (dsh-compact-basic's auxiliary summarization call) additionally carries `x-deepseek-harness-compact: 1`, so the host can separate compaction traffic from conversation requests.
@@ -62,7 +72,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA`
## Testing
Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback.
Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers (next-request base-URL/key pickup, literal precedence, keyless onboarding, last-good snapshots, retry-policy re-registration), and `tests/loader-composition.spec.ts` boots the full chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback and a request whose key exists only in a credentials-local document.
## Model Experience
@@ -96,6 +106,8 @@ Loop-retained response blocks append to the next request and preserve its earlie
## Known Limitations and Deferred Work
- **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape.
- **`Config.apiKey` is schema-tagged `role('secret')` but not yet masked anywhere** — the settings `describe()` envelope returns values verbatim; the wire/UI layer that must redact secret-role fields ships with the settings RPC surface.
- **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin).
- **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`).
- **Serialization flattens user and tool-result content to text blocks** — plugin-added block types are skipped, and empty tool output crosses the wire as the literal `(no output)`.
+15 -3
View File
@@ -14,8 +14,9 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback
baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com
apiKeyEnv: DEEPSEEK_API_KEY # default; resolved per request via ctx.credentials, then the environment
# apiKey: … # literal escape hatch; prefer the reference so no secret enters this file
baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted
thinking: enabled # optional; provider default is enabled
reasoningEffort: high # optional; off | high | max — omitted ⇒ high
streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default
@@ -44,6 +45,15 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在分片间花费的时间。同一个稳定的 abort 信号会在整个调用期间传递给请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用恰好发起一次提供方请求;它把已配置策略注册为提供方元数据,再由 `dsh-llm-retry` 在持久化的 agent(智能体)步骤边界单独执行该策略。
## 动态配置(settings + credentials
连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk
- **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。
- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek')` 始终报告当前策略。
## 应用归因
每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,即用于识别 harness 的必需 `User-Agent` 基线(见 [dsh-llm § 应用归因](../llm/README.md#app-attribution-attributionts))。在该适配器契约(adapter contract)下,直接 DeepSeek 请求与 OpenAI 兼容 gateway 请求都不会获得提供方特定应用归因标头;OpenRouter 应用归因暂缓到未来的显式 OpenRouter 适配器或模式。`GenerateOptions.purpose``compaction` 的请求(dsh-compact-basic 的辅助摘要调用)还会携带 `x-deepseek-harness-compact: 1`,让宿主可以将压缩流量与会话请求分开。
@@ -62,7 +72,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
## 测试
单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high``off``max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。真实 API 覆盖位于 `tests/adapter.e2e.ts``pnpm run test:e2e`,需有 key 才会运行):V4 Flash + V4 Pro,覆盖思考启用/禁用与两种官方 effort 级别,包括思考 + 工具往返与推理回传。
单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high``off``max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider(下一请求即生效的 base-URL/密钥拾取、字面值优先、无密钥上手、最后可用快照、重试策略重注册),`tests/loader-composition.spec.ts` 则从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起完整链路,并在磁盘上编辑 `settings.yaml`/`.env`真实 API 覆盖位于 `tests/adapter.e2e.ts``pnpm run test:e2e`,需有 key 才会运行):V4 Flash + V4 Pro,覆盖思考启用/禁用与两种官方 effort 级别,包括思考 + 工具往返与推理回传,以及密钥仅存在于 credentials-local 文档中的请求
## 模型体验
@@ -96,6 +106,8 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用
## 已知限制与暂缓事项
- **settings 的 `models` 列表会整体替换组合列表**:settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。
- **`Config.apiKey` 已在 schema 中标注 `role('secret')`,但尚未在任何地方脱敏**:settings 的 `describe()` 信封原样返回值;负责对 secret 角色字段脱敏的 wireUI 层将随 settings RPC 面一起交付。
- **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。
- **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。
- **序列化会将 user 与工具结果内容展平为文本块**:会跳过插件添加的块类型,空工具输出会以字面 `(no output)` 通过协议发送。
+4
View File
@@ -27,8 +27,10 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-credentials": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -37,8 +39,10 @@
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
+70 -51
View File
@@ -1,21 +1,24 @@
/**
* `DeepSeekAdapter`: fetch + SSE against a DeepSeek (OpenAI-compatible)
* chat-completions endpoint, emitting harness StreamChunks.
* chat-completions endpoint, emitting harness StreamChunks. The adapter is
* transport-only: connection facts arrive through a thunk resolved once per
* operation and the bearer token through a per-request resolver, so the
* registering plugin owns validation, layering, and credential policy.
*
* @module dsh-llm-deepseek/adapter
*/
import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type {
GenerateOptions,
LlmModelInfo,
LlmProviderInfo,
LlmResolvedModelInfo,
ResolvedRetryPolicy,
RetryPolicyConfig,
StreamChunk,
} from '@deepseek-ai/dsh-llm'
import { idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { serializeRequest } from './serialize.ts'
import type { RequestDefaults } from './serialize.ts'
import { parseSse } from './sse.ts'
@@ -34,22 +37,46 @@ export interface DeepSeekCatalogModel {
contextWindow?: number
}
/** Constructor options for {@link DeepSeekAdapter}; the plugin's `apply` resolves them from Config + environment. */
export interface DeepSeekAdapterOptions {
/** Bearer token sent in the `authorization` header on every request. */
apiKey: string
/**
* Validated connection facts for one operation. The plugin's
* `resolveAdapterOptions` is the one explicit resolve step producing this
* shape; the adapter trusts it and re-reads it per operation, which is what
* makes a configuration change reach the next request without re-registration.
*/
export interface DeepSeekConnectionOptions {
/** Endpoint base; `/chat/completions` is appended. */
baseURL: string
/**
* Literal API key of this same resolution, when the configuration carried
* one. Travelling with the endpoint is the point: a request can never pair
* one generation's URL with another generation's secret.
*/
apiKey?: string
/** Credential reference of this same resolution, resolved per request when no literal key exists. */
apiKeyEnv: CredentialRef
/** Request defaults applied to every call (thinking mode, effort). */
defaults?: RequestDefaults
defaults: RequestDefaults
/** Positive context capacity used when the selected model has no exact value. */
defaultContextWindow?: number
/** Advisory models exposed to discovery consumers; requests remain unrestricted. */
models?: readonly DeepSeekCatalogModel[]
models: readonly DeepSeekCatalogModel[]
/** Maximum provider idle time while one stream read is outstanding. */
streamIdleTimeoutMs?: number
/** Provider-owned model-request retry policy; omission uses normal defaults. */
retryPolicy?: RetryPolicyConfig
streamIdleTimeoutMs: number
/** Provider-owned model-request retry policy, already resolved. */
retryPolicy: ResolvedRetryPolicy
}
/** Constructor options for {@link DeepSeekAdapter}: the two resolution seams the plugin owns. */
export interface DeepSeekAdapterOptions {
/** Current validated connection facts; called once per operation. */
options: () => DeepSeekConnectionOptions
/**
* Resolve the bearer token for the connection facts of one request. The
* snapshot is passed in — never re-read — so the key can only ever come
* from the same resolution as the endpoint it is sent to. Throws `LlmError`
* `MISSING_CREDENTIAL` when no key is available anywhere.
*/
resolveApiKey: (connection: DeepSeekConnectionOptions) => Promise<string>
}
/** Default maximum idle interval while an adapter stream read is outstanding. */
@@ -118,29 +145,8 @@ export function httpErrorCode(status: number, error?: WireError['error']): strin
* map to `ABORTED`; the configured per-read idle watchdog maps to `TIMEOUT`.
*/
export class DeepSeekAdapter extends LlmAdapter {
private readonly streamIdleTimeoutMs: number
private readonly retryPolicy: ResolvedRetryPolicy
constructor(private readonly options: DeepSeekAdapterOptions) {
constructor(private readonly config: DeepSeekAdapterOptions) {
super()
if (options.defaults?.thinking === 'disabled'
&& options.defaults.reasoningEffort !== undefined
&& options.defaults.reasoningEffort !== 'off') {
throw new Error('llm-deepseek: only reasoningEffort "off" can be configured when thinking is disabled')
}
if (options.defaultContextWindow !== undefined
&& (!Number.isInteger(options.defaultContextWindow) || options.defaultContextWindow <= 0)) {
throw new Error('llm-deepseek: defaultContextWindow must be a positive integer')
}
this.streamIdleTimeoutMs = options.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS
if (!Number.isFinite(this.streamIdleTimeoutMs)
|| this.streamIdleTimeoutMs <= 0
|| this.streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
throw new Error(
`llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
)
}
this.retryPolicy = resolveRetryPolicy(options.retryPolicy, 'llm-deepseek: retryPolicy')
}
override providerInfo(provider: string): LlmProviderInfo {
@@ -148,11 +154,11 @@ export class DeepSeekAdapter extends LlmAdapter {
}
override providerRetryPolicy(_provider: string): ResolvedRetryPolicy {
return this.retryPolicy
return this.config.options().retryPolicy
}
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
return Promise.resolve((this.options.models ?? []).map(model => modelInfo(provider, model)))
return Promise.resolve(this.config.options().models.map(model => modelInfo(provider, model)))
}
override resolveModel(
@@ -160,15 +166,16 @@ export class DeepSeekAdapter extends LlmAdapter {
model: string,
_signal?: AbortSignal,
): Promise<LlmResolvedModelInfo> {
const configured = this.options.models?.find(entry => entry.id === model)
const connection = this.config.options()
const configured = connection.models.find(entry => entry.id === model)
const contextWindow = configured?.contextWindow
?? this.options.defaultContextWindow
?? connection.defaultContextWindow
return Promise.resolve({
...configured === undefined
? { provider, id: model, name: model }
: modelInfo(provider, configured),
...contextWindow === undefined ? {} : { context: { contextWindow } },
...this.options.defaults?.thinking === 'disabled'
...connection.defaults.thinking === 'disabled'
? {
reasoning: {
efforts: OFF_ONLY_REASONING_EFFORTS,
@@ -178,9 +185,9 @@ export class DeepSeekAdapter extends LlmAdapter {
: {
reasoning: {
efforts: REASONING_EFFORTS,
defaultEffort: this.options.defaults?.reasoningEffort === 'off'
defaultEffort: connection.defaults.reasoningEffort === 'off'
? OFF_REASONING_EFFORT
: this.options.defaults?.reasoningEffort === 'max'
: connection.defaults.reasoningEffort === 'max'
? MAX_REASONING_EFFORT
: HIGH_REASONING_EFFORT,
},
@@ -189,12 +196,19 @@ export class DeepSeekAdapter extends LlmAdapter {
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
// One resolution per stream call: connection facts and the credential
// freeze here and hold for this whole request, so an in-flight stream
// never observes a configuration change and the next call re-resolves.
// The key resolves *from this snapshot*, so an endpoint and the secret
// sent to it can never come from different configuration generations.
const connection = this.config.options()
const apiKey = await this.config.resolveApiKey(connection)
const consumer = new AbortController()
const upstream = options.signal === undefined
? consumer.signal
: AbortSignal.any([options.signal, consumer.signal])
using watchdog = idleWatchdog(upstream, this.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE)
const iterator = this.request(options, watchdog.signal)[Symbol.asyncIterator]()
using watchdog = idleWatchdog(upstream, connection.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE)
const iterator = this.request(options, watchdog.signal, connection, apiKey)[Symbol.asyncIterator]()
let exhausted = false
try {
while (true) {
@@ -208,7 +222,7 @@ export class DeepSeekAdapter extends LlmAdapter {
} catch (error: unknown) {
if (timeoutOf(watchdog.signal, STREAM_IDLE_TIMEOUT_CODE) !== undefined) {
throw new LlmError(
`DeepSeek stream idle timeout after ${this.streamIdleTimeoutMs}ms`,
`DeepSeek stream idle timeout after ${connection.streamIdleTimeoutMs}ms`,
'TIMEOUT',
{ cause: error },
)
@@ -217,7 +231,7 @@ export class DeepSeekAdapter extends LlmAdapter {
throw new LlmError('DeepSeek request aborted by caller', 'ABORTED', { cause: error })
}
if (error instanceof LlmError) throw error
throw new LlmError(`DeepSeek API stream from ${this.options.baseURL} failed`, 'TRANSPORT', { cause: error })
throw new LlmError(`DeepSeek API stream from ${connection.baseURL} failed`, 'TRANSPORT', { cause: error })
} finally {
consumer.abort('DeepSeek stream consumer stopped')
if (!exhausted && iterator.return !== undefined) {
@@ -230,13 +244,18 @@ export class DeepSeekAdapter extends LlmAdapter {
}
}
private async * request(options: GenerateOptions, signal: AbortSignal): AsyncIterable<StreamChunk> {
const body = serializeRequest(options, this.options.defaults ?? {})
private async * request(
options: GenerateOptions,
signal: AbortSignal,
connection: DeepSeekConnectionOptions,
apiKey: string,
): AsyncIterable<StreamChunk> {
const body = serializeRequest(options, connection.defaults)
// Prepared outside the try so the TRANSPORT label below covers exactly the
// transport boundary, never a serialization failure.
const payload = JSON.stringify(body)
const headers = {
'authorization': `Bearer ${this.options.apiKey}`,
'authorization': `Bearer ${apiKey}`,
'content-type': 'application/json',
'accept': 'text/event-stream',
...attributionHeaders(),
@@ -252,7 +271,7 @@ export class DeepSeekAdapter extends LlmAdapter {
// outweighs its additional runtime dependencies.
let response: Response
try {
response = await fetch(`${this.options.baseURL}/chat/completions`, {
response = await fetch(`${connection.baseURL}/chat/completions`, {
method: 'POST',
headers,
body: payload,
@@ -266,7 +285,7 @@ export class DeepSeekAdapter extends LlmAdapter {
// lives on `cause`. Wrapping with the endpoint and chaining the cause
// lets `errorChain` render the full diagnosis at every reporting seam.
throw new LlmError(
`DeepSeek API request to ${this.options.baseURL} failed`,
`DeepSeek API request to ${connection.baseURL} failed`,
'TRANSPORT',
{ cause: error },
)
+137 -24
View File
@@ -1,41 +1,57 @@
/**
* Register a {@link DeepSeekAdapter} for the `deepseek` provider route on `ctx.llm`. Configuration uses
* Cordis schemastery; pass secrets from environment variables through `cordis.yml` with `!!js`,
* as shown in the package README, rather than reading ad hoc files.
* Register a {@link DeepSeekAdapter} for the `deepseek` provider route on
* `ctx.llm`, with connection facts resolved per request instead of frozen at
* load: the plugin layers its `cordis.yml` entry config under the optional
* `llm-deepseek` user-settings section (`ctx.settings`) and resolves the API
* key through the optional credential seam (`ctx.credentials`), so a changed
* base URL, catalog, or key reaches the very next request without restarting
* anything, while an in-flight stream keeps the facts it started with. The
* one registration-captured fact — the retry policy — re-registers the route
* in place when it changes.
* @module @deepseek-ai/dsh-llm-deepseek
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import { RetryPolicySchema } from '@deepseek-ai/dsh-llm'
import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts'
import type { DeepSeekCatalogModel } from './adapter.ts'
import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts'
export { DeepSeekAdapter } from './adapter.ts'
export type { DeepSeekAdapterOptions, DeepSeekCatalogModel } from './adapter.ts'
export type { DeepSeekAdapterOptions, DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts'
export type { RequestDefaults } from './serialize.ts'
export type * from './types.ts'
export const name = 'llm-deepseek'
export const inject = ['llm']
const NS = settingsNamespace('llm-deepseek')
const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY'
/** The single provider route this plugin owns. */
const PROVIDER = 'deepseek'
const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 256_000 },
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: 256_000 },
]
/**
* Plugin config, validated by the same-named schemastery schema. Every field
* is optional in yml: credentials/endpoint fall back to the environment (a
* missing API key fails plugin load, not the first call), omitted thinking
* mode uses the provider default, and omitted reasoning effort resolves to
* `high`.
* Plugin config, validated by the same-named schemastery schema and doubling
* as the `llm-deepseek` settings-section shape. Every field is optional in
* yml: a missing API key resolves through {@link Config.apiKeyEnv} at each
* request (a request without any key fails with `MISSING_CREDENTIAL`, not at
* plugin load), omitted thinking mode uses the provider default, and omitted
* reasoning effort resolves to `high`.
*/
export interface Config {
/** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */
/** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
apiKey?: string
/** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
apiKeyEnv?: string
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
baseURL?: string
/** Deployment thinking policy; `disabled` limits every conversation request to `off`. */
@@ -60,7 +76,8 @@ const catalogModel: z<DeepSeekCatalogModel> = z.object({
})
export const Config: z<Config> = z.object({
apiKey: z.string(),
apiKey: z.string().role('secret'),
apiKeyEnv: z.string().default(DEFAULT_API_KEY_ENV),
baseURL: z.string(),
thinking: z.union(['enabled', 'disabled']),
reasoningEffort: z.union(['off', 'high', 'max']),
@@ -73,6 +90,14 @@ export const Config: z<Config> = z.object({
/** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */
export const PUBLIC_BASE_URL = 'https://api.deepseek.com'
/**
* One resolution's complete request facts. Connection and credential facts
* are one value on purpose: a snapshot the resolver rejects keeps the whole
* previous generation, so a request can never pair a stale endpoint with a
* newer key.
*/
export type ResolvedDeepSeekOptions = DeepSeekConnectionOptions
/** Resolve, validate, and detach the advisory model catalog. */
function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): DeepSeekCatalogModel[] {
const seen = new Set<string>()
@@ -98,20 +123,36 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
})
}
export function apply(ctx: Context, config: Config): void {
/**
* The one explicit resolve step from raw config to validated connection
* facts. Programmatic construction may bypass Schemastery normalization, so
* every default and bound is re-judged here — for the composition entry at
* load (fail loud) and for each settings snapshot at its first use.
* @param config - raw plugin config or resolved settings snapshot.
* @returns validated connection facts plus the credential reference.
*/
export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
if (config.thinking === 'disabled'
&& config.reasoningEffort !== undefined
&& config.reasoningEffort !== 'off') {
throw new Error('llm-deepseek: only reasoningEffort "off" can be configured when thinking is disabled')
}
const apiKey = config.apiKey ?? process.env.DEEPSEEK_API_KEY
if (apiKey === undefined || apiKey.length === 0) {
throw new Error('llm-deepseek: an API key is required (Config.apiKey or $DEEPSEEK_API_KEY)')
if (config.defaultContextWindow !== undefined
&& (!Number.isInteger(config.defaultContextWindow) || config.defaultContextWindow <= 0)) {
throw new Error('llm-deepseek: defaultContextWindow must be a positive integer')
}
const baseURL = config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL
ctx.llm.registerAdapter(['deepseek'], new DeepSeekAdapter({
apiKey,
baseURL,
const streamIdleTimeoutMs = config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS
if (!Number.isFinite(streamIdleTimeoutMs)
|| streamIdleTimeoutMs <= 0
|| streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
throw new Error(
`llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
)
}
return {
...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {},
apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL,
defaults: {
thinking: config.thinking,
reasoningEffort: config.reasoningEffort,
@@ -120,7 +161,79 @@ export function apply(ctx: Context, config: Config): void {
? {}
: { defaultContextWindow: config.defaultContextWindow },
models: resolveModels(config.models),
streamIdleTimeoutMs: config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS,
...config.retryPolicy === undefined ? {} : { retryPolicy: config.retryPolicy },
}))
streamIdleTimeoutMs,
retryPolicy: resolveRetryPolicy(config.retryPolicy, 'llm-deepseek: retryPolicy'),
}
}
export function apply(ctx: Context, config: Config): void {
let current: () => Config = () => config
let lastRaw: Config | undefined
let lastGood: ResolvedDeepSeekOptions | undefined
const options = (): ResolvedDeepSeekOptions => {
const raw = current()
if (raw === lastRaw && lastGood !== undefined) return lastGood
try {
const next = resolveAdapterOptions(raw)
lastRaw = raw
lastGood = next
return next
} catch (error) {
// Static composition resolves before anything registers, so this branch
// only sees a live settings snapshot failing a beyond-schema bound:
// keep serving the last good facts and say so once per bad snapshot.
if (lastGood === undefined) throw error
lastRaw = raw
ctx.logger.error('llm-deepseek: keeping the last good configuration after an invalid settings section')
ctx.logger.error(error)
return lastGood
}
}
options()
const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise<string> => {
// Every credential fact comes from the caller's snapshot, so a rejected
// settings generation cannot leak its key onto the previous endpoint.
if (connection.apiKey !== undefined) return connection.apiKey
const ref = connection.apiKeyEnv
const credentials = ctx.get('credentials')
if (credentials !== undefined) {
const hit = await credentials.resolve(ref)
if (hit !== undefined) return hit.value
} else {
// Without the seam, keep the historical ambient fallback so a plain
// cordis.yml composition works from the environment alone.
const ambient = process.env[ref]
if (ambient !== undefined && ambient.length > 0) return ambient
}
throw new LlmError(
`llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials`
+ ` service (the web Models page writes it), export ${ref} in the launching environment, or — as a`
+ ' last resort — set a literal "apiKey" in the llm-deepseek settings section',
'MISSING_CREDENTIAL',
)
}
const adapter = new DeepSeekAdapter({ options, resolveApiKey })
// Route effects bind to this apply fiber via the stable `ctx` reference,
// even when a swap runs inside the scoped settings callback below.
let disposeRoute = ctx.llm.registerAdapter([PROVIDER], adapter)
let registeredPolicy = options().retryPolicy
const ensureRegistrationFacts = (): void => {
const policy = options().retryPolicy
if (deepEqualJson(policy, registeredPolicy)) return
// The registry captures the retry policy at registration, so it is the one
// fact per-request resolution cannot refresh: swap the registration in one
// synchronous section (same adapter instance, no NO_ADAPTER window).
disposeRoute()
disposeRoute = ctx.llm.registerAdapter([PROVIDER], adapter)
registeredPolicy = policy
}
installSettingsSection(ctx, NS, Config, config, {
setSource: (source) => {
current = source
},
onChange: ensureRegistrationFacts,
})
}
+33 -1
View File
@@ -1,7 +1,11 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { createUserMessage, CallId, ReasoningEffortId , createMessage } from '@deepseek-ai/dsh-llm'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import type { Config } from '@deepseek-ai/dsh-llm-deepseek'
import { assemble, type AssembledResult } from './assemble.ts'
@@ -53,6 +57,34 @@ const weatherTool: ToolSchema = {
}
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => {
it('serves a real request with the key held only by a credentials-local document', async () => {
const key = process.env.DEEPSEEK_API_KEY
if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY')
const dir = await mkdtemp(join(tmpdir(), 'dsh-e2e-credentials-'))
try {
await writeFile(join(dir, '.env'), `DEEPSEEK_API_KEY=${key}\n`, { mode: 0o600 })
// Scrub the ambient variable so only the credential seam can supply the
// key: this request proves the per-request resolution path end to end.
vi.stubEnv('DEEPSEEK_API_KEY', '')
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LlmService)
await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
await ctx.plugin(LlmDeepSeek, {})
const result = await assemble(ctx, {
model: FLASH,
messages: ask('Reply with exactly the word: pong'),
maxTokens: 50,
})
expect(result.finish.kind).toBe('stop')
expect(textOf(result).toLowerCase()).toContain('pong')
} finally {
vi.unstubAllEnvs()
await rm(dir, { recursive: true, force: true })
}
})
it('flash dynamically switches from off to high', async () => {
const ctx = await harness(FLASH, { reasoningEffort: 'off' })
const withoutThinking = await assemble(ctx,{
+80 -125
View File
@@ -1,5 +1,3 @@
import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { createUserMessage,
@@ -14,90 +12,18 @@ import LlmService, { createUserMessage,
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek'
import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
import { httpErrorCode } from '../src/adapter.ts'
import { assemble } from './assemble.ts'
/** One scripted behavior for the next request the mock server receives. */
type Behavior =
| { kind: 'sse'; events: string[]; delayMs?: number }
| { kind: 'http-error'; status: number; body: string; contentType?: string; headers?: Record<string, string> }
| { kind: 'close-early'; events: string[] }
interface MockServer {
url: string
/** Bodies of received requests, in order. */
requests: unknown[]
/** Header bags of received requests, in order (parallel to `requests`). */
headers: IncomingMessage['headers'][]
script: Behavior[]
close(): Promise<void>
}
const servers: Server[] = []
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
import type { Behavior } from './mock-server.ts'
afterEach(async () => {
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
await closeMockServers()
vi.unstubAllEnvs()
vi.useRealTimers()
})
/** Local chat-completions stand-in: replays scripted behaviors per request. */
async function mockServer(script: Behavior[]): Promise<MockServer> {
const requests: unknown[] = []
const headers: IncomingMessage['headers'][] = []
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
let body = ''
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
request.on('end', () => {
requests.push(JSON.parse(body))
headers.push(request.headers)
const behavior = script.shift()
if (!behavior) {
response.writeHead(500).end('mock script exhausted')
return
}
if (behavior.kind === 'http-error') {
response.writeHead(behavior.status, {
'content-type': behavior.contentType ?? 'application/json',
...behavior.headers,
})
response.end(behavior.body)
return
}
response.writeHead(200, { 'content-type': 'text/event-stream' })
const write = (index: number): void => {
if (index >= behavior.events.length) {
if (behavior.kind === 'sse') response.end()
else response.destroy() // close-early: drop the socket mid-stream
return
}
response.write(`data: ${behavior.events[index]}\n\n`)
setTimeout(() => { write(index + 1) }, behavior.kind === 'sse' ? behavior.delayMs ?? 0 : 5)
}
write(0)
})
})
servers.push(server)
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('no port')
return {
url: `http://127.0.0.1:${address.port}`,
requests,
headers,
script,
close: () => new Promise(resolve => server.close(() => { resolve() })),
}
}
const textEvents = [
'{"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
'{"choices":[{"delta":{"content":"hello"}}]}',
'{"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
'[DONE]',
]
async function harness(baseURL: string, config: object = {}) {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -105,6 +31,15 @@ async function harness(baseURL: string, config: object = {}) {
return ctx
}
/** Direct adapter over the plugin's real resolve step, with a static key. */
function adapterOf(config: Partial<LlmDeepSeek.Config> & { apiKey?: string } = {}): DeepSeekAdapter {
const { apiKey, ...rest } = config
return new DeepSeekAdapter({
options: () => resolveAdapterOptions(rest),
resolveApiKey: () => Promise.resolve(apiKey ?? 'k'),
})
}
describe('DeepSeekAdapter against a mock server', () => {
it('streams a text generation end to end through the assembler', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
@@ -275,11 +210,7 @@ describe('DeepSeekAdapter against a mock server', () => {
'rejects direct adapter effort %s before I/O when thinking is disabled',
async (effort) => {
const server = await mockServer([])
const adapter = new DeepSeekAdapter({
apiKey: 'test-key',
baseURL: server.url,
defaults: { thinking: 'disabled' },
})
const adapter = adapterOf({ apiKey: 'test-key', baseURL: server.url, thinking: 'disabled' })
const stream = adapter.stream({
provider: 'deepseek',
@@ -483,7 +414,7 @@ describe('DeepSeekAdapter against a mock server', () => {
})
it('throws EMPTY_RESPONSE when the response has no body', async () => {
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
const adapter = adapterOf({ baseURL: 'http://127.0.0.1:1' })
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(null, { status: 200 }),
)
@@ -538,7 +469,7 @@ describe('DeepSeekAdapter against a mock server', () => {
it('maps connection failures to TRANSPORT without losing the cause', async () => {
const cause = new TypeError('connection refused')
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(cause)
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'https://example.invalid' })
const adapter = adapterOf({ baseURL: 'https://example.invalid' })
try {
const drain = async (): Promise<void> => {
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
@@ -555,7 +486,7 @@ describe('DeepSeekAdapter against a mock server', () => {
failed.reject('offline')
return failed.promise
})
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'https://example.invalid' })
const adapter = adapterOf({ baseURL: 'https://example.invalid' })
try {
const drain = async (): Promise<void> => {
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
@@ -585,11 +516,7 @@ describe('DeepSeekAdapter against a mock server', () => {
})
return Promise.resolve(new Response(body, { status: 200 }))
})
const adapter = new DeepSeekAdapter({
apiKey: 'k',
baseURL: 'https://example.invalid',
streamIdleTimeoutMs: 100,
})
const adapter = adapterOf({ baseURL: 'https://example.invalid', streamIdleTimeoutMs: 100 })
try {
const drain = (async () => {
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
@@ -733,22 +660,15 @@ describe('plugin registration and config', () => {
)
it.each(['high', 'max'] as const)(
'rejects disabled-thinking effort %s at the direct constructor boundary',
'rejects disabled-thinking effort %s at the resolver boundary',
(reasoningEffort) => {
expect(() => new DeepSeekAdapter({
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
defaults: { thinking: 'disabled', reasoningEffort },
})).toThrow(/only reasoningEffort "off"/)
expect(() => resolveAdapterOptions({ thinking: 'disabled', reasoningEffort }))
.toThrow(/only reasoningEffort "off"/)
},
)
it('accepts disabled thinking with off at the direct constructor boundary', async () => {
const adapter = new DeepSeekAdapter({
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
defaults: { thinking: 'disabled', reasoningEffort: 'off' },
})
it('accepts disabled thinking with off at the resolver boundary', async () => {
const adapter = adapterOf({ thinking: 'disabled', reasoningEffort: 'off' })
await expect(adapter.resolveModel('deepseek', 'pass-through')).resolves.toMatchObject({
reasoning: {
efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }],
@@ -863,11 +783,8 @@ describe('plugin registration and config', () => {
it.each([0, 1.5])(
'rejects invalid adapter-wide default context capacity %s',
async (defaultContextWindow) => {
expect(() => new DeepSeekAdapter({
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
defaultContextWindow,
})).toThrow(/defaultContextWindow must be a positive integer/)
expect(() => resolveAdapterOptions({ defaultContextWindow }))
.toThrow(/defaultContextWindow must be a positive integer/)
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -889,13 +806,42 @@ describe('plugin registration and config', () => {
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
})
it('throws a clear error when no API key is available', async () => {
it('loads keyless, keeps the catalog browsable, and fails the request actionably', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmDeepSeek, {}))
.rejects.toThrow(/an API key is required/)
expect(ctx.llm.listProviders()).toEqual([])
await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' })
// First-boot onboarding: the route registers so models stay discoverable;
// only the request itself needs a key.
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2)
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
// The guidance leads with the credential store — the path that keeps the
// secret out of configuration files — and mentions a literal key last.
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s)
})
it('reads the ambient variable when no credentials seam is mounted', async () => {
// The plain cordis.yml composition: no credential provider, the key in
// the launching environment.
vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key')
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { baseURL: server.url })
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(server.headers[0]?.authorization).toBe('Bearer ambient-key')
})
it('treats an empty ambient variable as no key when no credentials seam is mounted', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' })
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
})
it('prefers explicit config over env for key and base URL', async () => {
@@ -927,23 +873,32 @@ describe('plugin registration and config', () => {
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
})
it('adapter is constructible directly for embedding', async () => {
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
it('adapter is constructible directly for embedding over the shared resolver', async () => {
const adapter = adapterOf()
expect(adapter).toBeInstanceOf(DeepSeekAdapter)
await expect(adapter.listModels('deepseek')).resolves.toEqual([])
// Direct embedding shares the plugin's one resolve step, so it advertises
// the same default catalog instead of a divergent empty one.
await expect(adapter.listModels('deepseek')).resolves.toHaveLength(2)
})
it('resolves connection facts and the credential exactly once per stream call', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const options = vi.fn(() => resolveAdapterOptions({ baseURL: server.url }))
const resolveApiKey = vi.fn(() => Promise.resolve('per-request-key'))
const adapter = new DeepSeekAdapter({ options, resolveApiKey })
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
expect(options).toHaveBeenCalledTimes(1)
expect(resolveApiKey).toHaveBeenCalledTimes(1)
expect(server.headers[0]?.authorization).toBe('Bearer per-request-key')
})
it('rejects invalid idle watchdog bounds for direct and plugin composition', async () => {
expect(() => new DeepSeekAdapter({
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
streamIdleTimeoutMs: Number.POSITIVE_INFINITY,
})).toThrow(/streamIdleTimeoutMs.*positive finite/)
expect(() => new DeepSeekAdapter({
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1,
})).toThrow(/streamIdleTimeoutMs.*no greater/)
expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: Number.POSITIVE_INFINITY }))
.toThrow(/streamIdleTimeoutMs.*positive finite/)
expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 }))
.toThrow(/streamIdleTimeoutMs.*no greater/)
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -0,0 +1,186 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import LlmService from '@deepseek-ai/dsh-llm'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { SettingsLocal } from '@deepseek-ai/dsh-settings-local'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { assemble } from './assemble.ts'
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
const NS = settingsNamespace('llm-deepseek')
const KEY_REF = credentialRef('DEEPSEEK_API_KEY')
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!()
await closeMockServers()
vi.unstubAllEnvs()
})
async function home(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-llm-dynamic-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
interface Harness {
ctx: Context
settingsFiber: { dispose(): Promise<void> }
}
/**
* Real dynamic composition: llm + settings-local + credentials-local +
* llm-deepseek over one temp harness home. `watch: false` keeps every change
* flowing through the in-process write path, which is deterministic; external
* file watching is the providers' own covered concern.
*/
async function boot(dir: string, config: object): Promise<Harness> {
const ctx = new Context()
cleanups.push(async () => {
await ctx.fiber.dispose()
})
await ctx.plugin(LlmService)
const settingsFiber = ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false })
await settingsFiber
await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
await ctx.plugin(LlmDeepSeek, config)
return { ctx, settingsFiber }
}
function prompt(ctx: Context) {
return assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
}
describe('request-level dynamic configuration', () => {
it('routes the next request with the freshly resolved base URL and credential', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()
await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=first-key\n')
const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await boot(dir, { baseURL: serverA.url })
await prompt(ctx)
expect(serverA.headers[0]?.authorization).toBe('Bearer first-key')
await ctx.settings.update(NS, { baseURL: serverB.url })
await ctx.credentials.set(KEY_REF, 'second-key')
await prompt(ctx)
// No restart, no re-registration: the next request resolved both facts.
expect(serverA.requests).toHaveLength(1)
expect(serverB.headers[0]?.authorization).toBe('Bearer second-key')
})
it('prefers a literal settings apiKey over the credential layers', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()
await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=file-key\n')
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await boot(dir, { baseURL: server.url })
await ctx.settings.update(NS, { apiKey: 'literal-key' })
await prompt(ctx)
expect(server.headers[0]?.authorization).toBe('Bearer literal-key')
})
it('starts keyless and serves the next request once the key arrives', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await boot(dir, { baseURL: server.url })
await expect(prompt(ctx)).rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
await ctx.credentials.set(KEY_REF, 'sk-arrived')
await prompt(ctx)
expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived')
})
it('advertises a live settings catalog without re-registration', async () => {
const dir = await home()
const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2)
await ctx.settings.update(NS, { models: [{ id: 'settings-model', name: 'From Settings' }] })
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
{ provider: 'deepseek', id: 'settings-model', name: 'From Settings' },
])
})
it('re-registers the route in place when the captured retry policy changes', async () => {
const dir = await home()
const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
await ctx.settings.update(NS, {
retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 } },
})
expect(ctx.llm.providerRetryPolicy('deepseek')).toEqual({
mode: 'always',
initialDelayMs: 25,
maxDelayMs: 100,
jitterRatio: 0.2,
})
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
})
it('keeps the last good options when a settings snapshot fails beyond-schema validation', async () => {
const dir = await home()
const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
// Schema-valid but resolver-invalid: duplicate catalog ids pass the array
// schema and fail the explicit resolve step.
await ctx.settings.update(NS, { models: [{ id: 'dup' }, { id: 'dup' }] })
await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2)
await ctx.settings.update(NS, { models: [{ id: 'recovered' }] })
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
{ provider: 'deepseek', id: 'recovered', name: 'recovered' },
])
})
it('sends the whole last-good snapshot when a rejected one changed both the key and the URL', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()
const good = await mockServer([{ kind: 'sse', events: textEvents }])
const rejected = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await boot(dir, { apiKey: 'good-key', baseURL: good.url })
// One snapshot moves the endpoint AND the literal key, and fails the
// resolve step beyond the schema (duplicate catalog ids).
await ctx.settings.update(NS, {
apiKey: 'rejected-key',
baseURL: rejected.url,
models: [{ id: 'dup' }, { id: 'dup' }],
})
await prompt(ctx)
// The rejected generation contributes nothing: not its endpoint, and — the
// regression this pins — not its key either.
expect(rejected.requests).toHaveLength(0)
expect(good.requests).toHaveLength(1)
expect(good.headers[0]?.authorization).toBe('Bearer good-key')
})
it('falls back to the composition entry when settings detach', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()
await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=steady-key\n')
const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx, settingsFiber } = await boot(dir, { baseURL: serverA.url })
await ctx.settings.update(NS, { baseURL: serverB.url })
await prompt(ctx)
expect(serverB.requests).toHaveLength(1)
await settingsFiber.dispose()
await prompt(ctx)
expect(serverA.requests).toHaveLength(1)
expect(serverA.headers[0]?.authorization).toBe('Bearer steady-key')
})
})
@@ -0,0 +1,174 @@
/**
* Real-composition guard for the dynamic-configuration chain: LlmService,
* settings-local, credentials-local, and llm-deepseek boot from a test-only
* cordis.yml through the actual Loader + Include path, external edits of
* settings.yaml and .env hot-publish through their providers, and the very
* next request carries the fresh base URL and credential. The same adapter
* composition without settings or credentials entries keeps entry-config
* behavior — the documented optional-inject fallback.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import LlmService from '@deepseek-ai/dsh-llm'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import CredentialsLocal from '@deepseek-ai/dsh-credentials-local'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import SettingsLocal from '@deepseek-ai/dsh-settings-local'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { assemble } from './assemble.ts'
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
const NS = settingsNamespace('llm-deepseek')
const KEY_REF = credentialRef('DEEPSEEK_API_KEY')
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
await closeMockServers()
vi.unstubAllEnvs()
})
async function loadComposition(
options: { withDynamic: boolean; baseURL: string; reuseRoot?: string },
): Promise<{ ctx: Context; settingsPath: string; envPath: string }> {
// A reused root is the restart case: the same harness home, its documents
// exactly as the previous process left them.
const fresh = options.reuseRoot === undefined
root = options.reuseRoot ?? await mkdtemp(join(tmpdir(), 'dsh-llm-composition-'))
const settingsPath = join(root, 'settings.yaml')
const envPath = join(root, '.env')
if (options.withDynamic && fresh) {
await writeFile(settingsPath, '# personal settings\n')
await writeFile(envPath, 'DEEPSEEK_API_KEY=boot-key\n')
}
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
'- id: llm',
" name: 'test-llm-service'",
...options.withDynamic
? [
'- id: settings',
" name: '@deepseek-ai/dsh-settings-local'",
' config:',
` path: ${JSON.stringify(settingsPath)}`,
' debounceMs: 10',
'- id: credentials',
" name: '@deepseek-ai/dsh-credentials-local'",
' config:',
` path: ${JSON.stringify(envPath)}`,
' debounceMs: 10',
]
: [],
'- id: llm-deepseek',
" name: '@deepseek-ai/dsh-llm-deepseek'",
' config:',
` baseURL: ${JSON.stringify(options.baseURL)}`,
...options.withDynamic ? [] : [' apiKey: entry-key'],
'',
].join('\n'))
const ctx = new Context()
context = ctx
ctx.baseUrl = pathToFileURL(root).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['test-llm-service', LlmService],
['@deepseek-ai/dsh-settings-local', SettingsLocal],
['@deepseek-ai/dsh-credentials-local', CredentialsLocal],
['@deepseek-ai/dsh-llm-deepseek', LlmDeepSeek],
])
ctx.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof ctx.loader.internal>
await ctx.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await ctx.loader.await()
return { ctx, settingsPath, envPath }
}
describe('llm-deepseek real dynamic composition', () => {
it('boots from cordis.yml and routes the next request after external settings and .env edits', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx, settingsPath, envPath } = await loadComposition({ withDynamic: true, baseURL: serverA.url })
expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual([NS])
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(serverA.headers[0]?.authorization).toBe('Bearer boot-key')
// External edits, exactly as a user or the web UI would leave them on disk.
await writeFile(settingsPath, `llm-deepseek:\n baseURL: ${serverB.url}\n`)
await vi.waitFor(() => {
expect((ctx.get('settings')!.get(NS) as { baseURL?: string }).baseURL).toBe(serverB.url)
}, { timeout: 5000 })
await writeFile(envPath, 'DEEPSEEK_API_KEY=rotated-key\n')
await vi.waitFor(async () => {
expect(await ctx.get('credentials')!.resolve(KEY_REF)).toEqual({ value: 'rotated-key', source: 'file' })
}, { timeout: 5000 })
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(serverA.requests).toHaveLength(1)
expect(serverB.headers[0]?.authorization).toBe('Bearer rotated-key')
})
it('keeps a stored key writable and rotatable across a real restart', async () => {
// No ambient DEEPSEEK_API_KEY: the shipped surfaces no longer hoist
// $DSH_HOME/.env into process.env, so a stored key must stay file-sourced.
vi.stubEnv('DEEPSEEK_API_KEY', '')
const first = await mockServer([{ kind: 'sse', events: textEvents }])
const second = await mockServer([{ kind: 'sse', events: textEvents }])
const boot = await loadComposition({ withDynamic: true, baseURL: first.url })
const home = root!
await boot.ctx.get('credentials')!.set(KEY_REF, 'stored-by-ui')
expect(await boot.ctx.get('credentials')!.describe(KEY_REF))
.toEqual({ configured: true, source: 'file', writable: true })
await assemble(boot.ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(first.headers[0]?.authorization).toBe('Bearer stored-by-ui')
await boot.ctx.fiber.dispose()
context = undefined
// Restart over the same harness home.
const restarted = await loadComposition({ withDynamic: true, baseURL: second.url, reuseRoot: home })
const credentials = restarted.ctx.get('credentials')!
// The stored key is still the provider's own writable file entry — not a
// read-only launch override, which is what hoisting it would have made it.
expect(await credentials.resolve(KEY_REF)).toEqual({ value: 'stored-by-ui', source: 'file' })
expect(await credentials.describe(KEY_REF)).toEqual({ configured: true, source: 'file', writable: true })
// Rotation still works after the restart, and the next request uses it.
await credentials.set(KEY_REF, 'rotated-after-restart')
await assemble(restarted.ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(second.headers[0]?.authorization).toBe('Bearer rotated-after-restart')
})
it('boots the same adapter without settings or credentials entries on entry config alone', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url })
expect(ctx.get('settings')).toBeUndefined()
expect(ctx.get('credentials')).toBeUndefined()
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(server.headers[0]?.authorization).toBe('Bearer entry-key')
})
})
@@ -0,0 +1,82 @@
import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
/** One scripted behavior for the next request the mock server receives. */
export type Behavior =
| { kind: 'sse'; events: string[]; delayMs?: number }
| { kind: 'http-error'; status: number; body: string; contentType?: string; headers?: Record<string, string> }
| { kind: 'close-early'; events: string[] }
export interface MockServer {
url: string
/** Bodies of received requests, in order. */
requests: unknown[]
/** Header bags of received requests, in order (parallel to `requests`). */
headers: IncomingMessage['headers'][]
script: Behavior[]
close(): Promise<void>
}
const servers: Server[] = []
/** Close every server opened since the last call; run from each spec's afterEach. */
export async function closeMockServers(): Promise<void> {
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
}
/** A minimal complete text generation, reused by request-shape assertions. */
export const textEvents = [
'{"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
'{"choices":[{"delta":{"content":"hello"}}]}',
'{"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
'[DONE]',
]
/** Local chat-completions stand-in: replays scripted behaviors per request. */
export async function mockServer(script: Behavior[]): Promise<MockServer> {
const requests: unknown[] = []
const headers: IncomingMessage['headers'][] = []
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
let body = ''
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
request.on('end', () => {
requests.push(JSON.parse(body))
headers.push(request.headers)
const behavior = script.shift()
if (!behavior) {
response.writeHead(500).end('mock script exhausted')
return
}
if (behavior.kind === 'http-error') {
response.writeHead(behavior.status, {
'content-type': behavior.contentType ?? 'application/json',
...behavior.headers,
})
response.end(behavior.body)
return
}
response.writeHead(200, { 'content-type': 'text/event-stream' })
const write = (index: number): void => {
if (index >= behavior.events.length) {
if (behavior.kind === 'sse') response.end()
else response.destroy() // close-early: drop the socket mid-stream
return
}
response.write(`data: ${behavior.events[index]}\n\n`)
setTimeout(() => { write(index + 1) }, behavior.kind === 'sse' ? behavior.delayMs ?? 0 : 5)
}
write(0)
})
})
servers.push(server)
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('no port')
return {
url: `http://127.0.0.1:${address.port}`,
requests,
headers,
script,
close: () => new Promise(resolve => server.close(() => { resolve() })),
}
}
+6
View File
@@ -20,6 +20,12 @@
{
"path": "../../llm/llm"
},
{
"path": "../../credentials/credentials"
},
{
"path": "../../settings/settings"
},
{
"path": "../../support/invariants"
},
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md
README.md: ac47cf6a21285fc887948a5a7798a9f1cb9157b0
README.zh.md: 650ec7a578549cec6bd001e15ff5be4dea86e942
README.md: 0099c9acd39cd2d471936505726d68423f351c76
README.zh.md: 7cb4f5fcbc1c7a67b77d690031cc7d553569433f
+19 -11
View File
@@ -2,21 +2,21 @@
English | [中文](README.zh.md)
Generic multi-provider adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai). One plugin instance owns an explicit list of provider profiles; every request selects a profile with `GenerateOptions.provider` and resolves `GenerateOptions.model` dynamically from pi-ai's installed catalog.
Generic multi-provider adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai). One plugin instance owns a dict of provider profiles keyed by route; every request selects a profile with `GenerateOptions.provider` and resolves `GenerateOptions.model` dynamically from pi-ai's installed catalog.
The package root exposes the Cordis plugin contract and `PiAiAdapter`; profile resolution, model construction, replay conversion, and stream conversion remain package-internal.
## Config
Configure credentials and deployment-specific transport settings per provider. Omitting `apiKey` delegates authentication to pi-ai's provider-native ambient discovery. `baseURL` overrides only the endpoint of the selected catalog model, preserving its API family and compatibility metadata, so private proxies such as `https://proxy.example.com:8443` remain supported.
Configure credentials and deployment-specific transport settings per provider, keyed by the provider route itself. Prefer `apiKeyEnv` — a credential *reference* resolved per request — over a literal `apiKey`, so no secret enters this file. Omitting **both** is what delegates authentication to pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. `baseURL` overrides only the endpoint of the selected catalog model, preserving its API family and compatibility metadata, so private proxies such as `https://proxy.example.com:8443` remain supported.
```yaml
- id: llm
name: '@deepseek-ai/dsh-llm-pi-ai'
config:
providers:
- provider: openai
apiKey: !!js process.env.OPENAI_API_KEY
openai:
apiKeyEnv: OPENAI_API_KEY
baseURL: https://proxy.example.com:8443
reasoning: high
retryPolicy:
@@ -26,22 +26,28 @@ Configure credentials and deployment-specific transport settings per provider. O
initialDelayMs: 500
maxDelayMs: 10000
jitterRatio: 0.1
- provider: anthropic
apiKey: !!js process.env.ANTHROPIC_API_KEY
anthropic:
apiKeyEnv: ANTHROPIC_API_KEY
streamIdleTimeoutMs: 300000
- provider: openrouter
apiKey: !!js process.env.OPENROUTER_API_KEY
openrouter:
apiKeyEnv: OPENROUTER_API_KEY
headers:
X-Deployment: production
```
Each provider name must exist in pi-ai's installed catalog and may appear only once in this plugin instance. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`.
Each dict key must exist in pi-ai's installed catalog; the dict shape makes duplicates unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`.
## Dynamic configuration (settings + credentials)
The adapter reads its profiles through a thunk **once per operation** instead of freezing them at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. Without a mounted settings service the entry config alone drives the adapter, unchanged.
Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A live settings snapshot naming an unknown provider (or failing any other resolver bound) keeps the last good profiles and logs the failure; the entry config itself still fails plugin load.
The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers.
The `reasoning.efforts` list is pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model therefore exposes pi-ai's `off` choice. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`.
Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name.
Supported profile fields are `apiKey`, `apiKeyEnv`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name.
The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`.
@@ -71,7 +77,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata
## Testing
Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. Real-API coverage remains key-gated under `pnpm run test:e2e`.
Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers: a settings-born route registers live and drops when the user layer resets, `apiKeyEnv` credentials rotate between requests, and an unknown-provider snapshot keeps the last good profiles. `tests/loader-composition.spec.ts` boots the dormant posture from a test-only `cordis.yml` through the actual Loader and registers its route from an on-disk `settings.yaml` edit. Real-API coverage remains key-gated under `pnpm run test:e2e`.
## Model Experience
@@ -105,6 +111,8 @@ Recorded response content appends to the next request and does not invalidate it
## Known Limitations and Deferred Work
- **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer.
- **`apiKey` is schema-tagged `role('secret')` but not yet masked anywhere** — the settings `describe()` envelope returns values verbatim; the wire/UI layer that must redact secret-role fields ships with the settings RPC surface.
- **Catalog membership is required** — custom model ids that are absent from the installed pi-ai catalog fail with `UNKNOWN_MODEL`, even when a provider profile supplies a custom endpoint.
- **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field.
- **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override.
+19 -11
View File
@@ -2,21 +2,21 @@
[English](README.md) | 中文
基于 [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) 的 harness LLM(大语言模型)seam 通用多提供方适配器。一个插件实例拥有显式提供方 profile 列表;每个请求使用 `GenerateOptions.provider` 选择 profile,并从 pi-ai 已安装 catalog 中动态解析 `GenerateOptions.model`
基于 [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) 的 harness LLM(大语言模型)seam 通用多提供方适配器。一个插件实例拥有一份以路由为键的提供方 profile 字典;每个请求使用 `GenerateOptions.provider` 选择 profile,并从 pi-ai 已安装 catalog 中动态解析 `GenerateOptions.model`
包(package)根入口导出 Cordis 插件契约与 `PiAiAdapter`;profile 解析、模型构造、回放转换和流转换保留在包内部。
## 配置
按提供方配置凭与部署特定传输设置。省略 `apiKey` 会将认证委托给 pi-ai 的提供方原生环境发现`baseURL` 只会覆盖所选 catalog 模型的端点,保留其 API 家族与兼容性元数据,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy。
按提供方配置凭与部署特定传输设置,并以提供方路由本身为键。优先使用 `apiKeyEnv`——按请求解析的凭据*引用*——而非字面 `apiKey`,让机密不进入该文件。**两者**都省略,才会把认证委托给 pi-ai 的提供方原生环境发现;已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证`baseURL` 只会覆盖所选 catalog 模型的端点,保留其 API 家族与兼容性元数据,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy。
```yaml
- id: llm
name: '@deepseek-ai/dsh-llm-pi-ai'
config:
providers:
- provider: openai
apiKey: !!js process.env.OPENAI_API_KEY
openai:
apiKeyEnv: OPENAI_API_KEY
baseURL: https://proxy.example.com:8443
reasoning: high
retryPolicy:
@@ -26,22 +26,28 @@
initialDelayMs: 500
maxDelayMs: 10000
jitterRatio: 0.1
- provider: anthropic
apiKey: !!js process.env.ANTHROPIC_API_KEY
anthropic:
apiKeyEnv: ANTHROPIC_API_KEY
streamIdleTimeoutMs: 300000
- provider: openrouter
apiKey: !!js process.env.OPENROUTER_API_KEY
openrouter:
apiKeyEnv: OPENROUTER_API_KEY
headers:
X-Deployment: production
```
每个提供方名称必须存在于 pi-ai 已安装 catalog 中,且在此插件实例中最多出现一次。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。
每个字典键都必须存在于 pi-ai 已安装 catalog 中;字典形状使重复项无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。
## 动态配置(settings + credentials
适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典,base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy,全部在下一次请求生效,无需重启。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。
凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。存活 settings 快照若点名未知提供方(或违反任何其他 resolver 约束),则保留最后可用 profile 并记录失败;entry 配置本身仍会使插件加载失败。
适配器通过 `ctx.llm.listModels(provider)` 公开每个已配置提供方已安装的 pi-ai 模型。这是从 `getModels(provider)` 派生的提供方无关 selector 元数据;请求时解析仍会执行权威 catalog 查找,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。
`reasoning.efforts` 列表是 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh``max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。因此,不具备推理(reasoning)能力的模型也会公开 pi-ai 的 `off` 选项。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`
受支持的 profile 字段是 `provider``apiKey``baseURL``headers``reasoning``thinkingBudgets``cacheRetention``transport``timeoutMs``websocketConnectTimeoutMs``streamIdleTimeoutMs``retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。
受支持的 profile 字段是 `apiKey``apiKeyEnv``baseURL``headers``reasoning``thinkingBudgets``cacheRetention``transport``timeoutMs``websocketConnectTimeoutMs``streamIdleTimeoutMs``retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。
适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries``maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`
@@ -71,7 +77,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK
## 测试
单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用只发起一个协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。真实 API 覆盖仍需 key 才会启用,并通过 `pnpm run test:e2e` 运行。
单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用只发起一个协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local providersettings 里新生的路由实时完成注册,并在用户层重置时随之移除,`apiKeyEnv` 凭据在两次请求之间轮换,点名未知提供方的快照则保留最后可用 profile。`tests/loader-composition.spec.ts` 从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起休眠姿态,并从磁盘上的一次 `settings.yaml` 编辑注册出它的路由。真实 API 覆盖仍需 key 才会启用,并通过 `pnpm run test:e2e` 运行。
## 模型体验
@@ -105,6 +111,8 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish
## 已知限制与暂缓事项
- **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。
- **`apiKey` 已在 schema 中标注 `role('secret')`,但尚未在任何地方脱敏**:settings 的 `describe()` 信封原样返回值;负责对 secret 角色字段脱敏的 wireUI 层将随 settings RPC 面一起交付。
- **必须属于 catalog**:已安装 pi-ai catalog 中不存在的自定义模型 id 会以 `UNKNOWN_MODEL` 失败,即使提供方 profile 配置了自定义端点。
- **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。
- **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。
+4
View File
@@ -27,8 +27,10 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-credentials": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -37,9 +39,11 @@
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
+26 -17
View File
@@ -30,15 +30,22 @@ import type {
StreamChunk,
} from '@deepseek-ai/dsh-llm'
import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { resolveProfiles } from './config.ts'
import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts'
import type { ResolvedPiAiProviderProfile } from './config.ts'
import { toPiContext } from './context.ts'
import { toStreamChunks } from './stream.ts'
/** Constructor options for {@link PiAiAdapter}. */
/** Constructor options for {@link PiAiAdapter}: the two resolution seams the plugin owns. */
export interface PiAiAdapterOptions {
/** Validated provider profiles this adapter instance owns. */
profiles: readonly PiAiProviderProfile[]
/** Current validated profiles by provider route; called once per operation. */
profiles: () => ReadonlyMap<string, ResolvedPiAiProviderProfile>
/**
* Resolve the credential for one already-resolved profile; called once per
* stream call and frozen for that call. `undefined` defers to pi-ai's
* provider-native ambient discovery, which the plugin allows only for a
* profile naming no credential at all; a named reference that misses throws
* `LlmError` `MISSING_CREDENTIAL` rather than falling back.
*/
resolveApiKey: (provider: string, profile: ResolvedPiAiProviderProfile) => Promise<string | undefined>
}
/**
@@ -46,7 +53,7 @@ export interface PiAiAdapterOptions {
* override, preserving the catalog's API/capability/compatibility metadata.
*/
function resolvePiModel(
profile: Omit<PiAiProviderProfile, 'retryPolicy'>,
profile: ResolvedPiAiProviderProfile,
modelId: string,
): Model<Api> {
const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model<Api> | undefined
@@ -58,12 +65,13 @@ function resolvePiModel(
/** Copy profile stream knobs into pi-ai's common option vocabulary. */
function profileOptions(
profile: Omit<PiAiProviderProfile, 'retryPolicy'>,
profile: ResolvedPiAiProviderProfile,
reasoning: ModelThinkingLevel | undefined,
apiKey: string | undefined,
): SimpleStreamOptions {
const enabledReasoning: ThinkingLevel | undefined = reasoning === 'off' ? undefined : reasoning
return {
...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey },
...apiKey === undefined ? {} : { apiKey },
...enabledReasoning === undefined ? {} : { reasoning: enabledReasoning },
...profile.thinkingBudgets === undefined ? {} : { thinkingBudgets: profile.thinkingBudgets },
...profile.cacheRetention === undefined ? {} : { cacheRetention: profile.cacheRetention },
@@ -104,19 +112,16 @@ function requestHeaders(headers: Readonly<Record<string, string>> | undefined):
* request, so models need not be registered during the Cordis lifecycle.
*/
export class PiAiAdapter extends LlmAdapter {
private readonly profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>
constructor(options: PiAiAdapterOptions) {
constructor(private readonly config: PiAiAdapterOptions) {
super()
this.profiles = new Map(resolveProfiles(options.profiles).map(profile => [profile.provider, profile]))
}
override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined {
return this.profiles.get(provider)?.retryPolicy
return this.config.profiles().get(provider)?.retryPolicy
}
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
const profile = this.profiles.get(provider)
const profile = this.config.profiles().get(provider)
if (profile === undefined) {
return Promise.reject(new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER'))
}
@@ -132,7 +137,7 @@ export class PiAiAdapter extends LlmAdapter {
model: string,
_signal?: AbortSignal,
): Promise<LlmResolvedModelInfo> {
const profile = this.profiles.get(provider)
const profile = this.config.profiles().get(provider)
if (profile === undefined) {
return Promise.reject(new LlmError(
`pi-ai adapter does not own provider "${provider}"`,
@@ -165,7 +170,10 @@ export class PiAiAdapter extends LlmAdapter {
if (options.stop !== undefined) {
throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION')
}
const profile = this.profiles.get(options.provider)
// One resolution per stream call: the profile snapshot and the credential
// freeze here and hold for this whole request, so an in-flight stream
// never observes a configuration change and the next call re-resolves.
const profile = this.config.profiles().get(options.provider)
if (profile === undefined) {
throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER')
}
@@ -174,6 +182,7 @@ export class PiAiAdapter extends LlmAdapter {
model,
options.reasoningEffort ?? profile.reasoning,
)
const apiKey = await this.config.resolveApiKey(options.provider, profile)
const consumer = new AbortController()
const upstream = options.signal === undefined
@@ -184,7 +193,7 @@ export class PiAiAdapter extends LlmAdapter {
try {
const events = streamSimple(model, toPiContext(options), {
...profileOptions(profile, reasoning),
...profileOptions(profile, reasoning, apiKey),
...options.temperature === undefined ? {} : { temperature: options.temperature },
...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens },
...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) },
+57 -35
View File
@@ -1,5 +1,7 @@
/**
* Configuration schema and provider-profile validation for the pi-ai adapter.
* Profiles are a dict keyed by provider route, so the composition base and a
* user-settings layer merge per provider and the route set is structural.
*
* @module dsh-llm-pi-ai/config
*/
@@ -7,6 +9,8 @@
import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
import type { CacheRetention, ModelThinkingLevel, ThinkingBudgets, Transport } from '@earendil-works/pi-ai'
import z from 'schemastery'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
@@ -14,12 +18,12 @@ import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-ll
/** Default maximum idle interval while an adapter stream read is outstanding. */
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000
/** Configuration for one pi-ai provider route. */
/** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */
export interface PiAiProviderProfile {
/** pi-ai provider catalog name and Harness route key. */
provider: string
/** Provider credential; when absent pi-ai uses its provider-native ambient discovery. */
/** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */
apiKey?: string
/** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */
apiKeyEnv?: string
/** Override the selected catalog model's endpoint without changing its protocol metadata. */
baseURL?: string
/** Provider request headers; Harness attribution wins reserved names. */
@@ -42,18 +46,26 @@ export interface PiAiProviderProfile {
retryPolicy?: RetryPolicyConfig
}
/** Validated profile with every adapter-owned default resolved. */
export interface ResolvedPiAiProviderProfile extends Omit<PiAiProviderProfile, 'retryPolicy'> {
/** Validated profile with its route stamped and every adapter-owned default resolved. */
export interface ResolvedPiAiProviderProfile extends Omit<PiAiProviderProfile, 'apiKeyEnv' | 'retryPolicy'> {
/** pi-ai provider catalog name and Harness route key (the configuration dict key). */
provider: string
/** Validated credential reference, when one is configured. */
apiKeyEnv?: CredentialRef
/** Positive finite provider-idle interval after defaulting. */
streamIdleTimeoutMs: number
/** Immutable retry policy captured with this provider route. */
retryPolicy: ResolvedRetryPolicy
}
/** Plugin configuration: the non-empty provider profiles this instance owns. */
/** Plugin configuration: the provider routes this instance owns. */
export interface Config {
/** Non-empty set of pi-ai provider routes this adapter instance owns. */
providers: PiAiProviderProfile[]
/**
* pi-ai provider routes, keyed by provider. An empty (or omitted) dict is
* the dormant settings-driven posture: the adapter mounts with no routes
* and registers them the moment a settings section supplies profiles.
*/
providers?: Record<string, PiAiProviderProfile>
}
const thinkingBudgets = z.object({
@@ -64,8 +76,8 @@ const thinkingBudgets = z.object({
})
const profile = z.object({
provider: z.string().required(),
apiKey: z.string(),
apiKey: z.string().role('secret'),
apiKeyEnv: z.string(),
baseURL: z.string(),
headers: z.dict(z.string()),
reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
@@ -80,54 +92,64 @@ const profile = z.object({
/** Runtime schema for {@link Config}. */
export const Config: z<Config> = z.object({
providers: z.array(profile).required(),
providers: z.dict(profile).default({}),
})
/**
* Validate profiles against the installed pi-ai catalog and return a detached
* shallow copy suitable for adapter construction.
* @param profiles - configured provider profiles.
* route-keyed map suitable for per-request reads. This is the one explicit
* resolve step, so an omitted dict resolves to the empty (dormant) route set
* here rather than through a hidden fallback.
* @param providers - configured provider profiles keyed by route.
* @returns validated profiles in configuration order.
*/
export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): ResolvedPiAiProviderProfile[] {
if (profiles.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile')
export function resolveProfiles(
providers: Readonly<Record<string, PiAiProviderProfile>> | undefined,
): Map<string, ResolvedPiAiProviderProfile> {
if (Array.isArray(providers)) {
throw new Error('llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles')
}
const entries = Object.entries(providers ?? {})
const supported = new Set<string>(getBuiltinProviders())
const seen = new Set<string>()
return profiles.map((source) => {
const resolved = new Map<string, ResolvedPiAiProviderProfile>()
for (const [provider, source] of entries) {
const legacy = source as PiAiProviderProfile & {
provider?: unknown
maxRetries?: unknown
maxRetryDelayMs?: unknown
}
if ('provider' in legacy) {
throw new Error('llm-pi-ai: the profile "provider" field moved to the providers dict key')
}
if ('maxRetries' in legacy || 'maxRetryDelayMs' in legacy) {
throw new Error('llm-pi-ai: maxRetries and maxRetryDelayMs were removed; compose agent recovery with dsh-llm-retry')
}
if (source.provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty')
if (!supported.has(source.provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${source.provider}"`)
if (seen.has(source.provider)) throw new Error(`llm-pi-ai: duplicate provider profile "${source.provider}"`)
if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty')
if (!supported.has(provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${provider}"`)
if (source.apiKey !== undefined && source.apiKey.trim().length === 0) {
throw new Error(`llm-pi-ai: provider "${source.provider}" has an empty apiKey; omit it to use ambient authentication`)
throw new Error(`llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication`)
}
if (source.baseURL !== undefined && source.baseURL.length === 0) {
throw new Error(`llm-pi-ai: provider "${source.provider}" has an empty baseURL`)
throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`)
}
const streamIdleTimeoutMs = source.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS
if (!Number.isFinite(streamIdleTimeoutMs)
|| streamIdleTimeoutMs <= 0
|| streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
throw new Error(
`llm-pi-ai: provider "${source.provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
`llm-pi-ai: provider "${provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
)
}
seen.add(source.provider)
return {
...source,
const { apiKeyEnv, retryPolicy, ...rest } = source
resolved.set(provider, {
...rest,
provider,
...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) },
streamIdleTimeoutMs,
retryPolicy: resolveRetryPolicy(
source.retryPolicy,
`llm-pi-ai: provider "${source.provider}" retryPolicy`,
),
...source.headers === undefined ? {} : { headers: { ...source.headers } },
...source.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...source.thinkingBudgets } },
}
})
retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`),
...rest.headers === undefined ? {} : { headers: { ...rest.headers } },
...rest.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } },
})
}
return resolved
}
+121 -14
View File
@@ -1,22 +1,27 @@
/**
* Generic pi-ai-backed LLM adapter plugin. One plugin instance registers an
* explicit set of provider profiles; requests select a profile by provider and
* resolve the model dynamically from pi-ai's installed catalog.
* Generic pi-ai-backed LLM adapter plugin. One plugin instance owns a dict of
* provider routes; requests select a profile by provider and resolve the
* model dynamically from pi-ai's installed catalog. Profile facts resolve per
* request over the optional `llm-pi-ai` user-settings section and the
* optional credential seam, so a changed key, endpoint, or knob reaches the
* next request without a restart; a changed *route set* (or a route's
* registration-captured retry policy) re-registers the same adapter instance
* in place.
*
* ```yaml
* - id: llm
* name: '@deepseek-ai/dsh-llm-pi-ai'
* config:
* providers:
* - provider: openai
* apiKey: !!js process.env.OPENAI_API_KEY
* openai:
* apiKeyEnv: OPENAI_API_KEY
* retryPolicy:
* mode: normal
* maxRetries: 2
* - provider: anthropic
* apiKey: !!js process.env.ANTHROPIC_API_KEY
* - provider: openrouter
* apiKey: !!js process.env.OPENROUTER_API_KEY
* anthropic:
* apiKeyEnv: ANTHROPIC_API_KEY
* openrouter:
* apiKeyEnv: OPENROUTER_API_KEY
* baseURL: https://proxy.example.com/v1
* ```
*
@@ -24,21 +29,123 @@
*/
import type { Context } from 'cordis'
import type {} from '@deepseek-ai/dsh-llm'
import { LlmError } from '@deepseek-ai/dsh-llm'
import type { AdapterRegistrationHandle } from '@deepseek-ai/dsh-llm'
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { PiAiAdapter } from './adapter.ts'
import { Config, resolveProfiles } from './config.ts'
import type { ResolvedPiAiProviderProfile } from './config.ts'
export { PiAiAdapter } from './adapter.ts'
export type { PiAiAdapterOptions } from './adapter.ts'
export { Config } from './config.ts'
export type { PiAiProviderProfile } from './config.ts'
export type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts'
export const name = 'llm-pi-ai'
export const inject = ['llm']
const NS = settingsNamespace('llm-pi-ai')
/**
* The registry captures these per route; a change here must re-register.
* Sorted by provider so a settings document that merely reorders its keys is
* not mistaken for a route change.
*/
function registrationFacts(profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>): unknown {
return [...profiles.entries()]
.map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy }))
.sort((left, right) => left.provider.localeCompare(right.provider))
}
/** Register one generic pi-ai adapter for all configured provider routes. */
export function apply(ctx: Context, config: Config): void {
const profiles = resolveProfiles(config.providers)
const adapter = new PiAiAdapter({ profiles: config.providers })
ctx.llm.registerAdapter(profiles.map(entry => entry.provider), adapter)
let current: () => Config = () => config
let lastRaw: Config | undefined
let lastGood: ReadonlyMap<string, ResolvedPiAiProviderProfile> | undefined
const profiles = (): ReadonlyMap<string, ResolvedPiAiProviderProfile> => {
const raw = current()
if (raw === lastRaw && lastGood !== undefined) return lastGood
try {
const next = resolveProfiles(raw.providers)
lastRaw = raw
lastGood = next
return next
} catch (error) {
// Static composition resolves before anything registers, so this branch
// only sees a live settings snapshot failing catalog or bound checks:
// keep serving the last good profiles and say so once per bad snapshot.
if (lastGood === undefined) throw error
lastRaw = raw
ctx.logger.error('llm-pi-ai: keeping the last good profiles after an invalid settings section')
ctx.logger.error(error)
return lastGood
}
}
profiles()
const resolveApiKey = async (
provider: string,
profile: ResolvedPiAiProviderProfile,
): Promise<string | undefined> => {
if (profile.apiKey !== undefined) return profile.apiKey
const ref = profile.apiKeyEnv
// Only a profile that names no credential at all defers to pi-ai's
// provider-native discovery. Once one is named, a miss must fail loud:
// handing pi-ai `undefined` would let it pick up an unrelated ambient key
// (OPENAI_API_KEY and friends), billing another tenant for a request the
// deployment meant to authenticate differently.
if (ref === undefined) return undefined
const credentials = ctx.get('credentials')
const hit = credentials !== undefined
? (await credentials.resolve(ref))?.value
// Without the seam, read exactly the named variable so a plain
// cordis.yml composition works from the environment alone.
: process.env[ref]
if (hit !== undefined && hit.length > 0) return hit
throw new LlmError(
`llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not`
+ ` set — store ${ref} through the credentials service (the web Models page writes it) or export it,`
+ ' and remove apiKeyEnv only if this provider should authenticate from pi-ai\'s own environment discovery',
'MISSING_CREDENTIAL',
)
}
const adapter = new PiAiAdapter({ profiles, resolveApiKey })
// Route effects bind to this apply fiber via the stable `ctx` reference,
// even when a swap runs inside the scoped settings callback below. A bare
// mount (zero routes) is the dormant posture: nothing registers until a
// settings section supplies profiles, and routes drop when it empties.
let registration: AdapterRegistrationHandle | undefined
let registeredFacts: unknown
const ensureRegistrationFacts = (): void => {
const facts = registrationFacts(profiles())
if (deepEqualJson(facts, registeredFacts)) return
// The registry captures the route set and each route's retry policy at
// registration, so a change to either must re-register. The swap is
// atomic (same adapter instance, validated before anything moves): a
// conflicting route leaves the previous routes serving requests, and
// `registeredFacts` only advances once the registry actually holds the
// new set — so returning to a working configuration always re-applies.
const routes = [...profiles().keys()]
if (registration === undefined) {
// Dormant bare mount: nothing is registered until a section supplies
// profiles, and an empty section keeps it that way.
if (routes.length === 0) {
registeredFacts = facts
return
}
registration = ctx.llm.registerAdapter(routes, adapter)
} else {
registration.replace(routes)
}
registeredFacts = facts
}
ensureRegistrationFacts()
installSettingsSection(ctx, NS, Config, config, {
setSource: (source) => {
current = source
},
onChange: ensureRegistrationFacts,
})
}
+7 -6
View File
@@ -23,12 +23,13 @@ async function harness(_model: string, config: Partial<PiAiProviderProfile> = {}
contexts.push(ctx)
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, {
providers: [{
provider: 'deepseek',
...process.env.DEEPSEEK_API_KEY === undefined ? {} : { apiKey: process.env.DEEPSEEK_API_KEY },
...process.env.DEEPSEEK_BASE_URL === undefined ? {} : { baseURL: process.env.DEEPSEEK_BASE_URL },
...config,
}],
providers: {
deepseek: {
...process.env.DEEPSEEK_API_KEY === undefined ? {} : { apiKey: process.env.DEEPSEEK_API_KEY },
...process.env.DEEPSEEK_BASE_URL === undefined ? {} : { baseURL: process.env.DEEPSEEK_BASE_URL },
...config,
},
},
})
return ctx
}
+85 -126
View File
@@ -1,5 +1,3 @@
import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, ReasoningEffortId, userAgent } from '@deepseek-ai/dsh-llm'
@@ -9,94 +7,30 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
import { resolveProfiles } from '../src/config.ts'
import { assemble } from './assemble.ts'
interface MockServer {
url: string
paths: string[]
requests: unknown[]
headers: IncomingMessage['headers'][]
readonly closedResponses: number
responseClosed: Promise<void>
}
const servers: Server[] = []
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
afterEach(async () => {
vi.unstubAllEnvs()
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
await closeMockServers()
})
async function mockServer(script: {
status?: number
events?: string[]
body?: string
delayMs?: number
headers?: Record<string, string>
}[]): Promise<MockServer> {
const paths: string[] = []
const requests: unknown[] = []
const headers: IncomingMessage['headers'][] = []
let closedResponses = 0
const responseClosed = Promise.withResolvers<undefined>()
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
response.on('close', () => {
closedResponses += 1
responseClosed.resolve(undefined)
})
let body = ''
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
request.on('end', () => {
paths.push(request.url ?? '')
requests.push(body.length === 0 ? undefined : JSON.parse(body))
headers.push(request.headers)
const behavior = script.shift() ?? { status: 500, body: 'script exhausted' }
if (behavior.status !== undefined && behavior.status !== 200) {
response.writeHead(behavior.status, { 'content-type': 'application/json', ...behavior.headers })
response.end(behavior.body ?? '{}')
return
}
response.writeHead(200, { 'content-type': 'text/event-stream' })
let index = 0
const writeNext = (): void => {
const event = behavior.events?.[index++]
if (event === undefined) { response.end(); return }
response.write(`data: ${event}\n\n`)
if (behavior.delayMs === undefined) writeNext()
else setTimeout(writeNext, behavior.delayMs)
}
writeNext()
})
})
servers.push(server)
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('no port')
return {
url: `http://127.0.0.1:${address.port}`,
paths,
requests,
headers,
responseClosed: responseClosed.promise,
get closedResponses() { return closedResponses },
}
}
const textEvents = [
'{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}',
'{"choices":[{"delta":{"content":"hello"},"index":0,"finish_reason":null}]}',
'{"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
'[DONE]',
]
async function harness(baseURL: string, overrides: Record<string, unknown> = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, {
providers: [{ provider: 'deepseek', apiKey: 'test-key', baseURL, ...overrides }],
providers: { deepseek: { apiKey: 'test-key', baseURL, ...overrides } },
})
return ctx
}
/** Direct adapter over the real profile resolver, with literal-key resolution. */
function adapterOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>): PiAiAdapter {
return new PiAiAdapter({
profiles: () => resolveProfiles(providers),
resolveApiKey: (_provider, profile) => Promise.resolve(profile.apiKey),
})
}
describe('PiAiAdapter provider routing', () => {
it('resolves a catalog model dynamically and uses a private endpoint', async () => {
const server = await mockServer([{ events: textEvents }])
@@ -182,8 +116,8 @@ describe('PiAiAdapter provider routing', () => {
const server = await mockServer([{ events: textEvents }])
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['deepseek'], new PiAiAdapter({
profiles: [{ provider: 'deepseek', apiKey: 'test-key', baseURL: server.url }],
ctx.llm.registerAdapter(['deepseek'], adapterOf({
deepseek: { apiKey: 'test-key', baseURL: server.url },
}))
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
@@ -212,7 +146,7 @@ describe('PiAiAdapter provider routing', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, {
providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }],
providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } },
})
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
expect(result.finish.kind).toBe('error')
@@ -232,7 +166,7 @@ describe('PiAiAdapter provider routing', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, {
providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }],
providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } },
})
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
@@ -246,12 +180,13 @@ describe('PiAiAdapter provider routing', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, {
providers: [{
provider: 'openai',
apiKey: 'test-key',
baseURL: `${server.url}/api/projects/openai/openai/v1`,
headers: { 'api-key': 'test-key', Authorization: '' },
}],
providers: {
openai: {
apiKey: 'test-key',
baseURL: `${server.url}/api/projects/openai/openai/v1`,
headers: { 'api-key': 'test-key', Authorization: '' },
},
},
})
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-5.5', messages: [] })
expect(result.finish.kind).toBe('error')
@@ -333,16 +268,15 @@ describe('provider profile lifecycle', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
const fiber = await ctx.plugin(LlmPiAi, {
providers: [
{
provider: 'openai',
providers: {
openai: {
retryPolicy: {
mode: 'always',
backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 },
},
},
{ provider: 'anthropic' },
],
anthropic: {},
},
})
expect(ctx.llm.listProviders()).toEqual([
{ id: 'openai', name: 'openai' },
@@ -365,7 +299,7 @@ describe('provider profile lifecycle', () => {
it('exposes the installed pi-ai model catalog through provider-neutral metadata', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, { providers: [{ provider: 'openai' }] })
await ctx.plugin(LlmPiAi, { providers: { openai: {} } })
const models = await ctx.llm.listModels('openai')
expect(models.find(model => model.id === 'gpt-4.1')).toEqual({
provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1',
@@ -379,7 +313,7 @@ describe('provider profile lifecycle', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, {
providers: [{ provider: 'deepseek' }, { provider: 'openai' }],
providers: { deepseek: {}, openai: {} },
})
await expect(ctx.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
@@ -413,7 +347,7 @@ describe('provider profile lifecycle', () => {
const supported = new Context()
await supported.plugin(LlmService)
await supported.plugin(LlmPiAi, {
providers: [{ provider: 'deepseek', reasoning: 'max' }],
providers: { deepseek: { reasoning: 'max' } },
})
await expect(supported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
.resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('max') } })
@@ -421,7 +355,7 @@ describe('provider profile lifecycle', () => {
const unsupported = new Context()
await unsupported.plugin(LlmService)
await unsupported.plugin(LlmPiAi, {
providers: [{ provider: 'deepseek', reasoning: 'medium' }],
providers: { deepseek: { reasoning: 'medium' } },
})
await expect(unsupported.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
.rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
@@ -429,7 +363,7 @@ describe('provider profile lifecycle', () => {
const disabled = new Context()
await disabled.plugin(LlmService)
await disabled.plugin(LlmPiAi, {
providers: [{ provider: 'deepseek', reasoning: 'off' }],
providers: { deepseek: { reasoning: 'off' } },
})
await expect(disabled.llm.resolveModelInfo('deepseek', 'deepseek-v4-flash'))
.resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('off') } })
@@ -443,24 +377,53 @@ describe('provider profile lifecycle', () => {
expect(server.headers[0]?.authorization).toBe('Bearer ambient-key')
})
it('validates empty, duplicate, unknown, and explicitly blank profiles', () => {
expect(() => resolveProfiles([])).toThrow(/at least one/)
expect(() => resolveProfiles([{ provider: '' }])).toThrow(/non-empty/)
expect(() => resolveProfiles([{ provider: 'not-real' }])).toThrow(/unknown/)
expect(() => resolveProfiles([{ provider: 'openai' }, { provider: 'openai' }])).toThrow(/duplicate/)
expect(() => resolveProfiles([{ provider: 'openai', apiKey: '' }])).toThrow(/empty apiKey/)
expect(() => resolveProfiles([{ provider: 'openai', apiKey: ' ' }])).toThrow(/empty apiKey/)
expect(() => resolveProfiles([{ provider: 'openai', baseURL: '' }])).toThrow(/empty baseURL/)
it('falls back to the ambient environment for apiKeyEnv without the credentials seam', async () => {
vi.stubEnv('PI_CUSTOM_REF_KEY', 'custom-ref-key')
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(server.url, { apiKey: undefined, apiKeyEnv: 'PI_CUSTOM_REF_KEY' })
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(server.headers[0]?.authorization).toBe('Bearer custom-ref-key')
})
it('fails a named-but-missing apiKeyEnv instead of using another ambient key', async () => {
// The exact confusion this guards: the named reference is empty while an
// unrelated provider key sits in the environment. Deferring to pi-ai's own
// discovery here would authenticate as another tenant.
vi.stubEnv('PI_CUSTOM_REF_KEY', '')
vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key')
const server = await mockServer([{ events: textEvents }])
const ctx = await harness(server.url, { apiKey: undefined, apiKeyEnv: 'PI_CUSTOM_REF_KEY' })
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/provider route "deepseek".*PI_CUSTOM_REF_KEY/s)
expect(server.requests).toHaveLength(0)
})
it('validates empty, unknown, legacy-shaped, and explicitly blank profiles', () => {
// Empty and omitted dicts are the dormant zero-route posture, not errors.
expect(resolveProfiles({}).size).toBe(0)
expect(resolveProfiles(undefined).size).toBe(0)
expect(() => resolveProfiles({ '': {} })).toThrow(/non-empty/)
expect(() => resolveProfiles({ 'not-real': {} })).toThrow(/unknown/)
// The pre-release array shape and its per-profile provider field fail
// loud with migration directions instead of half-working.
expect(() => resolveProfiles([{ provider: 'openai' }] as never)).toThrow(/dict keyed by provider/)
expect(() => resolveProfiles({ openai: { provider: 'openai' } as never })).toThrow(/moved to the providers dict key/)
expect(() => resolveProfiles({ openai: { apiKey: '' } })).toThrow(/empty apiKey/)
expect(() => resolveProfiles({ openai: { apiKey: ' ' } })).toThrow(/empty apiKey/)
expect(() => resolveProfiles({ openai: { baseURL: '' } })).toThrow(/empty baseURL/)
expect(() => resolveProfiles({ openai: { apiKeyEnv: 'not-a-var!' } })).toThrow(/must match/)
})
it.each(['maxRetries', 'maxRetryDelayMs'] as const)(
'rejects removed profile field %s instead of silently restoring hidden SDK retries',
async (field) => {
const legacy = { provider: 'openai', [field]: 2 }
expect(() => resolveProfiles([legacy as never])).toThrow(/removed.*agent recovery/i)
const legacy = { [field]: 2 }
expect(() => resolveProfiles({ openai: legacy })).toThrow(/removed.*agent recovery/i)
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmPiAi, { providers: [legacy as never] }))
await expect(ctx.plugin(LlmPiAi, { providers: { openai: legacy } }))
.rejects.toThrow(/removed.*agent recovery/i)
},
)
@@ -476,30 +439,26 @@ describe('provider profile lifecycle', () => {
for (const entry of invalid) {
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmPiAi, { providers: [{ provider: 'openai', ...entry }] }))
await expect(ctx.plugin(LlmPiAi, { providers: { openai: { ...entry } } }))
.rejects.toThrow()
}
})
it('rejects invalid nested retryPolicy at the provider-profile boundary', async () => {
expect(() => resolveProfiles([{
provider: 'openai',
retryPolicy: { mode: 'always', backoff: { jitterRatio: -1 } },
}])).toThrow(/retryPolicy\.backoff\.jitterRatio/)
expect(() => resolveProfiles({
openai: { retryPolicy: { mode: 'always', backoff: { jitterRatio: -1 } } },
})).toThrow(/retryPolicy\.backoff\.jitterRatio/)
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmPiAi, {
providers: [{
provider: 'openai',
retryPolicy: { mode: 'normal', maxRetries: -1 },
}],
providers: { openai: { retryPolicy: { mode: 'normal', maxRetries: -1 } } },
})).rejects.toThrow(/retryPolicy/)
expect(ctx.llm.listProviders()).toEqual([])
})
it('constructs the adapter directly and rejects routes it does not own', async () => {
const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai' }] })
const adapter = adapterOf({ openai: {} })
await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' })
await expect(adapter.resolveModel('anthropic', 'claude-sonnet-4'))
.rejects.toMatchObject({ code: 'NO_ADAPTER' })
@@ -511,12 +470,12 @@ describe('provider profile lifecycle', () => {
expect(new LlmError('x', 'X')).toBeInstanceOf(Error)
})
it('validates direct-constructor profiles at the embedding boundary', () => {
expect(() => new PiAiAdapter({
profiles: [{ provider: 'openai', streamIdleTimeoutMs: 0 }],
it('validates profiles at the shared resolver boundary', () => {
expect(() => resolveProfiles({
openai: { streamIdleTimeoutMs: 0 },
})).toThrow(/streamIdleTimeoutMs.*positive finite/)
expect(() => new PiAiAdapter({
profiles: [{ provider: 'openai', streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 }],
expect(() => resolveProfiles({
openai: { streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 },
})).toThrow(/streamIdleTimeoutMs.*no greater/)
})
})
@@ -527,7 +486,7 @@ describe('abort wiring', () => {
const message = Object.defineProperty({}, 'role', {
get() { throw original },
})
const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] })
const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } })
const drain = async (): Promise<void> => {
for await (const _chunk of adapter.stream({
provider: 'deepseek',
@@ -548,7 +507,7 @@ describe('abort wiring', () => {
throw original
},
})
const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] })
const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } })
const drain = async (): Promise<void> => {
for await (const _chunk of adapter.stream({
provider: 'deepseek',
@@ -562,7 +521,7 @@ describe('abort wiring', () => {
})
it('resolves catalog endpoints without an override before honoring pre-abort', async () => {
const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] })
const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } })
const controller = new AbortController()
controller.abort('already stopped')
const chunks = []
@@ -0,0 +1,189 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import { SettingsLocal } from '@deepseek-ai/dsh-settings-local'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import { assemble } from './assemble.ts'
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
const NS = settingsNamespace('llm-pi-ai')
/** Minimal foreign adapter: only needs to own a route the pi-ai plugin then wants. */
class StubAdapter extends LlmAdapter {
override async * stream(): AsyncIterable<never> {
throw new Error('stub adapter must never stream')
}
}
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!()
await closeMockServers()
vi.unstubAllEnvs()
})
async function home(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-dynamic-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
/** Real dynamic composition mirroring the deepseek twin's harness. */
async function boot(dir: string, config: LlmPiAi.Config): Promise<Context> {
const ctx = new Context()
cleanups.push(async () => {
await ctx.fiber.dispose()
})
await ctx.plugin(LlmService)
await ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false })
await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
await ctx.plugin(LlmPiAi, config)
return ctx
}
describe('request-level dynamic profiles', () => {
it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => {
vi.stubEnv('PI_DYNAMIC_KEY', '')
const dir = await home()
await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-from-settings\n')
const server = await mockServer([{ events: textEvents }])
// The exact product posture: `- id: llm-pi-ai` with no config at all.
const ctx = await boot(dir, {})
expect(ctx.llm.listProviders()).toEqual([])
await ctx.settings.update(NS, {
providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } },
})
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek'])
await expect(ctx.llm.listModels('deepseek')).resolves.not.toHaveLength(0)
const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
expect(server.headers[0]?.authorization).toBe('Bearer pk-from-settings')
// Emptying the user layer returns the adapter to its dormant state.
await ctx.settings.replace(NS, {})
expect(ctx.llm.listProviders()).toEqual([])
})
it('adds a provider route from settings and drops it when the user layer resets', async () => {
const dir = await home()
const server = await mockServer([{ events: textEvents }])
const ctx = await boot(dir, {
providers: { openai: { apiKey: 'k', baseURL: 'http://127.0.0.1:1/v1' } },
})
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
await ctx.settings.update(NS, {
providers: { deepseek: { apiKey: 'live-key', baseURL: server.url } },
})
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai', 'deepseek'])
const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
expect(server.headers[0]?.authorization).toBe('Bearer live-key')
// Reset the user layer: the settings-born route unregisters, the
// composition route stays.
await ctx.settings.replace(NS, {})
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
await expect(assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] }))
.rejects.toMatchObject({ code: 'NO_ADAPTER' })
})
it('rotates the per-request credential referenced by apiKeyEnv', async () => {
vi.stubEnv('PI_DYNAMIC_KEY', '')
const dir = await home()
await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-one\n')
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
const ctx = await boot(dir, {
providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } },
})
await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
expect(server.headers[0]?.authorization).toBe('Bearer pk-one')
await ctx.credentials.set(credentialRef('PI_DYNAMIC_KEY'), 'pk-two')
await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
expect(server.headers[1]?.authorization).toBe('Bearer pk-two')
})
it('re-registers routes in place when a captured retry policy changes', async () => {
const dir = await home()
const ctx = await boot(dir, { providers: { openai: {} } })
await ctx.settings.update(NS, {
providers: {
openai: {
retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 } },
},
},
})
expect(ctx.llm.providerRetryPolicy('openai')).toEqual({
mode: 'always',
initialDelayMs: 25,
maxDelayMs: 100,
jitterRatio: 0.2,
})
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
})
it('keeps the last good profiles when a settings snapshot names an unknown provider', async () => {
const dir = await home()
const ctx = await boot(dir, { providers: { openai: {} } })
// Schema-valid but catalog-invalid: the resolver rejects it and the
// last good route set keeps serving.
await ctx.settings.update(NS, { providers: { 'not-a-real-provider': {} } })
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
})
it('keeps serving its routes when a settings-born route collides with another adapter', async () => {
const dir = await home()
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
const ctx = await boot(dir, { providers: { openai: { apiKey: 'pk', baseURL: `${server.url}/v1` } } })
// Another adapter owns `anthropic`; the registry must refuse to hand it over.
ctx.llm.registerAdapter(['anthropic'], new StubAdapter())
await ctx.settings.update(NS, {
providers: {
openai: { apiKey: 'pk', baseURL: `${server.url}/v1` },
anthropic: { apiKey: 'other' },
},
})
// The conflicting swap was refused whole: the previous route set still
// owns openai (an eager dispose would have dropped it), and anthropic
// still belongs to its original adapter.
expect(ctx.llm.listProviders().map(provider => provider.id).sort()).toEqual(['anthropic', 'openai'])
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
expect(result.finish.kind).toBe('error')
expect(server.paths).toEqual(['/v1/responses'])
// Reverting to the working configuration re-applies, even though its
// facts equal the ones the registry already holds.
await ctx.settings.replace(NS, {})
expect(ctx.llm.listProviders().map(provider => provider.id).sort()).toEqual(['anthropic', 'openai'])
await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
expect(server.paths).toEqual(['/v1/responses', '/v1/responses'])
})
it('ignores a settings document that merely reorders its provider keys', async () => {
const dir = await home()
const ctx = await boot(dir, { providers: { openai: {}, anthropic: {} } })
const before = ctx.llm.listProviders().map(provider => provider.id)
// Same routes, different YAML key order: nothing about the registration
// changed, so no swap should happen at all.
await ctx.settings.update(NS, { providers: { anthropic: {}, openai: {} } })
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(before)
})
})
@@ -0,0 +1,116 @@
/**
* Real-composition guard for the dormant pi-ai posture: LlmService,
* settings-local, credentials-local, and a bare `llm-pi-ai` row boot from a
* test-only cordis.yml through the actual Loader + Include path, an external
* edit of settings.yaml registers the route live, and the next request
* carries the credential the .env supplies. A hand-mounted `ctx.plugin` cannot
* catch Loader export-shape failures, which is why the twin adapter has the
* same guard.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import LlmService from '@deepseek-ai/dsh-llm'
import CredentialsLocal from '@deepseek-ai/dsh-credentials-local'
import SettingsLocal from '@deepseek-ai/dsh-settings-local'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import { assemble } from './assemble.ts'
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
await closeMockServers()
vi.unstubAllEnvs()
})
/** Boot the dormant composition: a bare `llm-pi-ai` row with no config at all. */
async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }> {
root = await mkdtemp(join(tmpdir(), 'dsh-pi-composition-'))
const settingsPath = join(root, 'settings.yaml')
await writeFile(settingsPath, '# personal settings\n')
await writeFile(join(root, '.env'), 'PI_COMPOSITION_KEY=key-from-store\n')
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
'- id: llm',
" name: 'test-llm-service'",
'- id: settings',
" name: '@deepseek-ai/dsh-settings-local'",
' config:',
` path: ${JSON.stringify(settingsPath)}`,
' debounceMs: 10',
'- id: credentials',
" name: '@deepseek-ai/dsh-credentials-local'",
' config:',
` path: ${JSON.stringify(join(root, '.env'))}`,
' debounceMs: 10',
'- id: llm-pi-ai',
" name: '@deepseek-ai/dsh-llm-pi-ai'",
'',
].join('\n'))
const ctx = new Context()
context = ctx
ctx.baseUrl = pathToFileURL(root).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['test-llm-service', LlmService],
['@deepseek-ai/dsh-settings-local', SettingsLocal],
['@deepseek-ai/dsh-credentials-local', CredentialsLocal],
['@deepseek-ai/dsh-llm-pi-ai', LlmPiAi],
])
ctx.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof ctx.loader.internal>
await ctx.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await ctx.loader.await()
return { ctx, settingsPath }
}
describe('llm-pi-ai real dormant composition', () => {
it('boots with zero routes and registers one the moment settings supply a profile', async () => {
vi.stubEnv('PI_COMPOSITION_KEY', '')
const server = await mockServer([{ events: textEvents }])
const { ctx, settingsPath } = await loadComposition()
// The shipped posture: the adapter exists, no route does.
expect(ctx.llm.listProviders()).toEqual([])
// Exactly what the web Models page leaves on disk.
await writeFile(settingsPath, [
'llm-pi-ai:',
' providers:',
' deepseek:',
' apiKeyEnv: PI_COMPOSITION_KEY',
` baseURL: ${server.url}`,
'',
].join('\n'))
await vi.waitFor(() => {
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek'])
}, { timeout: 5000 })
const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
expect(server.headers[0]?.authorization).toBe('Bearer key-from-store')
})
})
@@ -0,0 +1,82 @@
import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
export interface MockServer {
url: string
paths: string[]
requests: unknown[]
headers: IncomingMessage['headers'][]
readonly closedResponses: number
responseClosed: Promise<void>
}
const servers: Server[] = []
/** Close every server opened since the last call; run from each spec's afterEach. */
export async function closeMockServers(): Promise<void> {
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
}
/** A minimal complete text generation in pi-ai's chat-completions shape. */
export const textEvents = [
'{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}',
'{"choices":[{"delta":{"content":"hello"},"index":0,"finish_reason":null}]}',
'{"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
'[DONE]',
]
/** Local provider stand-in: replays scripted behaviors per request. */
export async function mockServer(script: {
status?: number
events?: string[]
body?: string
delayMs?: number
headers?: Record<string, string>
}[]): Promise<MockServer> {
const paths: string[] = []
const requests: unknown[] = []
const headers: IncomingMessage['headers'][] = []
let closedResponses = 0
const responseClosed = Promise.withResolvers<undefined>()
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
response.on('close', () => {
closedResponses += 1
responseClosed.resolve(undefined)
})
let body = ''
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
request.on('end', () => {
paths.push(request.url ?? '')
requests.push(body.length === 0 ? undefined : JSON.parse(body))
headers.push(request.headers)
const behavior = script.shift() ?? { status: 500, body: 'script exhausted' }
if (behavior.status !== undefined && behavior.status !== 200) {
response.writeHead(behavior.status, { 'content-type': 'application/json', ...behavior.headers })
response.end(behavior.body ?? '{}')
return
}
response.writeHead(200, { 'content-type': 'text/event-stream' })
let index = 0
const writeNext = (): void => {
const event = behavior.events?.[index++]
if (event === undefined) { response.end(); return }
response.write(`data: ${event}\n\n`)
if (behavior.delayMs === undefined) writeNext()
else setTimeout(writeNext, behavior.delayMs)
}
writeNext()
})
})
servers.push(server)
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('no port')
return {
url: `http://127.0.0.1:${address.port}`,
paths,
requests,
headers,
responseClosed: responseClosed.promise,
get closedResponses() { return closedResponses },
}
}
@@ -43,12 +43,11 @@ async function harness(): Promise<Context> {
contexts.push(ctx)
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, {
providers: providerCases.map(profile => ({
provider: profile.provider,
providers: Object.fromEntries(providerCases.map(profile => [profile.provider, {
...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey },
...profile.baseURL === undefined ? {} : { baseURL: profile.baseURL },
...profile.headers === undefined ? {} : { headers: profile.headers },
})),
}])),
})
return ctx
}
@@ -10,6 +10,7 @@ vi.mock('@earendil-works/pi-ai/compat', async (importOriginal) => {
})
import { PiAiAdapter } from '../src/adapter.ts'
import { resolveProfiles } from '../src/config.ts'
afterEach(() => { streamSimple.mockReset() })
@@ -21,7 +22,10 @@ describe('pi-ai SDK retry boundary', () => {
throw failure
},
})
const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai', apiKey: 'test-key' }] })
const adapter = new PiAiAdapter({
profiles: () => resolveProfiles({ openai: { apiKey: 'test-key' } }),
resolveApiKey: () => Promise.resolve('test-key'),
})
const drain = async (): Promise<void> => {
for await (const _chunk of adapter.stream({
provider: 'openai',
+6
View File
@@ -20,6 +20,12 @@
{
"path": "../../llm/llm"
},
{
"path": "../../credentials/credentials"
},
{
"path": "../../settings/settings"
},
{
"path": "../../support/invariants"
},
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm/README.md
README.md: d343449d1530bf70a3a8c57f883894e29c42d18f
README.zh.md: 4dc4a0ca06378116d05fdb4b9b048738930511fd
README.md: 5b0c1b2dcafeefaad25f1714e4a1783430370118
README.zh.md: 5f5c8142ec829e8ca8cfd40e6caa341ae0a33c7d
+1 -1
View File
@@ -10,7 +10,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
### Public API
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber.
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. The returned disposer also carries `replace(providers)`: the candidate route set is validated in full before anything moves, so a conflict with another adapter leaves the current routes registered and serving, and the swap itself is one synchronous section with no observable gap. `replace([])` is legal — a registration holding zero routes — unlike an empty initial registration.
- `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order.
- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` Return the provider-owned retry policy captured during registration, with normal defaults resolved.
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` Discover the models one registered provider currently advertises.
+1 -1
View File
@@ -10,7 +10,7 @@
### 公开 API
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose(资源释放)。
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle` 为给定提供方路由注册一个适配器实例。注册要么全部成功,要么全部不生效,并且会随调用 fiber 一起 dispose(资源释放)。返回的释放器还携带 `replace(providers)`:候选路由集合会在任何东西变动之前完整校验,因此与另一适配器冲突时,当前路由保持注册且继续服务,而替换本身是一个同步区段,不存在可观察的空档。`replace([])` 合法——一个持有零条路由的注册——这与空的初始注册不同。
- `ctx.llm.listProviders(): LlmProviderInfo[]` 按注册顺序描述已注册提供方路由。
- `ctx.llm.providerRetryPolicy(provider: string): ResolvedRetryPolicy` 返回注册时捕获的提供方重试策略,并解析 normal 默认值。
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` 发现某个已注册提供方当前公布的模型。
+89 -25
View File
@@ -184,6 +184,30 @@ export abstract class LlmAdapter {
abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>
}
/**
* What {@link LlmService.registerAdapter} returns: the disposer, plus an
* atomic route replacement for the same adapter instance.
*/
export interface AdapterRegistrationHandle {
/** Release every route this registration currently holds. */
(): void
/**
* Replace this registration's routes with `providers`, keeping the same
* adapter instance. The candidate set is validated in full first — a
* conflict with another adapter, an invalid name, or bad provider metadata
* throws and leaves the current routes untouched — and the swap itself is
* one synchronous section, so no request can observe a gap. An empty array
* is legal here (a settings section that emptied holds zero routes while
* staying registered), unlike an empty initial registration.
*
* Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration
* has been released: its routes are gone and its disposer has already run,
* so anything registered afterwards would have no owner left to release it.
* @param providers - the complete next route set for this registration.
*/
replace(providers: string[]): void
}
/**
* The abstract `llm` service: an adapter registry plus a streaming model-call
* surface, interceptable via the `llm/stream` waterfall.
@@ -201,39 +225,79 @@ export class LlmService extends Service {
* Disposed with the fiber.
* @param providers - every provider route this adapter should serve.
* @param adapter - the adapter that streams calls for those providers.
* @returns the disposer that unregisters all of them.
* @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}.
*/
registerAdapter(providers: string[], adapter: LlmAdapter): () => void {
registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle {
// The routes this registration currently holds; `replace` rewrites it, and
// the disposer releases whatever it holds at disposal time.
const owned = new Set<string>()
// The disposer has run: `owned` being empty cannot say so on its own,
// because `replace([])` legally leaves a live registration holding none.
let released = false
const dispose = this.ctx.effect(function* (this: LlmService) {
if (providers.length === 0) throw new LlmError('an adapter must register at least one provider', 'INVALID_ADAPTER')
const unique = new Set<string>()
const registrations: AdapterRegistration[] = []
for (const provider of providers) {
if (provider.length === 0) throw new LlmError('adapter provider names must be non-empty', 'INVALID_ADAPTER')
if (unique.has(provider) || this.adapters.has(provider)) {
throw new LlmError(`an adapter for provider "${provider}" is already registered`, 'DUPLICATE_ADAPTER')
}
const info = adapter.providerInfo(provider)
if (typeof info.id !== 'string' || info.id !== provider || typeof info.name !== 'string' || info.name.length === 0) {
throw new LlmError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, 'INVALID_ADAPTER')
}
unique.add(provider)
const retryPolicy = adapter.providerRetryPolicy(provider)
?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`)
registrations.push({
adapter,
provider: { id: info.id, name: info.name },
retryPolicy,
})
}
for (const registration of registrations) this.adapters.set(registration.provider.id, registration)
this.commitRoutes(owned, this.prepareRoutes(providers, adapter, owned))
yield () => {
for (const provider of providers) this.adapters.delete(provider)
released = true
for (const provider of owned) this.adapters.delete(provider)
owned.clear()
}
}.bind(this), 'llm.registerAdapter()')
// ctx.effect's disposer returns Promise<void>; our disposer API is
// synchronous fire-and-forget — discard the (always-resolved) promise.
return () => void dispose()
const handle = (() => void dispose()) as AdapterRegistrationHandle
handle.replace = (next: string[]): void => {
// Registering here would leak: the effect's disposer already ran, so
// nothing remains to release whatever this call would put in the map.
if (released) {
throw new LlmError('a disposed adapter registration cannot replace its routes', 'REGISTRATION_DISPOSED')
}
this.commitRoutes(owned, this.prepareRoutes(next, adapter, owned))
}
return handle
}
/**
* Validate one candidate route set for `adapter`, treating routes this
* registration already holds as available. Nothing is mutated: a rejected
* candidate leaves the registry exactly as it was.
*/
private prepareRoutes(providers: string[], adapter: LlmAdapter, owned: ReadonlySet<string>): AdapterRegistration[] {
const unique = new Set<string>()
const registrations: AdapterRegistration[] = []
for (const provider of providers) {
if (provider.length === 0) throw new LlmError('adapter provider names must be non-empty', 'INVALID_ADAPTER')
if (unique.has(provider) || (this.adapters.has(provider) && !owned.has(provider))) {
throw new LlmError(`an adapter for provider "${provider}" is already registered`, 'DUPLICATE_ADAPTER')
}
const info = adapter.providerInfo(provider)
if (typeof info.id !== 'string' || info.id !== provider || typeof info.name !== 'string' || info.name.length === 0) {
throw new LlmError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, 'INVALID_ADAPTER')
}
unique.add(provider)
const retryPolicy = adapter.providerRetryPolicy(provider)
?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`)
registrations.push({
adapter,
provider: { id: info.id, name: info.name },
retryPolicy,
})
}
return registrations
}
/**
* Swap this registration's routes for the prepared ones in one synchronous
* section, so no observer can see the registry between the release and the
* re-registration.
*/
private commitRoutes(owned: Set<string>, registrations: readonly AdapterRegistration[]): void {
for (const provider of owned) this.adapters.delete(provider)
owned.clear()
for (const registration of registrations) {
this.adapters.set(registration.provider.id, registration)
owned.add(registration.provider.id)
}
}
/**
+28
View File
@@ -1381,4 +1381,32 @@ describe('LlmService', () => {
disposeAgain()
expect(ctx.llm.listProviders()).toEqual([])
})
it('refuses to replace routes on a registration that was already released', async () => {
// The leak this prevents: the effect's disposer has run, so a route added
// afterwards would sit in the registry with nothing left to release it.
const ctx = new Context()
await ctx.plugin(LlmService)
const handle = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
handle()
expect(() => { handle.replace(['leaked']) })
.toThrow(/disposed adapter registration cannot replace its routes/)
expect(ctx.llm.listProviders()).toEqual([])
})
it('still allows an empty route set on a live registration', async () => {
// `replace([])` is the settings-section-emptied case: legal, and it must
// not be mistaken for disposal by the guard above.
const ctx = new Context()
await ctx.plugin(LlmService)
const handle = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
handle.replace([])
expect(ctx.llm.listProviders()).toEqual([])
handle.replace(['m2'])
expect(ctx.llm.listProviders()).toEqual([{ id: 'm2', name: 'm2' }])
handle()
expect(ctx.llm.listProviders()).toEqual([])
})
})
@@ -27,6 +27,7 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-atomic-write": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
@@ -38,6 +39,7 @@
"yaml": "^2.9.0"
},
"devDependencies": {
"@deepseek-ai/dsh-atomic-write": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
+13 -87
View File
@@ -10,10 +10,10 @@
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { watch as chokidarWatch } from 'chokidar'
import { randomBytes } from 'node:crypto'
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { mkdir, readFile } from 'node:fs/promises'
import { dirname, extname, join, resolve } from 'node:path'
import { Document, parseDocument } from 'yaml'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { Settings, deepEqualJson, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
@@ -96,23 +96,6 @@ function isENOENT(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
}
/** Whether an exclusive create failed because the path already exists. */
function isEEXIST(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
}
/**
* Writer-lock protocol constants. These are robustness invariants of the
* cross-process write protocol, not deployment tunables: a holder rewrites one
* small document in milliseconds, so contention resolves well inside the
* retry deadline, and a lock older than the stale age can only belong to a
* crashed holder.
*/
const LOCK_RETRY_INITIAL_MS = 20
const LOCK_RETRY_MAX_MS = 200
const LOCK_TIMEOUT_MS = 2_000
const LOCK_STALE_MS = 5_000
/** File-backed settings provider (`settings.yaml`/`.json`). */
export class SettingsLocal extends Settings {
static Config: z<Config> = z.object({
@@ -197,8 +180,11 @@ export class SettingsLocal extends Settings {
}
private async persistSection(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
await mkdir(dirname(this.spec.filename), { recursive: true })
await this.withWriterLock(async () => {
// The writer lock's exclusive create needs the parent to exist before
// writeFileAtomic gets its own chance to create it.
// 0700: the harness home holds user-private documents.
await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
await withFileLock(this.spec.filename, async () => {
// Read-modify-write: fold in any on-disk state this process has not
// observed yet — an external edit still inside the watcher debounce
// window, a change the watcher missed, or another process's write — so
@@ -209,74 +195,14 @@ export class SettingsLocal extends Settings {
const output = this.spec.format === 'yaml'
? this.renderYaml(ns, section)
: this.renderJson(ns, section)
// Exclusive-create (`wx`) a random-suffix sibling: the open refuses to
// follow any planted symlink at a guessable temp path, and the fresh inode
// carries owner-only permissions that survive the rename — a document that
// may hold personal values is never world-readable and never a symlink.
const temp = `${this.spec.filename}.${randomBytes(6).toString('hex')}.tmp`
// TODO(settings-atomic-durability): Use a replacement that fsyncs the file
// and parent directory and preserves owner-only permissions on Windows.
try {
await writeFile(temp, output, { mode: 0o600, flag: 'wx' })
await rename(temp, this.spec.filename)
} catch (error) {
await rm(temp, { force: true })
throw error
}
// 0600: a document that may hold personal values is never world-readable.
await writeFileAtomic(this.spec.filename, output, { mode: 0o600, dirMode: 0o700 })
this.text = output
})
}
/**
* Hold the cross-process writer lock around one read-render-rename cycle.
* The lock is a `wx`-created sibling (`<file>.lock`); the rename-based
* commit keeps readers lock-free, so only writers contend. A lock older
* than {@link LOCK_STALE_MS} is a crashed holder and is broken with a
* warning; a live holder past {@link LOCK_TIMEOUT_MS} fails the write.
*/
private async withWriterLock<T>(operation: () => Promise<T>): Promise<T> {
const lockPath = `${this.spec.filename}.lock`
const deadline = Date.now() + LOCK_TIMEOUT_MS
let delay = LOCK_RETRY_INITIAL_MS
for (;;) {
try {
await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' })
break
} catch (error) {
if (!isEEXIST(error)) throw error
}
const ageMs = await this.lockAgeMs(lockPath)
// The holder released between the failed create and the stat: the lock
// is free right now, so retry without burning backoff or deadline.
if (ageMs === undefined) continue
if (ageMs > LOCK_STALE_MS) {
// TODO(settings-lock-ownership): Replace age-only takeover with ownership-safe
// acquisition and release so a slow writer cannot remove a successor's lock.
}, {
onStaleBreak: (lockPath) => {
this.ctx.logger.warn('settings-local: breaking a stale writer lock at %s', lockPath)
await rm(lockPath, { force: true })
continue
}
if (Date.now() >= deadline) {
throw new Error(`settings-local: timed out waiting for the writer lock at ${lockPath}`)
}
await new Promise(resolve => setTimeout(resolve, delay))
delay = Math.min(delay * 2, LOCK_RETRY_MAX_MS)
}
try {
return await operation()
} finally {
await rm(lockPath, { force: true })
}
}
/** Age of the writer lock, or `undefined` when it vanished after a failed create. */
private async lockAgeMs(lockPath: string): Promise<number | undefined> {
try {
return Date.now() - (await stat(lockPath)).mtimeMs
} catch (error) {
if (!isENOENT(error)) throw error
return undefined
}
},
})
}
override async* [Service.init](): AsyncGenerator<() => Promise<void> | void, void, void> {
@@ -17,6 +17,9 @@
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/atomic-write"
},
{
"path": "../../util/paths"
},

Some files were not shown because too many files have changed in this diff Show More