fix(llm): keep request generations coherent

This commit is contained in:
Tianyi Cui
2026-07-31 01:27:12 +08:00
parent afb05b4049
commit a9e2489db8
26 changed files with 237 additions and 142 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md
2026-07-29-request-level-llm-config-credentials.md: ec00b52bdbe8f00d334618f3e5347974a3928e67
2026-07-29-request-level-llm-config-credentials.zh.md: 29835b9fb320e6b31cb49a632ff0e56d88fb3f44
2026-07-29-request-level-llm-config-credentials.md: d84de8b74eb88720d782dfa20e193e6d180843dc
2026-07-29-request-level-llm-config-credentials.zh.md: 67b2d2e7cf952a7cb385e7c9dd67bd3a05651beb

View File

@@ -12,11 +12,11 @@ The [settings seam](2026-07-28-user-settings-seam.md) shipped without a producti
## Decision
**Per-request resolution, not fiber rebuilds.** The adapters take an options thunk and a per-stream credential resolver instead of rebuilding their fibers. Connection, credential, and request-transport facts are read for the operation, while an in-flight stream keeps the facts it started with. A missing key is a request-time `MISSING_CREDENTIAL` failure while the route remains registered. Provider routes and their retry policies are composition-fixed instead of triggering registration swaps.
**Per-request resolution, not fiber rebuilds.** The adapters take an options thunk and a per-stream credential resolver instead of rebuilding their fibers. Connection, credential, and request-transport facts are read for the stream, while an in-flight stream keeps the facts it started with. Model catalog/capability, context, reasoning-default, provider-route, and retry-policy facts are composition-fixed. A missing key is a request-time `MISSING_CREDENTIAL` failure while the route remains registered.
**Secrets are references, values live behind `ctx.credentials`.** Configuration can carry `apiKeyEnv: DEEPSEEK_API_KEY`; the read-only credential seam resolves it per operation. `credentials-local` checks the live process environment first, then parses `$DSH_HOME/.env` on demand, with no cache or mutation surface. Resolution order in the adapters is a non-empty literal `apiKey` first, then the seam, then — only without a mounted seam — the named raw environment variable.
**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and `cordis.yml` entry as the composition `base`. `resolveAdapterOptions` and `resolveProfiles` remain the explicit validation steps, and a bad live snapshot keeps the last good request facts while a bad entry config fails load. pi-ai's `providers` is a non-empty dict keyed by its composition-owned routes; the user layer may override request facts for those routes but cannot add or remove them.
**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and `cordis.yml` entry as the composition `base`. `resolveAdapterOptions` and `resolveProfiles` remain the explicit validation steps. A live snapshot that changes a fixed fact or fails another bound keeps the whole last-good generation, while a bad entry config fails load. pi-ai's `providers` is a non-empty dict keyed by its composition-owned routes; the user layer may override only live request facts for those routes.
## Alternatives considered

View File

@@ -12,11 +12,11 @@ Status: implemented
## 决策
**按请求解析,而非重建 fiber。**适配器接收 options thunk 与按流调用的凭据解析器,不再重建其 fiber。连接、凭据与请求传输事实在操作期间读取,进行中的流则保持其起始事实。密钥缺失会在请求时以 `MISSING_CREDENTIAL` 失败,同时路由保持注册。提供方路由及其重试策略由组合固定,不触发注册替换。
**按请求解析,而非重建 fiber。**适配器接收 options thunk 与按流调用的凭据解析器,不再重建其 fiber。连接、凭据与请求传输事实按流读取,进行中的流则保持其起始事实。模型 catalog能力、上下文、推理reasoning默认值、提供方路由与重试策略由组合固定。密钥缺失会在请求时以 `MISSING_CREDENTIAL` 失败,同时路由保持注册。
**机密是引用,值藏在 `ctx.credentials` 背后。**配置可以携带 `apiKeyEnv: DEEPSEEK_API_KEY`;只读凭据 seam 按操作解析它。`credentials-local` 先检查活跃进程环境,再按需解析 `$DSH_HOME/.env`,既不缓存,也不提供变更接口。适配器内的解析顺序为:非空的字面 `apiKey` 优先,然后是 seam最后仅在未挂载 seam 时读取点名的原始环境变量。
**按插件划分 namespaceschema ≡ `Config`。**每个适配器注册自己的 namespace`llm-deepseek``llm-pi-ai`),采用其插件 `Config` schema并以 `cordis.yml` 配置项为组合 `base``resolveAdapterOptions``resolveProfiles` 仍是显式校验步骤;错误的存活快照会保留最后可用的请求事实,错误的 entry 配置则会加载失败。pi-ai 的 `providers` 是以组合所拥有路由为键的非空字典;用户层可以覆盖这些路由的请求事实,但不能新增或移除路由
**按插件划分 namespaceschema ≡ `Config`。**每个适配器注册自己的 namespace`llm-deepseek``llm-pi-ai`),采用其插件 `Config` schema并以 `cordis.yml` 配置项为组合 `base``resolveAdapterOptions``resolveProfiles` 仍是显式校验步骤。存活快照若更改固定事实或违反其他约束,会整代沿用最后可用设置;错误的 entry 配置则会加载失败。pi-ai 的 `providers` 是以组合所拥有路由为键的非空字典;用户层只能覆盖这些路由的实时请求事实。
## 曾考虑的替代方案

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md
2026-07-30-credential-boundaries-and-atomic-registration.md: 09beda90d4789f951f663a3f9df794d74db2c11e
2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 84f3826ba8a8b89fd5dd616164ac573151b8203c
2026-07-30-credential-boundaries-and-atomic-registration.md: 2a808048bccc619a311e61340b5aeb0f3113030d
2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 4c2147ea0f0d30624500aeda92fb02932a5b0960

View File

@@ -1,4 +1,4 @@
# Agent Note: credential boundaries, whole-snapshot requests, and atomic route registration
# Agent Note: credential boundaries and whole-generation LLM requests
Status: implemented
@@ -10,7 +10,7 @@ English | [中文](2026-07-30-credential-boundaries-and-atomic-registration.zh.m
Review found the credential path leaking across boundaries it had drawn. The shipped surfaces hoisted `$DSH_HOME/.env` into `process.env` before cordis booted, so on the next run `credentials-local` classified every key it had stored itself as a read-only ambient launch override: `describe()` reported `source: 'env'` with `writable: false`, `set`/`unset` rejected as shadowed, and a key stored from the web page or TUI became unrotatable and undeletable while the adapter kept using the value captured at launch. The store's own write path repeated the settings-local defects that same review round fixed (two independent chains, whole-file render from a stale cache), plus editor bugs of its own: a physical line inside another key's quoted multi-line value read as an assignment, CRLF endings degraded to LF, a multi-line entry reported `writable: true` while `set` always threw, and `credentials/updated` was emitted bare after the commit, so one broken observer made a durable write look failed. On the read side, the file's `0600` mode stops other OS users but not the model, whose bash and filesystem tools run as the same user.
Two request-path defects sat beside them. DeepSeek's per-request resolution kept connection facts in a last-good snapshot but re-read the literal `apiKey` from the raw configuration, so a settings generation the resolver rejected could still put its key on the previous generation's endpoint. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed. And its route swap disposed the old registration before creating the new one: a route another adapter owned dropped every existing route, after which the facts cache could equal the registry's, so restoring the working configuration never re-applied.
Request-path defects sat beside them. DeepSeek's per-request resolution kept connection facts in a last-good snapshot but re-read the literal `apiKey` from the raw configuration, so a settings generation the resolver rejected could still put its key on the previous generation's endpoint. Both adapters also read their full settings snapshot during asynchronous model-capability resolution and again at stream dispatch; a change between those reads could pair one generation's reasoning/capability facts with the next generation's endpoint and key. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed.
## Decision
@@ -18,7 +18,7 @@ Two request-path defects sat beside them. DeepSeek's per-request resolution kept
**The stored credential has no boundary against the model, and the READMEs say so.** `0600` under a `0700` directory stops other OS users; the model's bash and filesystem tools run as that same user, and the shipped default confines nothing. What the harness does hold to is narrower and stated as exactly that: no surface hoists the document into `process.env`, and the model is never handed a resolved path to it, so reaching the value takes a deliberate read of a path it was not given. An OS-keychain provider — a store the model's processes cannot read at all — is recorded as the real answer rather than implied by a partial one.
**One request, one generation.** DeepSeek's resolved snapshot carries the credential facts (literal key and reference) beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. A rejected generation now contributes nothing at all. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and the reference. The boot-time credential probe is deleted: it could run before the credentials service mounted and reported every failure as a missing key, while the first request already gives the accurate error.
**One request cannot straddle settings generations.** Model catalog/capability, context, reasoning-default, provider-route, and retry-policy facts are captured from composition. Only connection, credential, and request-transport facts resolve live, once at stream dispatch. A live snapshot that changes any fixed fact is rejected whole, so its endpoint and key cannot combine with capability facts resolved before the change. DeepSeek's accepted snapshot carries the literal key and reference beside the endpoint, and `resolveApiKey` receives that snapshot instead of re-reading configuration. pi-ai defers to provider-native discovery only for a profile naming no credential; a configured reference that misses fails with `MISSING_CREDENTIAL` naming the route and reference.
**Provider routes are composition-owned.** `registerAdapter` binds one non-empty route set to its calling fiber and returns a disposer. Settings cannot create or remove routes or change their captured retry policy, so the registry needs no replacement lifecycle and a bad settings snapshot leaves the composition registration untouched.
@@ -33,4 +33,4 @@ Two request-path defects sat beside them. DeepSeek's per-request resolution kept
## Consequences
The local provider performs a direct environment-then-dotenv read for each resolution; mutation, description, writer locking, and change events are absent. `LlmAdapter` registrants receive an ordinary disposer, and `DeepSeekConnectionOptions` carries credential facts with its endpoint so one rejected settings generation cannot contribute only a key. An OS-keychain provider remains the path to isolating secrets from same-user model tools.
The local provider performs a direct environment-then-dotenv read for each resolution; mutation, description, writer locking, and change events are absent. `LlmAdapter` registrants receive an ordinary disposer. Each adapter captures model/capability defaults from composition and resolves one live connection snapshot at dispatch, so a rejected settings generation cannot contribute only an endpoint or key. An OS-keychain provider remains the path to isolating secrets from same-user model tools.

View File

@@ -1,10 +1,10 @@
# Agent Note: 凭据边界、按整份快照发起的请求与原子路由注册
# Agent Note: 凭据边界与 LLM大语言模型请求的同代一致性
Status: implemented
[English](2026-07-30-credential-boundaries-and-atomic-registration.md) | 中文
> 范围:对[请求级 LLM(大语言模型)配置 seam](2026-07-29-request-level-llm-config-credentials.md)的存储与请求边界修正。后续的[只读凭据与静态路由](../simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md)决策移除了凭据写入、共享原子写入器与可变注册;本 note 负责保留至今的机密边界与整次请求同代规则。
> 范围:对[请求级 LLM 配置 seam](2026-07-29-request-level-llm-config-credentials.md)的存储与请求边界修正。后续的[只读凭据与静态路由](../simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md)决策移除了凭据写入、共享原子写入器与可变注册;本 note 负责保留至今的机密边界与整次请求同代规则。
## 问题
@@ -14,7 +14,7 @@ Status: implemented
在读取一侧,文件的 `0600` 权限挡得住其他 OS 用户,却挡不住模型:它的 bash 与文件系统工具就以同一个用户身份运行。
与之并排的还有两个请求路径缺陷。DeepSeek 的按请求解析把连接事实保存在最后可用快照里,却仍从原始配置重新读取字面 `apiKey`,于是被 resolver 拒绝的那一代设置,照样能把自己的密钥送到上一代的端点上。配置了 `apiKeyEnv` 却解析不到值时pi-ai 会把 `undefined` 交给 SDK让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。而且它的路由替换是先释放旧注册、再创建新注册:只要有一条路由已被别的适配器占有,现有路由就会被全部丢掉,此后事实缓存可能与注册表中的事实相等,于是把配置改回可用状态也不会重新生效。
与之并排的还有请求路径缺陷。DeepSeek 的按请求解析把连接事实保存在最后可用快照里,却仍从原始配置重新读取字面 `apiKey`,于是被 resolver 拒绝的那一代设置,照样能把自己的密钥送到上一代的端点上。两个适配器还会在异步模型能力解析期间读取一次完整 settings 快照并在流派发时再次读取若两次读取之间发生变更一代的推理reasoning能力事实就可能与下一代的端点和密钥拼接在一起。配置了 `apiKeyEnv` 却解析不到值时pi-ai 会把 `undefined` 交给 SDK让 pi-ai 自己的环境发现拿一个毫不相干的提供方密钥完成鉴权——那是另一个租户,账单还悄悄记在它头上。
## 决策
@@ -22,7 +22,7 @@ Status: implemented
**存下的凭据对模型没有边界,而 README 就是这么写的。**`0700` 目录下的 `0600` 挡得住其他 OS 用户;模型的 bash 与文件系统工具正是以同一用户身份运行而已交付的默认值不约束任何东西。harness 真正守住的更窄,也就照这个宽度写下来:没有任何一个面会把该文档提升进 `process.env`模型也从不会拿到它的解析后路径因此要拿到这个值需要刻意去读一条并未交给它的路径。OS 钥匙串keychain提供方——一个模型的进程根本读不到的存储——被记录为真正的答案而不是靠一个残缺的方案去暗示它。
**一次请求,一代设置。**DeepSeek 解析出的快照在端点旁一并携带凭据事实(字面密钥与引用`resolveApiKey` 接收这份快照,而不再重新读取配置。被拒绝的那一代如今完全不再贡献任何东西。只有当一个 profile 完全没有点名凭据时pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名路由与引用。启动时的凭据探测被删除:它可能在凭据服务挂载之前就运行,并把每一种失败都报成密钥缺失,而第一次请求本就会给出准确的错误。
**一次请求不得跨 settings 代取值。**模型 catalog能力、上下文、推理默认值、提供方路由与重试策略均从组合中捕获。只有连接、凭据与请求传输事实实时解析并且只在流派发时解析一次。存活快照若更改任何固定事实就会整代被拒绝因此它的端点与密钥无法同变更前解析出的能力事实组合。DeepSeek 已接受的快照在端点旁一并携带字面密钥与引用,`resolveApiKey` 接收这份快照,而不再重新读取配置。只有当一个 profile 完全没有点名凭据时pi-ai 才交给提供方原生的发现流程;配置了引用却解析不到,就以 `MISSING_CREDENTIAL` 失败,并点名路由与引用。
**提供方路由归组合所有。**`registerAdapter` 把一组非空路由绑定到调用方 fiber并返回释放器。settings 无法创建或移除路由,也无法更改注册时捕获的重试策略,因此注册表无需替换生命周期,错误的 settings 快照也不会影响组合注册。
@@ -37,4 +37,4 @@ Status: implemented
## 后果
本地提供方每次解析都会依次直接读取环境与 dotenv修改、描述、写入锁和变更事件均不存在。`LlmAdapter` 注册方收到普通释放器`DeepSeekConnectionOptions` 将凭据事实与端点一同携带,因此一代被拒绝的 settings 不可能只贡献密钥。OS 钥匙串提供方仍是将机密与同一用户身份下的模型工具隔离的实现路径。
本地提供方每次解析都会依次直接读取环境与 dotenv修改、描述、写入锁和变更事件均不存在。`LlmAdapter` 注册方收到普通释放器。每个适配器从组合中捕获模型/能力默认值,并在派发时解析一份实时连接快照,因此被拒绝的 settings 不可能只贡献端点或密钥。OS 钥匙串提供方仍是将机密与同一用户身份下的模型工具隔离的实现路径。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-31-read-only-credentials-and-static-llm-routes.md
2026-07-31-read-only-credentials-and-static-llm-routes.md: 2ebbea28c2dadeb9482c483247249ed4054749e4
2026-07-31-read-only-credentials-and-static-llm-routes.zh.md: ad21d93bf73ca0063eb97da8e610aa2814ee1726
2026-07-31-read-only-credentials-and-static-llm-routes.md: 3e0d4ceb612379d2ea29068605e0f7c60f1019a4
2026-07-31-read-only-credentials-and-static-llm-routes.zh.md: eca740c6d58d67a11270dba427efbb3b70852d2c

View File

@@ -14,7 +14,7 @@ That speculative closure accounted for much of the feature's runtime and test gr
`ctx.credentials` exposes only branded `CredentialRef` construction and `resolve(ref): Promise<string | undefined>`. `credentials-local` reads the named process environment value, then parses its dotenv file on demand. It owns no mutation, description, event, watcher, cache, editor, or writer lifecycle; externally changing either source is visible to the next resolution.
LLM provider routes and their retry policies are composition-owned. `registerAdapter()` returns a disposer rather than a mutable registration handle. DeepSeek always owns its one route, and pi-ai requires a non-empty configured route map; settings may change request-level facts for those existing routes but cannot create, remove, or retune registrations. The shared CLI composition therefore does not mount an empty pi-ai adapter.
LLM provider routes, model/capability metadata, context limits, reasoning defaults, and retry policies are composition-owned. `registerAdapter()` returns a disposer rather than a mutable registration handle. DeepSeek always owns its one route, and pi-ai requires a non-empty configured route map; settings may change only connection, credential, and request-transport facts for those existing routes. A settings generation that changes a fixed fact is rejected whole. The shared CLI composition therefore does not mount an empty pi-ai adapter.
The optional-settings helper only switches a consumer's source thunk between its composition entry and a live settings scope. Consumers read committed values through that thunk, so the helper needs no update watcher, derived-state callback, or teardown-state mirror. `settings-local` keeps its write protocol private instead of publishing a utility for a second writer that no longer exists.

View File

@@ -14,7 +14,7 @@ Status: implemented
`ctx.credentials` 只暴露品牌化 `CredentialRef` 的构造,以及 `resolve(ref): Promise<string | undefined>``credentials-local` 先读取点名的进程环境值,再按需解析其 dotenv 文件。它不拥有修改、描述、事件、watcher、缓存、编辑器或写入器生命周期从外部更改任一来源都会在下一次解析时生效。
LLM 提供方路由及其重试策略归组合所有。`registerAdapter()` 返回释放器而非可变注册句柄。DeepSeek 始终拥有自身唯一的路由pi-ai 则要求配置一份非空路由映射settings 可以更改这些现有路由的请求级事实,但不能创建、移除或重新调整注册。因此,共享 CLI命令行界面组合不会挂载空的 pi-ai 适配器。
LLM 提供方路由、模型能力元数据、上下文限制、推理reasoning默认值与重试策略归组合所有。`registerAdapter()` 返回释放器而非可变注册句柄。DeepSeek 始终拥有自身唯一的路由pi-ai 则要求配置一份非空路由映射settings 只能更改这些现有路由的连接、凭据与请求传输事实。更改固定事实的 settings 代会整代被拒绝。因此,共享 CLI命令行界面组合不会挂载空的 pi-ai 适配器。
可选 settings 辅助工具只在组合配置项与存活 settings scope 之间切换消费方的来源 thunk。消费方经该 thunk 读取已提交值,因此辅助工具不需要更新 watcher、派生状态回调或拆卸状态镜像。`settings-local` 将自身的写入协议保留为私有实现,不再为一个已不存在的第二写入方公开工具。

View File

@@ -630,17 +630,17 @@ export interface Config {
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`. */
/** Composition-fixed thinking policy; `disabled` limits every conversation request to `off`. */
thinking?: 'enabled' | 'disabled'
/** Default thinking effort (default `high`); `off` disables thinking per request. */
/** Composition-fixed default thinking effort (default `high`); `off` disables thinking per request. */
reasoningEffort?: 'off' | 'high' | 'max'
/** Positive context capacity used when the selected model has no exact value. */
/** Composition-fixed positive context capacity used when the selected model has no exact value. */
defaultContextWindow?: number
/** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */
/** Composition-fixed advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */
models?: DeepSeekCatalogModel[]
/** Maximum provider idle time while one stream read is outstanding (default five minutes). */
streamIdleTimeoutMs?: number
/** Provider-owned model-request retry policy; omission uses normal defaults. */
/** Composition-fixed provider-owned model-request retry policy; omission uses normal defaults. */
retryPolicy?: RetryPolicyConfig
}
@@ -682,7 +682,7 @@ export interface PiAiProviderProfile {
baseURL?: string
/** Provider request headers; Harness attribution wins reserved names. */
headers?: Record<string, string>
/** Provider-neutral pi-ai reasoning level. */
/** Composition-fixed provider-neutral pi-ai reasoning default. */
reasoning?: ModelThinkingLevel
/** Token budgets used by reasoning providers that support them. */
thinkingBudgets?: ThinkingBudgets
@@ -696,7 +696,7 @@ export interface PiAiProviderProfile {
websocketConnectTimeoutMs?: number
/** Maximum provider idle time while one stream read is outstanding. */
streamIdleTimeoutMs?: number
/** Provider-owned model-request retry policy; omission uses normal defaults. */
/** Composition-fixed provider-owned model-request retry policy; omission uses normal defaults. */
retryPolicy?: RetryPolicyConfig
}
```

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md
README.md: 8532dab4731e25b4af777217ad7c5ffad521d924
README.zh.md: 922fb50ae063ae0a1f90db3b915dc55096448a5c
README.md: 9d3014c9d4b3eb4fc2d02920c0cd4577e7977c89
README.zh.md: d7685749376f58012c7d834047dd2eae754e27b8

View File

@@ -47,9 +47,9 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und
## Dynamic configuration (settings + credentials)
Request 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 take effect on the next operation, while an in-flight stream keeps the facts it started with. The `deepseek` route and its retry policy remain fixed by the plugin composition. Two optional seams feed the request facts:
`resolveAdapterOptions` is the explicit resolve step from raw config to validated facts. The adapter reads live connection, credential, and request-transport facts through a thunk once per stream, so base URL, key, and idle budget changes reach the next request while an in-flight stream keeps its starting facts. The provider route, model catalog, context limits, thinking policy, reasoning default, and retry policy are composition-fixed. Two optional seams feed the live facts:
- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`. Without a mounted settings service the entry config alone drives the adapter. 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 request facts and logs the failure; the entry config itself still fails plugin load.
- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`. Without a mounted settings service the entry config alone drives the adapter. A live snapshot that changes a composition-fixed fact or fails a resolver bound is rejected as a whole generation: it contributes neither its changed connection nor credential. 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 rejected settings snapshot contributes neither its endpoint nor its key. A request with no key anywhere fails with `MISSING_CREDENTIAL`; after the operator supplies the named environment or dotenv value, the next request resolves it without a restart.
`ctx.llm.providerRetryPolicy('deepseek')` reports the policy captured from the composition entry at registration.
@@ -72,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. `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, and composition-fixed retry policy), 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.
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, including next-request base-URL/key pickup and a change landing between capability resolution and dispatch; the latter proves a generation that changes composition facts cannot contribute a newer endpoint or key. `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
@@ -106,7 +106,7 @@ 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.
- **Settings cannot change model/capability defaults** — catalog, context limits, thinking policy, reasoning default, and retry policy belong to composition; a settings generation that changes one is rejected whole.
- **`Config.apiKey` is schema-tagged `role('secret')` but not masked by `ctx.settings.describe()`** — do not expose that envelope to an untrusted UI without redacting secret-role fields.
- **`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)`).

View File

@@ -47,9 +47,9 @@ harness LLM大语言模型seam 的 DeepSeek chat-completions 适配器:
## 动态配置settings + credentials
请求事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤适配器经由一个 thunk **每操作重读一次**base URL、catalog、请求默认值与 idle 预算都在下一次操作生效,进行中的流则保持其起始事实。`deepseek` 路由及其重试策略始终由插件组合固定。两个可选 seam 为请求事实供值:
`resolveAdapterOptions` 是从原始配置到已校验事实的显式 resolve 步骤适配器经由一个 thunk 每个流读取一次实时连接、凭据与请求传输事实,因此 base URL、密钥与 idle 预算变更会作用于下一次请求,进行中的流则保持其起始事实。提供方路由、模型 catalog、上下文限制、思考策略、推理默认值与重试策略由组合固定。两个可选 seam 为实时事实供值:
- **`ctx.settings`**:插件用同一份 `Config` schema 注册 `llm-deepseek` namespace并以其 `cordis.yml` 条目为组合 `base`。未挂载 settings 服务时,仅由 entry 配置驱动适配器。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking推理强度组合则保留最后可用的请求事实并记录失败entry 配置本身仍会使插件加载失败。
- **`ctx.settings`**:插件用同一份 `Config` schema 注册 `llm-deepseek` namespace并以其 `cordis.yml` 条目为组合 `base`。未挂载 settings 服务时,仅由 entry 配置驱动适配器。存活快照若更改由组合固定的事实或违反 resolver 约束,会整代被拒绝:其变更后的连接与凭据均不会被采用。entry 配置本身仍会使插件加载失败。
- **`ctx.credentials`**API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后仅在未挂载 seam 时读取原始环境变量。由于凭据事实与连接事实同行,被拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败;操作者为点名的环境变量或 dotenv 值供值后,下一次请求无需重启即可解析它。
`ctx.llm.providerRetryPolicy('deepseek')` 报告注册时从组合配置项捕获的策略。
@@ -72,7 +72,7 @@ harness LLM大语言模型seam 的 DeepSeek chat-completions 适配器:
## 测试
单元套件使用本地 `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 文档中的请求。
单元套件使用本地 `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 文档中的请求。
## 模型体验
@@ -106,7 +106,7 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用
## 已知限制与暂缓事项
- **settings `models` 列表会整体替换组合列表**settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状
- **settings 无法更改模型/能力默认值**catalog、上下文限制、思考策略、推理默认值与重试策略归组合所有settings 若更改其中一项,整代设置都会被拒绝
- **`Config.apiKey` 已在 schema 中标注 `role('secret')`,但未由 `ctx.settings.describe()` 脱敏**:在对 secret 角色字段脱敏之前,不要向不受信任的 UI 暴露该信封。
- **未映射 `tool_choice`**它不属于核心词汇MVP 取舍,与 pi-ai twin 共享)。
- **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy拦截配置采用暂缓到第二个适配器需要该功能时`TODO(http)`)。

View File

@@ -1,9 +1,9 @@
/**
* `DeepSeekAdapter`: fetch + SSE against a DeepSeek (OpenAI-compatible)
* 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.
* transport-only: live connection facts arrive through a thunk resolved once
* per stream, composition-fixed capability/default facts arrive separately,
* and the bearer token comes from a per-request resolver.
*
* @module dsh-llm-deepseek/adapter
*/
@@ -38,10 +38,9 @@ export interface DeepSeekCatalogModel {
}
/**
* 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.
* Validated adapter facts. The plugin's `resolveAdapterOptions` is the explicit
* resolve step producing this shape; the adapter receives one composition
* snapshot plus a per-stream current snapshot.
*/
export interface DeepSeekConnectionOptions {
/** Endpoint base; `/chat/completions` is appended. */
@@ -54,22 +53,24 @@ export interface DeepSeekConnectionOptions {
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). */
/** Composition-fixed request defaults applied to every call (thinking mode, effort). */
defaults: RequestDefaults
/** Positive context capacity used when the selected model has no exact value. */
/** Composition-fixed positive context capacity used when the selected model has no exact value. */
defaultContextWindow?: number
/** Advisory models exposed to discovery consumers; requests remain unrestricted. */
/** Composition-fixed advisory models exposed to discovery consumers; requests remain unrestricted. */
models: readonly DeepSeekCatalogModel[]
/** Maximum provider idle time while one stream read is outstanding. */
streamIdleTimeoutMs: number
/** Provider-owned model-request retry policy, already resolved. */
/** Composition-fixed provider-owned model-request retry policy, already resolved. */
retryPolicy: ResolvedRetryPolicy
}
/** Constructor options for {@link DeepSeekAdapter}: the two resolution seams the plugin owns. */
/** Constructor inputs for {@link DeepSeekAdapter}: live, composition, and credential facts. */
export interface DeepSeekAdapterOptions {
/** Current validated connection facts; called once per operation. */
/** Current validated request facts; called once per stream. */
options: () => DeepSeekConnectionOptions
/** Composition snapshot owning catalog, capability/default, context, and retry facts. */
composition: 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
@@ -154,11 +155,11 @@ export class DeepSeekAdapter extends LlmAdapter {
}
override providerRetryPolicy(_provider: string): ResolvedRetryPolicy {
return this.config.options().retryPolicy
return this.config.composition.retryPolicy
}
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
return Promise.resolve(this.config.options().models.map(model => modelInfo(provider, model)))
return Promise.resolve(this.config.composition.models.map(model => modelInfo(provider, model)))
}
override resolveModel(
@@ -166,16 +167,16 @@ export class DeepSeekAdapter extends LlmAdapter {
model: string,
_signal?: AbortSignal,
): Promise<LlmResolvedModelInfo> {
const connection = this.config.options()
const configured = connection.models.find(entry => entry.id === model)
const composition = this.config.composition
const configured = composition.models.find(entry => entry.id === model)
const contextWindow = configured?.contextWindow
?? connection.defaultContextWindow
?? composition.defaultContextWindow
return Promise.resolve({
...configured === undefined
? { provider, id: model, name: model }
: modelInfo(provider, configured),
...contextWindow === undefined ? {} : { context: { contextWindow } },
...connection.defaults.thinking === 'disabled'
...composition.defaults.thinking === 'disabled'
? {
reasoning: {
efforts: OFF_ONLY_REASONING_EFFORTS,
@@ -185,9 +186,9 @@ export class DeepSeekAdapter extends LlmAdapter {
: {
reasoning: {
efforts: REASONING_EFFORTS,
defaultEffort: connection.defaults.reasoningEffort === 'off'
defaultEffort: composition.defaults.reasoningEffort === 'off'
? OFF_REASONING_EFFORT
: connection.defaults.reasoningEffort === 'max'
: composition.defaults.reasoningEffort === 'max'
? MAX_REASONING_EFFORT
: HIGH_REASONING_EFFORT,
},
@@ -196,9 +197,9 @@ 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.
// One live resolution per stream call: connection, credential, and
// transport facts freeze here and hold for the request. Model capability
// and default facts come from the composition snapshot above.
// 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()
@@ -250,7 +251,7 @@ export class DeepSeekAdapter extends LlmAdapter {
connection: DeepSeekConnectionOptions,
apiKey: string,
): AsyncIterable<StreamChunk> {
const body = serializeRequest(options, connection.defaults)
const body = serializeRequest(options, this.config.composition.defaults)
// Prepared outside the try so the TRANSPORT label below covers exactly the
// transport boundary, never a serialization failure.
const payload = JSON.stringify(body)

View File

@@ -4,9 +4,9 @@
* 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
* registration-captured facts stay composition-fixed.
* base URL, key, or request-transport control reaches the next request without
* restart. Catalog, capability/default, context, and retry facts stay fixed by
* composition.
* @module @deepseek-ai/dsh-llm-deepseek
*/
@@ -15,7 +15,7 @@ import z from 'schemastery'
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 { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
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, DeepSeekConnectionOptions } from './adapter.ts'
@@ -53,17 +53,17 @@ export interface Config {
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`. */
/** Composition-fixed thinking policy; `disabled` limits every conversation request to `off`. */
thinking?: 'enabled' | 'disabled'
/** Default thinking effort (default `high`); `off` disables thinking per request. */
/** Composition-fixed default thinking effort (default `high`); `off` disables thinking per request. */
reasoningEffort?: 'off' | 'high' | 'max'
/** Positive context capacity used when the selected model has no exact value. */
/** Composition-fixed positive context capacity used when the selected model has no exact value. */
defaultContextWindow?: number
/** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */
/** Composition-fixed advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */
models?: DeepSeekCatalogModel[]
/** Maximum provider idle time while one stream read is outstanding (default five minutes). */
streamIdleTimeoutMs?: number
/** Provider-owned model-request retry policy; omission uses normal defaults. */
/** Composition-fixed provider-owned model-request retry policy; omission uses normal defaults. */
retryPolicy?: RetryPolicyConfig
}
@@ -90,10 +90,9 @@ export const Config: z<Config> = z.object({
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.
* One resolution's complete adapter facts. Connection and credential facts
* stay one value, while catalog, capability/default, context, and retry facts
* must equal the composition snapshot.
*/
export type ResolvedDeepSeekOptions = DeepSeekConnectionOptions
@@ -165,8 +164,21 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
}
}
/** Facts that must stay identical to the plugin composition for the route's lifetime. */
function compositionFacts(options: ResolvedDeepSeekOptions): unknown {
return {
defaults: options.defaults,
...options.defaultContextWindow === undefined
? {}
: { defaultContextWindow: options.defaultContextWindow },
models: options.models,
retryPolicy: options.retryPolicy,
}
}
export function apply(ctx: Context, config: Config): void {
const compositionOptions = resolveAdapterOptions(config)
const fixedFacts = compositionFacts(compositionOptions)
let current: () => Config = () => config
let lastRaw: Config = config
let lastGood = compositionOptions
@@ -175,12 +187,17 @@ export function apply(ctx: Context, config: Config): void {
if (raw === lastRaw) return lastGood
try {
const next = resolveAdapterOptions(raw)
if (!deepEqualJson(compositionFacts(next), fixedFacts)) {
throw new Error(
'llm-deepseek: model catalog, capability defaults, context limits, and retry policy are composition-fixed',
)
}
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:
// only sees an invalid live snapshot or one that changes a fixed fact:
// keep serving the last good facts and say so once per bad snapshot.
lastRaw = raw
ctx.logger.error('llm-deepseek: keeping the last good configuration after an invalid settings section')
@@ -211,7 +228,7 @@ export function apply(ctx: Context, config: Config): void {
)
}
const adapter = new DeepSeekAdapter({ options, resolveApiKey })
const adapter = new DeepSeekAdapter({ options, composition: compositionOptions, resolveApiKey })
ctx.llm.registerAdapter([PROVIDER], adapter)
installSettingsSection(ctx, NS, Config, config, {

View File

@@ -34,8 +34,10 @@ async function harness(baseURL: string, config: object = {}) {
/** 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
const composition = resolveAdapterOptions(rest)
return new DeepSeekAdapter({
options: () => resolveAdapterOptions(rest),
options: () => composition,
composition,
resolveApiKey: () => Promise.resolve(apiKey ?? 'k'),
})
}
@@ -882,9 +884,10 @@ describe('plugin registration and config', () => {
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 composition = resolveAdapterOptions({ baseURL: server.url })
const options = vi.fn(() => composition)
const resolveApiKey = vi.fn(() => Promise.resolve('per-request-key'))
const adapter = new DeepSeekAdapter({ options, resolveApiKey })
const adapter = new DeepSeekAdapter({ options, composition, resolveApiKey })
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }

View File

@@ -19,6 +19,7 @@ afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!()
await closeMockServers()
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
async function home(): Promise<string> {
@@ -99,15 +100,13 @@ describe('request-level dynamic configuration', () => {
expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived')
})
it('advertises a live settings catalog without re-registration', async () => {
it('keeps the model catalog composition-fixed', 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' },
])
await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2)
})
it('keeps the registration retry policy composition-fixed', async () => {
@@ -133,33 +132,19 @@ describe('request-level dynamic configuration', () => {
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 () => {
it('rejects a settings generation that combines new composition and connection facts', 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).
// One schema-valid snapshot moves the endpoint and key while also trying
// to replace the composition-owned catalog.
await ctx.settings.update(NS, {
apiKey: 'rejected-key',
baseURL: rejected.url,
models: [{ id: 'dup' }, { id: 'dup' }],
models: [{ id: 'settings-model' }],
})
await prompt(ctx)
@@ -170,6 +155,42 @@ describe('request-level dynamic configuration', () => {
expect(good.headers[0]?.authorization).toBe('Bearer good-key')
})
it('cannot mix earlier capability facts with a later settings connection', async () => {
const dir = await home()
const first = await mockServer([{ kind: 'sse', events: textEvents }])
const second = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await boot(dir, {
apiKey: 'first-key',
baseURL: first.url,
thinking: 'disabled',
reasoningEffort: 'off',
})
const resolveModel = vi.spyOn(LlmDeepSeek.DeepSeekAdapter.prototype, 'resolveModel')
resolveModel.mockImplementation(async function (
this: LlmDeepSeek.DeepSeekAdapter,
provider,
model,
signal,
) {
resolveModel.mockRestore()
const resolved = await this.resolveModel(provider, model, signal)
// Land a complete settings generation after capability resolution but
// before stream dispatch. Its changed composition fact rejects it whole.
await ctx.settings.update(NS, {
apiKey: 'second-key',
baseURL: second.url,
thinking: 'enabled',
reasoningEffort: 'max',
})
return resolved
})
await prompt(ctx)
expect(second.requests).toHaveLength(0)
expect(first.headers[0]?.authorization).toBe('Bearer first-key')
expect(first.requests[0]).toMatchObject({ thinking: { type: 'disabled' } })
})
it('falls back to the composition entry when settings detach', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md
README.md: e8b7adf122946fc22f231fafb521866cbacdc652
README.zh.md: 5bbf034267f6e276bc6552c5e731f6369cd97aee
README.md: 972dcaed2ca18e0bef9c7c91ba1d3f518236a756
README.zh.md: 40d5794b6d4dac3bf31e54bd6e570407b72eaa9f

View File

@@ -39,15 +39,15 @@ Each dict key must exist in pi-ai's installed catalog; the dict shape makes dupl
## Dynamic configuration (settings + credentials)
The adapter reads its profiles through a thunk **once per operation** instead of freezing request facts 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`. The user layer can override request-level fields of a composition route, such as its endpoint, credential reference, headers, or transport controls, effective on the next operation. Provider routes and retry policies remain composition-fixed; a settings snapshot that changes either is rejected as one generation. Without a mounted settings service the entry config alone drives the adapter.
The adapter reads live connection, credential, and request-transport facts through a thunk once per stream. 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`. The user layer can override a composition route's endpoint, credential reference, headers, budgets, cache/transport choices, and timeouts for the next request. Provider routes, installed model capabilities, reasoning defaults, and retry policies remain composition-fixed; a settings snapshot that changes a fixed fact is rejected as one generation. Without a mounted settings service the entry config alone drives the adapter.
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. A live settings snapshot that changes registration facts, names an unknown provider, or fails another resolver bound keeps the last good profiles and logs the failure; the entry config itself fails plugin load.
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. A live settings snapshot that changes a fixed fact, names an unknown provider, or fails another resolver bound keeps the last good profiles and logs the failure; none of its connection or credential facts leak into a request. The entry config itself 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`.
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 composition profile's `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 `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.
Supported profile fields are `apiKey`, `apiKeyEnv`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. `reasoning` and `retryPolicy` are composition facts; the other fields are live request facts. Each optional retry policy is captured with its provider route, and 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`.
@@ -77,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. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers: endpoint and `apiKeyEnv` changes reach later requests while routes and retry policy stay composition-fixed. `tests/loader-composition.spec.ts` boots that chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. 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: endpoint and credential changes reach later requests, while a change landing between capability resolution and dispatch cannot combine an earlier reasoning default with a newer endpoint or key. `tests/loader-composition.spec.ts` boots that chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage remains key-gated under `pnpm run test:e2e`.
## Model Experience
@@ -111,7 +111,7 @@ Recorded response content appends to the next request and does not invalidate it
## Known Limitations and Deferred Work
- **Settings cannot add or remove routes** — provider ownership and retry policy are composition facts; the user layer can only change request-level fields of existing routes.
- **Settings cannot change routes or model defaults** — provider ownership, installed model capabilities, reasoning defaults, and retry policy are composition facts; the user layer can only change connection, credential, and request-transport fields of existing routes.
- **`apiKey` is schema-tagged `role('secret')` but not masked by `ctx.settings.describe()`** — do not expose that envelope to an untrusted UI without redacting secret-role fields.
- **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.

View File

@@ -39,15 +39,15 @@
## 动态配置settings + credentials
适配器经由一个 thunk **每操作读取一次** profile而非在构造期冻结请求事实。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace并以其 `cordis.yml` 条目为组合 `base`。用户层可以覆盖组合路由的请求级字段,例如端点、凭据引用、标头或传输控制项,并在下一次操作生效。提供方路由与重试策略始终由组合固定settings 快照若更改任一项,就会整代被拒绝。未挂载 settings 服务时,仅由 entry 配置驱动适配器。
适配器经由一个 thunk 每个流读取一次实时连接、凭据与请求传输事实。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace并以其 `cordis.yml` 条目为组合 `base`。用户层可以为下一次请求覆盖组合路由的端点、凭据引用、标头、预算、缓存传输选项与超时。提供方路由、已安装模型的能力、推理reasoning默认值与重试策略始终由组合固定settings 快照若更改固定事实,就会整代被拒绝。未挂载 settings 服务时,仅由 entry 配置驱动适配器。
凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile仅限这一种情况才交给 pi-ai 的环境发现。存活 settings 快照若更改注册事实、点名未知提供方或违反其他 resolver 约束,则保留最后可用 profile 并记录失败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`
`reasoning.efforts` 列表是 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh``max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID提供方模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。因此,不具备推理能力的模型也会公开 pi-ai 的 `off` 选项。组合 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`
受支持的 profile 字段是 `apiKey``apiKeyEnv``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``reasoning``retryPolicy` 属于组合事实,其他字段属于实时请求事实。每个可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。
适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries``maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent智能体级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`
@@ -77,7 +77,7 @@ pi-ai 会安装多个提供方 SDK并延迟加载 catalog 模型所选的 SDK
## 测试
单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型覆盖提供方profile 路由、每次适配器调用只发起一个协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider端点与 `apiKeyEnv` 变更会作用于后续请求,而路由与重试策略始终由组合固定`tests/loader-composition.spec.ts` 从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起该链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 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端点与凭据变更会作用于后续请求,而落在能力解析与派发之间的变更无法把较早一代的推理默认值与较新一代的端点或密钥拼接起来`tests/loader-composition.spec.ts` 从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起该链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖仍需 key 才会启用,并通过 `pnpm run test:e2e` 运行。
## 模型体验
@@ -111,7 +111,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish
## 已知限制与暂缓事项
- **settings 无法新增或移除路由**:提供方所有权与重试策略属于组合事实;用户层只能更改现有路由的请求级字段。
- **settings 无法更改路由或模型默认值**:提供方所有权、已安装模型的能力、推理默认值与重试策略属于组合事实;用户层只能更改现有路由的连接、凭据与请求传输字段。
- **`apiKey` 已在 schema 中标注 `role('secret')`,但未由 `ctx.settings.describe()` 脱敏**:在对 secret 角色字段脱敏之前,不要向不受信任的 UI 暴露该信封。
- **必须属于 catalog**:已安装 pi-ai catalog 中不存在的自定义模型 id 会以 `UNKNOWN_MODEL` 失败,即使提供方 profile 配置了自定义端点。
- **不支持 `GenerateOptions.stop`**pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence因此适配器会拒绝该字段。

View File

@@ -34,10 +34,12 @@ import type { ResolvedPiAiProviderProfile } from './config.ts'
import { toPiContext } from './context.ts'
import { toStreamChunks } from './stream.ts'
/** Constructor options for {@link PiAiAdapter}: the two resolution seams the plugin owns. */
/** Constructor inputs for {@link PiAiAdapter}: live, composition, and credential facts. */
export interface PiAiAdapterOptions {
/** Current validated profiles by provider route; called once per operation. */
/** Current validated request profiles by provider route; called once per stream. */
profiles: () => ReadonlyMap<string, ResolvedPiAiProviderProfile>
/** Composition snapshot owning routes, model capabilities, reasoning defaults, and retry policies. */
compositionProfiles: 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
@@ -117,11 +119,11 @@ export class PiAiAdapter extends LlmAdapter {
}
override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined {
return this.config.profiles().get(provider)?.retryPolicy
return this.config.compositionProfiles.get(provider)?.retryPolicy
}
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
const profile = this.config.profiles().get(provider)
const profile = this.config.compositionProfiles.get(provider)
if (profile === undefined) {
return Promise.reject(new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER'))
}
@@ -137,7 +139,7 @@ export class PiAiAdapter extends LlmAdapter {
model: string,
_signal?: AbortSignal,
): Promise<LlmResolvedModelInfo> {
const profile = this.config.profiles().get(provider)
const profile = this.config.compositionProfiles.get(provider)
if (profile === undefined) {
return Promise.reject(new LlmError(
`pi-ai adapter does not own provider "${provider}"`,
@@ -170,17 +172,18 @@ export class PiAiAdapter extends LlmAdapter {
if (options.stop !== undefined) {
throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION')
}
// 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.
// One live resolution per stream call: connection, credential, and
// transport facts freeze here and hold for the request. Capability and
// default reasoning facts come from the composition snapshot.
const profile = this.config.profiles().get(options.provider)
if (profile === undefined) {
const compositionProfile = this.config.compositionProfiles.get(options.provider)
if (profile === undefined || compositionProfile === undefined) {
throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER')
}
const model = resolvePiModel(profile, options.model)
const reasoning = resolveReasoningLevel(
model,
options.reasoningEffort ?? profile.reasoning,
options.reasoningEffort ?? compositionProfile.reasoning,
)
const apiKey = await this.config.resolveApiKey(options.provider, profile)

View File

@@ -28,7 +28,7 @@ export interface PiAiProviderProfile {
baseURL?: string
/** Provider request headers; Harness attribution wins reserved names. */
headers?: Record<string, string>
/** Provider-neutral pi-ai reasoning level. */
/** Composition-fixed provider-neutral pi-ai reasoning default. */
reasoning?: ModelThinkingLevel
/** Token budgets used by reasoning providers that support them. */
thinkingBudgets?: ThinkingBudgets
@@ -42,7 +42,7 @@ export interface PiAiProviderProfile {
websocketConnectTimeoutMs?: number
/** Maximum provider idle time while one stream read is outstanding. */
streamIdleTimeoutMs?: number
/** Provider-owned model-request retry policy; omission uses normal defaults. */
/** Composition-fixed provider-owned model-request retry policy; omission uses normal defaults. */
retryPolicy?: RetryPolicyConfig
}

View File

@@ -3,9 +3,9 @@
* 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 request knob
* reaches the next request without a restart. Provider routes and retry
* policies stay composition-fixed.
* optional credential seam, so a changed key, endpoint, or request-transport
* knob reaches the next request without a restart. Provider routes, model
* capabilities, reasoning defaults, and retry policies stay composition-fixed.
*
* ```yaml
* - id: llm
@@ -45,20 +45,24 @@ export const inject = ['llm']
const NS = settingsNamespace('llm-pi-ai')
/**
* The registry captures these per route; a change here must re-register.
* Composition captures these per route; a settings change cannot alter them.
* 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 {
function compositionFacts(profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>): unknown {
return [...profiles.entries()]
.map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy }))
.map(([provider, profile]) => ({
provider,
reasoning: profile.reasoning,
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 compositionProfiles = resolveProfiles(config.providers)
const compositionFacts = registrationFacts(compositionProfiles)
const fixedFacts = compositionFacts(compositionProfiles)
let current: () => Config = () => config
let lastRaw: Config = config
let lastGood: ReadonlyMap<string, ResolvedPiAiProviderProfile> = compositionProfiles
@@ -67,15 +71,15 @@ export function apply(ctx: Context, config: Config): void {
if (raw === lastRaw) return lastGood
try {
const next = resolveProfiles(raw.providers)
if (!deepEqualJson(registrationFacts(next), compositionFacts)) {
throw new Error('llm-pi-ai: provider routes and retry policies are composition-fixed')
if (!deepEqualJson(compositionFacts(next), fixedFacts)) {
throw new Error('llm-pi-ai: provider routes, reasoning defaults, and retry policies are composition-fixed')
}
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:
// only sees an invalid live snapshot or one that changes a fixed fact:
// keep serving the last good profiles and say so once per bad snapshot.
lastRaw = raw
ctx.logger.error('llm-pi-ai: keeping the last good profiles after an invalid settings section')
@@ -111,7 +115,7 @@ export function apply(ctx: Context, config: Config): void {
)
}
const adapter = new PiAiAdapter({ profiles, resolveApiKey })
const adapter = new PiAiAdapter({ profiles, compositionProfiles, resolveApiKey })
ctx.llm.registerAdapter([...compositionProfiles.keys()], adapter)
installSettingsSection(ctx, NS, Config, config, {

View File

@@ -25,8 +25,10 @@ async function harness(baseURL: string, overrides: Record<string, unknown> = {})
/** Direct adapter over the real profile resolver, with literal-key resolution. */
function adapterOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>): PiAiAdapter {
const compositionProfiles = resolveProfiles(providers)
return new PiAiAdapter({
profiles: () => resolveProfiles(providers),
profiles: () => compositionProfiles,
compositionProfiles,
resolveApiKey: (_provider, profile) => Promise.resolve(profile.apiKey),
})
}

View File

@@ -18,6 +18,7 @@ afterEach(async () => {
while (cleanups.length > 0) await cleanups.pop()!()
await closeMockServers()
vi.unstubAllEnvs()
vi.restoreAllMocks()
})
async function home(): Promise<string> {
@@ -97,4 +98,45 @@ describe('request-level dynamic profiles', () => {
jitterRatio: 0.2,
})
})
it('cannot mix earlier capability facts with a later settings connection', async () => {
const dir = await home()
const first = await mockServer([{ events: textEvents }])
const second = await mockServer([{ events: textEvents }])
const ctx = await boot(dir, {
providers: {
deepseek: {
apiKey: 'first-key',
baseURL: first.url,
reasoning: 'off',
},
},
})
const resolveModel = vi.spyOn(LlmPiAi.PiAiAdapter.prototype, 'resolveModel')
resolveModel.mockImplementation(async function (
this: LlmPiAi.PiAiAdapter,
provider,
model,
signal,
) {
resolveModel.mockRestore()
const resolved = await this.resolveModel(provider, model, signal)
// Land a complete settings generation after capability resolution but
// before stream dispatch. Its changed reasoning default rejects it whole.
await ctx.settings.update(NS, {
providers: {
deepseek: {
apiKey: 'second-key',
baseURL: second.url,
reasoning: 'max',
},
},
})
return resolved
})
await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
expect(second.requests).toHaveLength(0)
expect(first.headers[0]?.authorization).toBe('Bearer first-key')
})
})

View File

@@ -22,8 +22,10 @@ describe('pi-ai SDK retry boundary', () => {
throw failure
},
})
const compositionProfiles = resolveProfiles({ openai: { apiKey: 'test-key' } })
const adapter = new PiAiAdapter({
profiles: () => resolveProfiles({ openai: { apiKey: 'test-key' } }),
profiles: () => compositionProfiles,
compositionProfiles,
resolveApiKey: () => Promise.resolve('test-key'),
})
const drain = async (): Promise<void> => {