Merge commit '557cff8871e86a7a9be85c2ee2d8225db8b439fe' into worktree/pr977-merge-20260731
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 实体 | 产品:稳定表面 |
|
||||
|
||||
@@ -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
packages/credentials/README.i18n.yaml
Normal file
6
packages/credentials/README.i18n.yaml
Normal 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
packages/credentials/README.md
Normal file
14
packages/credentials/README.md
Normal 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
packages/credentials/README.zh.md
Normal file
14
packages/credentials/README.zh.md
Normal 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 留有余地。
|
||||
6
packages/credentials/credentials-local/README.i18n.yaml
Normal file
6
packages/credentials/credentials-local/README.i18n.yaml
Normal 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/credentials-local/README.md
|
||||
README.md: 126140b10719dc6f7bc458a118ba1feb1f440270
|
||||
README.zh.md: c22575115ab44b5e86a847ffe8f1fa1a795b580d
|
||||
54
packages/credentials/credentials-local/README.md
Normal file
54
packages/credentials/credentials-local/README.md
Normal file
@@ -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.
|
||||
54
packages/credentials/credentials-local/README.zh.md
Normal file
54
packages/credentials/credentials-local/README.zh.md
Normal file
@@ -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`;存储在启动时重新读取。
|
||||
48
packages/credentials/credentials-local/package.json
Normal file
48
packages/credentials/credentials-local/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
464
packages/credentials/credentials-local/src/index.ts
Normal file
464
packages/credentials/credentials-local/src/index.ts
Normal file
@@ -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
|
||||
31
packages/credentials/credentials-local/src/invariant.ts
Normal file
31
packages/credentials/credentials-local/src/invariant.ts
Normal file
@@ -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 */
|
||||
71
packages/credentials/credentials-local/tests/drain.spec.ts
Normal file
71
packages/credentials/credentials-local/tests/drain.spec.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
244
packages/credentials/credentials-local/tests/local.spec.ts
Normal file
244
packages/credentials/credentials-local/tests/local.spec.ts
Normal file
@@ -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' })
|
||||
})
|
||||
})
|
||||
223
packages/credentials/credentials-local/tests/watcher.spec.ts
Normal file
223
packages/credentials/credentials-local/tests/watcher.spec.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
33
packages/credentials/credentials-local/tsconfig.json
Normal file
33
packages/credentials/credentials-local/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
packages/credentials/credentials/README.i18n.yaml
Normal file
6
packages/credentials/credentials/README.i18n.yaml
Normal 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/credentials/README.md
|
||||
README.md: 1c18c4762360ad081227b7097cd82ddab4fcdefc
|
||||
README.zh.md: 751fb7c1e8326cef91b925c5f8b9f40d92e1bba6
|
||||
48
packages/credentials/credentials/README.md
Normal file
48
packages/credentials/credentials/README.md
Normal file
@@ -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.
|
||||
48
packages/credentials/credentials/README.zh.md
Normal file
48
packages/credentials/credentials/README.zh.md
Normal file
@@ -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()`。
|
||||
39
packages/credentials/credentials/package.json
Normal file
39
packages/credentials/credentials/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
162
packages/credentials/credentials/src/index.ts
Normal file
162
packages/credentials/credentials/src/index.ts
Normal file
@@ -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
|
||||
38
packages/credentials/credentials/src/invariant.ts
Normal file
38
packages/credentials/credentials/src/invariant.ts
Normal file
@@ -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))
|
||||
71
packages/credentials/credentials/tests/credentials.spec.ts
Normal file
71
packages/credentials/credentials/tests/credentials.spec.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
37
packages/credentials/credentials/tests/invariant.spec.ts
Normal file
37
packages/credentials/credentials/tests/invariant.spec.ts
Normal file
@@ -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/)
|
||||
})
|
||||
})
|
||||
49
packages/credentials/credentials/tests/memory.ts
Normal file
49
packages/credentials/credentials/tests/memory.ts
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
24
packages/credentials/credentials/tsconfig.json
Normal file
24
packages/credentials/credentials/tsconfig.json
Normal file
@@ -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,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
|
||||
|
||||
@@ -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)`.
|
||||
|
||||
@@ -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 角色字段脱敏的 wire/UI 层将随 settings RPC 面一起交付。
|
||||
- **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。
|
||||
- **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。
|
||||
- **序列化会将 user 与工具结果内容展平为文本块**:会跳过插件添加的块类型,空工具输出会以字面 `(no output)` 通过协议发送。
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,{
|
||||
|
||||
@@ -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)
|
||||
|
||||
186
packages/llm/llm-deepseek/tests/dynamic-config.spec.ts
Normal file
186
packages/llm/llm-deepseek/tests/dynamic-config.spec.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
174
packages/llm/llm-deepseek/tests/loader-composition.spec.ts
Normal file
174
packages/llm/llm-deepseek/tests/loader-composition.spec.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
82
packages/llm/llm-deepseek/tests/mock-server.ts
Normal file
82
packages/llm/llm-deepseek/tests/mock-server.ts
Normal file
@@ -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() })),
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,12 @@
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/credentials"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 provider:settings 里新生的路由实时完成注册,并在用户层重置时随之移除,`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 角色字段脱敏的 wire/UI 层将随 settings RPC 面一起交付。
|
||||
- **必须属于 catalog**:已安装 pi-ai catalog 中不存在的自定义模型 id 会以 `UNKNOWN_MODEL` 失败,即使提供方 profile 配置了自定义端点。
|
||||
- **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。
|
||||
- **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
189
packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts
Normal file
189
packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
116
packages/llm/llm-pi-ai/tests/loader-composition.spec.ts
Normal file
116
packages/llm/llm-pi-ai/tests/loader-composition.spec.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
82
packages/llm/llm-pi-ai/tests/mock-server.ts
Normal file
82
packages/llm/llm-pi-ai/tests/mock-server.ts
Normal file
@@ -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',
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/credentials"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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[]>` 发现某个已注册提供方当前公布的模型。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -546,4 +546,80 @@ export abstract class Settings extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Value mirror of the `FiberState` members {@link isUnloading} compares
|
||||
* against: a const enum has no runtime object to import, and the value is
|
||||
* needed at runtime (same rationale as the CLI boot driver's mirror).
|
||||
*/
|
||||
const FIBER_DISPOSED = 4
|
||||
const FIBER_UNLOADING = 5
|
||||
|
||||
/** Whether the consumer's own fiber is tearing down (not just losing the settings service). */
|
||||
function isUnloading(ctx: Context): boolean {
|
||||
const state: number = ctx.fiber.state
|
||||
return state === FIBER_UNLOADING || state === FIBER_DISPOSED
|
||||
}
|
||||
|
||||
/** Hooks a consumer hands to {@link installSettingsSection}. */
|
||||
export interface SettingsSectionHooks<T> {
|
||||
/**
|
||||
* Receive the active configuration source: the resolved settings scope
|
||||
* while one is attached, the composition entry otherwise. Called before
|
||||
* the matching `onChange` at attach and at detach.
|
||||
* @param current - thunk returning the currently authoritative value.
|
||||
*/
|
||||
setSource(current: () => T): void
|
||||
/**
|
||||
* Re-judge anything derived from the source — registration-level facts,
|
||||
* memoized resolutions — after an attach, a detach, or a committed change.
|
||||
*/
|
||||
onChange(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the canonical optional-settings consumer wiring: while a settings
|
||||
* service exists, register `ns` with the consumer's composition entry as the
|
||||
* `base` layer and point the source thunk at the resolved scope; when the
|
||||
* service goes away (disposal, provider reload), fall back to the entry so
|
||||
* the consumer keeps working exactly as composed. The registration rides the
|
||||
* scoped fiber, so no settings service ever mounted means none of this runs.
|
||||
* @param ctx - consumer plugin context owning the wiring.
|
||||
* @param ns - the consumer-owned settings namespace.
|
||||
* @param schema - schema resolving the namespace (typically the plugin Config).
|
||||
* @param entry - the consumer's composition entry config, used as `base`.
|
||||
* @param hooks - source sink and change notification.
|
||||
*/
|
||||
export function installSettingsSection<T>(
|
||||
ctx: Context,
|
||||
ns: SettingsNamespace,
|
||||
schema: z<T>,
|
||||
entry: T,
|
||||
hooks: SettingsSectionHooks<T>,
|
||||
): void {
|
||||
ctx.inject(['settings'], (sctx) => {
|
||||
const scope = sctx.settings.register(ns, schema, { base: entry })
|
||||
hooks.setSource(() => scope.get())
|
||||
sctx.effect(() => () => {
|
||||
// This disposer runs for two different reasons. A settings provider
|
||||
// detaching leaves the consumer running, so it must fall back to its
|
||||
// composition entry and re-judge what it derived. The consumer's own
|
||||
// unload runs it too — and there `onChange` would re-register routes
|
||||
// and touch resources the teardown is releasing, so the fallback is
|
||||
// pointless and the notification actively harmful.
|
||||
if (isUnloading(ctx)) return
|
||||
hooks.setSource(() => entry)
|
||||
hooks.onChange()
|
||||
})
|
||||
hooks.onChange()
|
||||
scope.watch(() => {
|
||||
// A stored change landing while the consumer unloads reaches the watcher
|
||||
// before the registration is released, and `onChange` is exactly as
|
||||
// harmful here as in the disposer above: it re-registers routes against
|
||||
// a fiber whose resources are being let go.
|
||||
if (isUnloading(ctx)) return
|
||||
hooks.onChange()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export default Settings
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { Settings, deepEqualJson, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts'
|
||||
import { Settings, deepEqualJson, installSettingsSection, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts'
|
||||
import { MemorySettings } from './memory.ts'
|
||||
|
||||
/** A provider implementing only the three primitives: the seam owns init. */
|
||||
@@ -652,3 +652,108 @@ describe('watch', () => {
|
||||
expect(scope.get()).toEqual({ theme: 'light', fontSize: 14 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('installSettingsSection', () => {
|
||||
const HelperSchema: z<{ theme: string }> = z.object({
|
||||
theme: z.string().default('default'),
|
||||
})
|
||||
|
||||
it('drives the source through attach, live commits, and detach', async () => {
|
||||
const ctx = new Context()
|
||||
const entry = { theme: 'entry' }
|
||||
let current: () => { theme: string } = () => entry
|
||||
let changes = 0
|
||||
installSettingsSection(ctx, settingsNamespace('helper-ns'), HelperSchema, entry, {
|
||||
setSource: (source) => {
|
||||
current = source
|
||||
},
|
||||
onChange: () => {
|
||||
changes += 1
|
||||
},
|
||||
})
|
||||
// No settings service mounted: nothing ran, the entry stays authoritative.
|
||||
expect(current()).toEqual({ theme: 'entry' })
|
||||
expect(changes).toBe(0)
|
||||
|
||||
const fiber = ctx.plugin(MemorySettings, { doc: { 'helper-ns': { theme: 'user' } } })
|
||||
await fiber
|
||||
await vi.waitFor(() => {
|
||||
expect(current()).toEqual({ theme: 'user' })
|
||||
})
|
||||
expect(changes).toBe(1)
|
||||
|
||||
await ctx.settings.update(settingsNamespace('helper-ns'), { theme: 'live' })
|
||||
await vi.waitFor(() => {
|
||||
expect(changes).toBe(2)
|
||||
})
|
||||
expect(current()).toEqual({ theme: 'live' })
|
||||
|
||||
await fiber.dispose()
|
||||
await vi.waitFor(() => {
|
||||
expect(changes).toBe(3)
|
||||
})
|
||||
expect(current()).toEqual({ theme: 'entry' })
|
||||
})
|
||||
|
||||
it('stays silent when the consumer itself unloads', async () => {
|
||||
const { ctx } = await boot({ doc: { 'helper-ns': { theme: 'user' } } })
|
||||
const entry = { theme: 'entry' }
|
||||
let current: () => { theme: string } = () => entry
|
||||
const changes: string[] = []
|
||||
const consumer = ctx.plugin({
|
||||
inject: ['settings'],
|
||||
apply: (child: Context) => {
|
||||
installSettingsSection(child, settingsNamespace('helper-ns'), HelperSchema, entry, {
|
||||
setSource: (source) => {
|
||||
current = source
|
||||
},
|
||||
onChange: () => {
|
||||
changes.push(current().theme)
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
await consumer
|
||||
await vi.waitFor(() => {
|
||||
expect(changes).toEqual(['user'])
|
||||
})
|
||||
|
||||
// The consumer's own teardown must not re-derive anything: an onChange
|
||||
// here would re-register routes and touch resources being released.
|
||||
await consumer.dispose()
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(changes).toEqual(['user'])
|
||||
})
|
||||
|
||||
it('stays silent for a stored change that lands while the consumer unloads', async () => {
|
||||
// The watcher outlives the start of teardown by the width of the unload,
|
||||
// so a document change arriving in that window reaches it. Notifying then
|
||||
// is exactly as harmful as notifying from the disposer.
|
||||
const { ctx, provider } = await boot({ doc: { 'helper-ns': { theme: 'user' } } })
|
||||
const entry = { theme: 'entry' }
|
||||
let current: () => { theme: string } = () => entry
|
||||
const changes: string[] = []
|
||||
const consumer = ctx.plugin({
|
||||
inject: ['settings'],
|
||||
apply: (child: Context) => {
|
||||
installSettingsSection(child, settingsNamespace('helper-ns'), HelperSchema, entry, {
|
||||
setSource: (source) => {
|
||||
current = source
|
||||
},
|
||||
onChange: () => {
|
||||
changes.push(current().theme)
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
await consumer
|
||||
await vi.waitFor(() => {
|
||||
expect(changes).toEqual(['user'])
|
||||
})
|
||||
|
||||
const unloading = consumer.dispose()
|
||||
provider.pushExternal({ 'helper-ns': { theme: 'racing' } })
|
||||
await unloading
|
||||
expect(changes).toEqual(['user'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -141,6 +141,13 @@ export interface LoaderSmokeOptions {
|
||||
readonly prepare?: (cwd: string) => Promise<void> | void
|
||||
/** Optional world-state assertion run in the isolated cwd before cleanup. */
|
||||
readonly inspect?: (cwd: string) => Promise<void> | void
|
||||
/**
|
||||
* Exact process exit code this smoke expects; defaults to `0`. Scenarios
|
||||
* pinning a designed failure surface (a one-shot turn ending in an error
|
||||
* result) declare its nonzero exit here, and a run that exits any other
|
||||
* way — including succeeding — still fails the smoke.
|
||||
*/
|
||||
readonly expectedExitCode?: number
|
||||
}
|
||||
|
||||
/** Captured output from a Loader smoke that exited successfully. */
|
||||
@@ -187,8 +194,9 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise<Loade
|
||||
if (result.timedOut) {
|
||||
throw new Error(`${options.label} did not exit within ${processTimeoutMs / 1_000}s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
if (result.failed) {
|
||||
throw new Error(`${options.label} exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
const expectedExitCode = options.expectedExitCode ?? 0
|
||||
if (result.exitCode !== expectedExitCode) {
|
||||
throw new Error(`${options.label} exited ${String(result.exitCode)} (expected ${expectedExitCode}). stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
}
|
||||
await options.inspect?.(cwd)
|
||||
return { stdout: result.stdout, stderr: result.stderr }
|
||||
|
||||
@@ -74,7 +74,32 @@ describe('runLoaderSmoke', () => {
|
||||
libBinScript: fixture('fail'),
|
||||
configPath,
|
||||
tsconfigPath,
|
||||
})).rejects.toThrow('failure fixture exited 7. stdout:\n\nstderr:\nfixture failed')
|
||||
})).rejects.toThrow('failure fixture exited 7 (expected 0). stdout:\n\nstderr:\nfixture failed')
|
||||
})
|
||||
|
||||
it('accepts a declared expected failure exit and rejects any other outcome', async () => {
|
||||
// A scenario pinning a designed failure surface declares its exit code…
|
||||
const declared = await runLoaderSmoke({
|
||||
label: 'declared failure fixture',
|
||||
tempDirPrefix: 'loader-smoke-declared-fail-',
|
||||
binScript: fixture('fail'),
|
||||
libBinScript: fixture('fail'),
|
||||
configPath,
|
||||
tsconfigPath,
|
||||
expectedExitCode: 7,
|
||||
})
|
||||
expect(declared.stderr).toBe('fixture failed\n')
|
||||
|
||||
// …and a run that succeeds instead still fails the smoke.
|
||||
await expect(runLoaderSmoke({
|
||||
label: 'unexpectedly clean fixture',
|
||||
tempDirPrefix: 'loader-smoke-clean-',
|
||||
binScript: fixture('success'),
|
||||
libBinScript: fixture('success'),
|
||||
configPath,
|
||||
tsconfigPath,
|
||||
expectedExitCode: 7,
|
||||
})).rejects.toThrow(/exited 0 \(expected 7\)/)
|
||||
})
|
||||
|
||||
it('kills a process at its deadline and reports captured output', async () => {
|
||||
|
||||
@@ -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/ui/app-boot/README.md
|
||||
README.md: 4d8c7de65515a251f227075c7baf041fc1b210c8
|
||||
README.zh.md: d9b69a2b102a60685524288f75edeaabb21802db
|
||||
README.md: 1beffd6fbff2b84202683b010cd104f7c84297c7
|
||||
README.zh.md: d9ce9774b9b492a98556bbd9aa4564b711dbe40e
|
||||
|
||||
@@ -27,8 +27,8 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](..
|
||||
|
||||
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the official `dsh` surfaces ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
|
||||
|
||||
- **`.env`** — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient environment > project `.env` > personal `.env`.
|
||||
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount — so a personal `apiKey` can reference the personal `.env`. A patch naming an entry id absent from the booted tree is a silent no-op. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
|
||||
- **`.env`** — the credential store of [`dsh-credentials-local`](../../credentials/credentials-local/README.md), read by that provider alone. No surface hoists it into `process.env`: doing so would make every stored key look like a read-only launch override on the next run, blocking rotation from the TUI and the web page. The environment layers are the ambient one and the invoking directory's `.env` (loaded by the bin; `process.loadEnvFile` never overrides), and a composition without the credential provider keeps resolving keys from those alone.
|
||||
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as the shipped surface overlays: an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the booted tree is a silent no-op. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
|
||||
|
||||
Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures.
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
|
||||
开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由官方 `dsh` 界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件:
|
||||
|
||||
- **`.env`**:在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境中的值 > 项目 `.env` > 个人 `.env`。
|
||||
- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值,因此个人 `apiKey` 可以引用个人 `.env`。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。
|
||||
- **`.env`**:[`dsh-credentials-local`](../../credentials/credentials-local/README.md) 的凭据存储,只由该 provider 读取。没有任何表层会把它提升进 `process.env`:那样做会让每个已存密钥在下次运行时看起来都像只读的启动时覆盖,从而阻断从 TUI 与 Web 页面轮换密钥。环境层次由环境中的值与调用目录的 `.env` 构成(由 bin 加载;`process.loadEnvFile` 从不覆盖已有值),没有凭据 provider 的组合仍然只从这两者解析密钥。
|
||||
- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与交付的 surface overlay 相同:按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在已启动树中,则静默不执行任何操作。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。
|
||||
|
||||
子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture(测试前置数据)中。
|
||||
|
||||
|
||||
@@ -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/util/README.md
|
||||
README.md: 605c3dd0beebc16109e8e6bc944ea722a60975c0
|
||||
README.zh.md: 5c66ded33a36079f80965cf466449843e07511f0
|
||||
README.md: 46904aba70c7cf0f98bb75cce79d97bb12b950a9
|
||||
README.zh.md: 59a2dcf7926c12d7005446393cadfd8b0be88f77
|
||||
|
||||
@@ -10,6 +10,7 @@ Zero-dependency primitives shared across the other groups. A package lands here
|
||||
| `paths/` | Canonical single-root `DSH_HOME` resolution plus shared filesystem path constants and helpers for harness user data (no harness deps) |
|
||||
| `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability |
|
||||
| `retention/` | Bounded model-facing output — `ItemRetainer`/`TextRetainer` + neutral notice helpers (pure, no harness deps); business semantics stay in each tool |
|
||||
| `atomic-write/` | Atomic file replacement — `writeFileAtomic` (exclusive-create temp + rename carrying the caller-stated mode); shared by the settings and credentials stores |
|
||||
| `native-command/` | No-shell `execFile` runner for host-native OS integrations — utf8 capture, abort propagation, Windows hide (no harness deps); command choice stays in each caller |
|
||||
|
||||
`dsh-brand` is the canonical case: it owns ONLY the `Branded<B>` helper, so a capability package can brand the ids it owns (`dsh-tasks`'s `TaskId`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
| `paths/` | 规范的单根 `DSH_HOME` 解析,以及 harness 用户数据的共享文件系统路径常量和辅助工具(无 harness 依赖) |
|
||||
| `timeout/` | 超时的时序/分类部分:`clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason`(纯函数,无 harness 依赖);终止机制保留在各个功能中 |
|
||||
| `retention/` | 有界的面向模型输出:`ItemRetainer`/`TextRetainer` 加上中性通知辅助工具(纯工具,无 harness 依赖);业务语义保留在各个工具中 |
|
||||
| `atomic-write/` | 原子文件替换:`writeFileAtomic`(独占创建临时文件 + 携带调用方所声明 mode 的 rename);由设置与凭据存储共用 |
|
||||
| `native-command/` | 宿主原生 OS 集成的免 shell `execFile` 运行器——utf8 捕获、abort 传播、Windows 窗口隐藏(无 harness 依赖);命令选择保留在各调用方 |
|
||||
|
||||
`dsh-brand` 是规范示例:它只负责 `Branded<B>` 辅助工具,因此功能包可以为自己拥有的 id 添加品牌(`dsh-tasks` 的 `TaskId`、`dsh-session` 的 `SessionId` 等),而只需依赖 `dsh-brand`,无需仅为使用 `Branded` 而引入不相关的包。
|
||||
|
||||
6
packages/util/atomic-write/README.i18n.yaml
Normal file
6
packages/util/atomic-write/README.i18n.yaml
Normal 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/util/atomic-write/README.md
|
||||
README.md: be9f896eb24e28aedc2c04858da8b8da9da548dc
|
||||
README.zh.md: 19a067dc84f12d334e5c31dda58e7cf78dac51f9
|
||||
45
packages/util/atomic-write/README.md
Normal file
45
packages/util/atomic-write/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# dsh-atomic-write
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Zero-dependency atomic file replacement shared by file-backed stores that must never leave partial, symlink-hijacked, or wider-than-intended content on disk — the user-settings document (`dsh-settings-local`) and the credentials store (`dsh-credentials-local`).
|
||||
|
||||
## Surface
|
||||
|
||||
```ts
|
||||
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
|
||||
declare const text: string
|
||||
declare const render: (previous: string) => string
|
||||
|
||||
await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 })
|
||||
|
||||
// Read-modify-write against the same file from several processes.
|
||||
await withFileLock('/home/u/.dsh/settings.yaml', async () => {
|
||||
await writeFileAtomic('/home/u/.dsh/settings.yaml', render(text), { mode: 0o600 })
|
||||
})
|
||||
```
|
||||
|
||||
`writeFileAtomic` commits one already-rendered string. The contract, in the order failures would exploit it:
|
||||
|
||||
- **Exclusive-create temp** (`wx`, random suffix): the open refuses to follow a symlink planted at a guessable temp path.
|
||||
- **The fresh inode carries `mode` through the rename**: replacing a wider-permission file narrows it without a chmod race. `mode` is required so the permission decision stays visible at every call site (subject to the process umask, like every fresh inode).
|
||||
- **`rename` replaces a symlinked target itself**, never writing through to its referent.
|
||||
- **Same-directory sibling** keeps the rename on one filesystem, so the swap stays atomic.
|
||||
- Parent directories are created; on any failure the temp is removed and the failure rethrown; readers observe either the old or the new complete content.
|
||||
|
||||
`withFileLock` serializes the writers of one file across processes, for the read-render-commit cycles a bare atomic commit cannot make safe on its own. The lock is a `wx`-created `<filename>.lock` sibling, so readers never contend; waiters back off exponentially and fail with a timeout rather than block forever. A lock older than the stale age is treated as a crashed holder and broken — see [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) for what that costs.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this is a pure filesystem primitive; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; nothing here enters a request prefix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Atomic, not durable** — no `fsync` of the file or its directory, so after a crash the rename may be observed unwound. The file-backed stores here re-read and republish on boot, keeping durability the caller's policy.
|
||||
- **String content only** — no `Buffer` or stream form until a consumer needs one.
|
||||
- **The lock takes over by age, not by ownership** (`TODO(settings-lock-ownership)`) — a holder slower than the stale age has its lock broken by a waiter, and release unlinks the path unconditionally, so a slow writer can remove a successor's lock. Two writers can then overlap and one cycle's result be lost. The stale age is set well above any write this repo performs, so the exposure is a paused or swapped-out process; ownership-safe acquisition and release is the fix.
|
||||
45
packages/util/atomic-write/README.zh.md
Normal file
45
packages/util/atomic-write/README.zh.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# dsh-atomic-write
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
零依赖的原子文件替换,供绝不允许在磁盘上留下不完整、被符号链接劫持或权限过宽内容的文件型存储共用:用户设置文档(`dsh-settings-local`)与凭据存储(`dsh-credentials-local`)。
|
||||
|
||||
## 接口面
|
||||
|
||||
```ts
|
||||
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
|
||||
declare const text: string
|
||||
declare const render: (previous: string) => string
|
||||
|
||||
await writeFileAtomic('/home/u/.dsh/settings.yaml', text, { mode: 0o600 })
|
||||
|
||||
// Read-modify-write against the same file from several processes.
|
||||
await withFileLock('/home/u/.dsh/settings.yaml', async () => {
|
||||
await writeFileAtomic('/home/u/.dsh/settings.yaml', render(text), { mode: 0o600 })
|
||||
})
|
||||
```
|
||||
|
||||
`writeFileAtomic` 提交一份已经渲染好的字符串。契约按故障利用它的先后顺序列出:
|
||||
|
||||
- **独占创建临时文件**(`wx` + 随机后缀):open 拒绝跟随预先埋在可猜测临时路径上的符号链接。
|
||||
- **全新 inode 携带 `mode` 走完 rename**:替换权限过宽的旧文件时直接收窄,不存在 chmod 竞态。`mode` 为必填,让权限决策始终可见于每个调用点(与所有新建 inode 一样受进程 umask 影响)。
|
||||
- **`rename` 替换的是符号链接目标本身**,绝不写穿到其指向的文件。
|
||||
- **同目录兄弟文件**保证 rename 落在同一文件系统上,交换保持原子。
|
||||
- 自动创建父目录;任何失败都会移除临时文件并重新抛出该失败;读取方只会观察到旧内容或完整的新内容。
|
||||
|
||||
`withFileLock` 跨进程串行化同一文件的写入方,服务于单靠原子提交无法保证安全的读-渲染-提交循环。锁是以 `wx` 创建的同目录 `<filename>.lock`,因此读取方从不参与竞争;等待方按指数退避,超时即失败而非无限阻塞。超过陈旧时限的锁被视为持有者已崩溃并被打破——其代价见[Known Limitations and Deferred Work](#known-limitations-and-deferred-work)。
|
||||
|
||||
## Model Experience
|
||||
|
||||
无:本包是纯文件系统原语,此处没有任何内容会到达模型请求。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
无;此处没有任何内容会进入请求前缀。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **原子但不保证持久**——不对文件或其所在目录做 `fsync`,因此崩溃后可能观察到 rename 被回退。此处的文件型存储在启动时重新读取并重新发布,把持久性留作调用方的策略。
|
||||
- **仅支持字符串内容**——在有消费方需要之前,不提供 `Buffer` 或流式形态。
|
||||
- **锁按时长而非归属接管**(`TODO(settings-lock-ownership)`)——持有者若慢于陈旧时限,其锁会被等待方打破,而释放又无条件删除该路径,因此慢写入方可能删掉后继者的锁。两个写入方随之重叠,一轮循环的结果可能丢失。陈旧时限远高于本仓库的任何一次写入,因此暴露面是被暂停或被换出的进程;修法是按归属安全地获取与释放。
|
||||
37
packages/util/atomic-write/package.json
Normal file
37
packages/util/atomic-write/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-atomic-write",
|
||||
"description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)",
|
||||
"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-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
157
packages/util/atomic-write/src/index.ts
Normal file
157
packages/util/atomic-write/src/index.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Zero-dependency atomic file replacement and writer coordination.
|
||||
* `writeFileAtomic` writes a random-suffix sibling with exclusive create and
|
||||
* the caller's permission bits, then renames it over the target, so readers
|
||||
* observe either the old or the new complete content and a replaced file ends
|
||||
* up with exactly the stated mode. `withFileLock` serializes cross-process
|
||||
* writers of one file through a `wx`-created `<file>.lock` sibling, so a
|
||||
* read-modify-write cycle can never resurrect a state another writer just
|
||||
* replaced; readers stay lock-free because the rename commit is atomic.
|
||||
* @module @deepseek-ai/dsh-atomic-write
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
|
||||
/**
|
||||
* Filesystem options for {@link writeFileAtomic}; `mode` is required so the
|
||||
* permission decision stays visible at every call site.
|
||||
*/
|
||||
export interface WriteFileAtomicOptions {
|
||||
/**
|
||||
* Permission bits stamped on the fresh temp inode and carried through the
|
||||
* rename (subject to the process umask, like every fresh inode).
|
||||
*/
|
||||
mode: number
|
||||
/**
|
||||
* Permission bits for parent directories this call creates (subject to the
|
||||
* umask; existing directories keep their mode). Omission uses the mkdir
|
||||
* default — pass `0o700` when the tree holds user-private data.
|
||||
*/
|
||||
dirMode?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace `filename` with `content` in one atomic step, creating parent
|
||||
* directories. The content is first written to a random-suffix sibling opened
|
||||
* with exclusive create (`wx`): the open refuses to follow a symlink planted
|
||||
* at the temp path, and the fresh inode carries `options.mode` through the
|
||||
* rename, so replacing a wider-permission file narrows it without a chmod
|
||||
* race. The rename also replaces a symlinked target itself instead of writing
|
||||
* through to its referent, and the same-directory sibling keeps the rename on
|
||||
* one filesystem. On any failure the temp file is removed and the failure
|
||||
* rethrown. Crash durability (fsync) is out of scope.
|
||||
* @param filename - final path receiving the content.
|
||||
* @param content - complete next file content.
|
||||
* @param options - permission bits for the replacement inode.
|
||||
*/
|
||||
export async function writeFileAtomic(filename: string, content: string, options: WriteFileAtomicOptions): Promise<void> {
|
||||
await mkdir(dirname(filename), {
|
||||
recursive: true,
|
||||
...options.dirMode === undefined ? {} : { mode: options.dirMode },
|
||||
})
|
||||
// TODO(settings-atomic-durability): Use a replacement that fsyncs the file
|
||||
// and parent directory and preserves owner-only permissions on Windows.
|
||||
const temp = `${filename}.${randomBytes(6).toString('hex')}.tmp`
|
||||
try {
|
||||
await writeFile(temp, content, { mode: options.mode, flag: 'wx' })
|
||||
await rename(temp, filename)
|
||||
} catch (error) {
|
||||
await rm(temp, { force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an exclusive create failed because the path already exists. */
|
||||
function isEEXIST(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
|
||||
}
|
||||
|
||||
/** Whether a filesystem error means absence. */
|
||||
function isENOENT(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
|
||||
}
|
||||
|
||||
/**
|
||||
* Writer-lock protocol constants. These are robustness invariants of the
|
||||
* cross-process write protocol, not deployment tunables: a holder rewrites one
|
||||
* small file 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
|
||||
|
||||
/** Options for {@link withFileLock}. */
|
||||
export interface WithFileLockOptions {
|
||||
/**
|
||||
* Called once each time a stale (crashed-holder) lock is broken, so the
|
||||
* caller can log the takeover in its own voice.
|
||||
*/
|
||||
onStaleBreak?: (lockPath: string) => void
|
||||
}
|
||||
|
||||
/** Age of the lock file, or `undefined` when it vanished after a failed create. */
|
||||
async function lockAgeMs(lockPath: string): Promise<number | undefined> {
|
||||
try {
|
||||
return Date.now() - (await stat(lockPath)).mtimeMs
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) throw error
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold the cross-process writer lock for `filename` around one operation. The
|
||||
* lock is a `wx`-created sibling (`<filename>.lock`); paired with the
|
||||
* rename-based commit of {@link writeFileAtomic}, readers stay lock-free and
|
||||
* only writers contend. Contention backs off exponentially; a lock older than
|
||||
* the stale age is a crashed holder and is broken (see
|
||||
* {@link WithFileLockOptions.onStaleBreak}); a live holder past the deadline
|
||||
* fails the operation with a timed-out error. The parent directory must exist.
|
||||
* @param filename - the file whose writers this lock serializes.
|
||||
* @param operation - the read-render-commit cycle to run while holding the lock.
|
||||
* @param options - stale-takeover notification hook.
|
||||
* @returns the operation's result; the lock releases on both outcomes.
|
||||
*/
|
||||
export async function withFileLock<T>(
|
||||
filename: string,
|
||||
operation: () => Promise<T>,
|
||||
options?: WithFileLockOptions,
|
||||
): Promise<T> {
|
||||
const lockPath = `${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 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.
|
||||
options?.onStaleBreak?.(lockPath)
|
||||
await rm(lockPath, { force: true })
|
||||
continue
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`atomic-write: 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 })
|
||||
}
|
||||
}
|
||||
30
packages/util/atomic-write/src/invariant.ts
Normal file
30
packages/util/atomic-write/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-atomic-write`.
|
||||
* @module @deepseek-ai/dsh-atomic-write/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-atomic-write'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'atomic-write-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this pure filesystem primitive owns no event stream or mutable runtime
|
||||
* data; its replacement contract is enforced by unit tests.
|
||||
*/
|
||||
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 */
|
||||
48
packages/util/atomic-write/tests/atomic-write.spec.ts
Normal file
48
packages/util/atomic-write/tests/atomic-write.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { lstat, mkdir, mkdtemp, readFile, readdir, stat, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { writeFileAtomic } from '../src/index.ts'
|
||||
|
||||
async function scratch(): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), 'dsh-atomic-write-'))
|
||||
}
|
||||
|
||||
describe('writeFileAtomic', () => {
|
||||
it('creates the file and its parents with exactly the stated mode', async () => {
|
||||
const dir = await scratch()
|
||||
const target = join(dir, 'nested', 'deep', 'doc.yaml')
|
||||
await writeFileAtomic(target, 'a: 1\n', { mode: 0o600 })
|
||||
expect(await readFile(target, 'utf8')).toBe('a: 1\n')
|
||||
expect((await stat(target)).mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
it('replaces existing content and narrows a wider-permission file to the stated mode', async () => {
|
||||
const dir = await scratch()
|
||||
const target = join(dir, 'doc.yaml')
|
||||
await writeFile(target, 'old', { mode: 0o644 })
|
||||
await writeFileAtomic(target, 'new', { mode: 0o600 })
|
||||
expect(await readFile(target, 'utf8')).toBe('new')
|
||||
expect((await stat(target)).mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
it('replaces a symlinked target itself without writing through to the referent', async () => {
|
||||
const dir = await scratch()
|
||||
const victim = join(dir, 'victim')
|
||||
await writeFile(victim, 'victim-content')
|
||||
const target = join(dir, 'doc.yaml')
|
||||
await symlink(victim, target)
|
||||
await writeFileAtomic(target, 'replaced', { mode: 0o600 })
|
||||
expect((await lstat(target)).isSymbolicLink()).toBe(false)
|
||||
expect(await readFile(target, 'utf8')).toBe('replaced')
|
||||
expect(await readFile(victim, 'utf8')).toBe('victim-content')
|
||||
})
|
||||
|
||||
it('leaves no temp sibling and rethrows when the rename fails', async () => {
|
||||
const dir = await scratch()
|
||||
const target = join(dir, 'occupied')
|
||||
await mkdir(target)
|
||||
await expect(writeFileAtomic(target, 'content', { mode: 0o600 })).rejects.toThrow()
|
||||
expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([])
|
||||
})
|
||||
})
|
||||
18
packages/util/atomic-write/tests/invariant.spec.ts
Normal file
18
packages/util/atomic-write/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as AtomicWriteInvariant from '../src/invariant.ts'
|
||||
|
||||
describe('atomic-write invariant companion', () => {
|
||||
it('registers its explained empty runtime invariant', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService)
|
||||
const fiber = await ctx.plugin(AtomicWriteInvariant)
|
||||
|
||||
expect(() => {
|
||||
ctx.invariants.register('@deepseek-ai/dsh-atomic-write', () => {})
|
||||
}).toThrow(/already registered/)
|
||||
await fiber.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
15
packages/util/atomic-write/tsconfig.json
Normal file
15
packages/util/atomic-write/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user