mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(llm): keep request generations coherent
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 .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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 时读取点名的原始环境变量。
|
||||
|
||||
**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),采用其插件 `Config` schema,并以 `cordis.yml` 配置项为组合 `base`。`resolveAdapterOptions` 与 `resolveProfiles` 仍是显式校验步骤;错误的存活快照会保留最后可用的请求事实,错误的 entry 配置则会加载失败。pi-ai 的 `providers` 是以组合所拥有路由为键的非空字典;用户层可以覆盖这些路由的请求事实,但不能新增或移除路由。
|
||||
**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),采用其插件 `Config` schema,并以 `cordis.yml` 配置项为组合 `base`。`resolveAdapterOptions` 与 `resolveProfiles` 仍是显式校验步骤。存活快照若更改固定事实或违反其他约束,会整代沿用最后可用设置;错误的 entry 配置则会加载失败。pi-ai 的 `providers` 是以组合所拥有路由为键的非空字典;用户层只能覆盖这些路由的实时请求事实。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 钥匙串提供方仍是将机密与同一用户身份下的模型工具隔离的实现路径。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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` 将自身的写入协议保留为私有实现,不再为一个已不存在的第二写入方公开工具。
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)`).
|
||||
|
||||
@@ -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)`)。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,因此适配器会拒绝该字段。
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
Reference in New Issue
Block a user