docs: bilingual credentials/settings-consumer documentation, catalogs, and gates
New credentials data-structure page (type-equiv manifested), group README, rewritten llm-deepseek/llm-pi-ai READMEs (dynamic configuration, dict profiles, credential chain), capability-seams/service-role registration, Agent Note (bilingual), demo compositions mounting settings-local + credentials-local with no inline key plumbing, installSettingsSection consumer helper on the settings seam (deduplicating both adapters' wiring), jscpd symmetry markers for the provider twins, runtime-closure additions for python/sdk-runtime, and doc-budget ceilings AGENTS.md 1750→1755 / packages/README.md 850→865 for the structural one-line group rows.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md
|
||||
README.md: 7a2314ea2ca0fb6606310a4961fcbc658240a7b8
|
||||
README.zh.md: 523bbbfd29b4598c024a1fff4a7121a7cb88bf41
|
||||
README.md: 88f4fd7c017a5dbb070bdaf8ee47bb5610b23303
|
||||
README.zh.md: 5331a4d44c08e2fc4a5f8486128079d9b01e8454
|
||||
|
||||
@@ -14,8 +14,9 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback
|
||||
baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com
|
||||
apiKeyEnv: DEEPSEEK_API_KEY # default; resolved per request via ctx.credentials, then the environment
|
||||
# apiKey: … # literal escape hatch; prefer the reference so no secret enters this file
|
||||
baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted
|
||||
thinking: enabled # optional; provider default is enabled
|
||||
reasoningEffort: high # optional; off | high | max — omitted ⇒ high
|
||||
streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default
|
||||
@@ -44,6 +45,15 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und
|
||||
|
||||
`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers the configured policy as provider metadata, and `dsh-llm-retry` separately executes it at durable agent-step boundaries.
|
||||
|
||||
## Dynamic configuration (settings + credentials)
|
||||
|
||||
Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk:
|
||||
|
||||
- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load.
|
||||
- **`ctx.credentials`** — the API key resolves per stream call: 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. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between.
|
||||
|
||||
The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek')` always reports the current policy.
|
||||
|
||||
## App attribution
|
||||
|
||||
Every request carries the shared attribution header from dsh-llm's `attributionHeaders()` - the mandatory `User-Agent` baseline identifying the harness (see [dsh-llm § App attribution](../llm/README.md#app-attribution-attributionts)). Direct DeepSeek requests and OpenAI-compatible gateway requests get no provider-specific app-attribution headers under this adapter contract; OpenRouter app attribution is deferred to a future explicit OpenRouter adapter or mode. A request whose `GenerateOptions.purpose` is `compaction` (dsh-compact-basic's auxiliary summarization call) additionally carries `x-deepseek-harness-compact: 1`, so the host can separate compaction traffic from conversation requests.
|
||||
@@ -62,7 +72,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA`
|
||||
|
||||
## Testing
|
||||
|
||||
Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback.
|
||||
Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers (next-request base-URL/key pickup, literal precedence, keyless onboarding, last-good snapshots, retry-policy re-registration), and `tests/loader-composition.spec.ts` boots the full chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback and a request whose key exists only in a credentials-local document.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -96,6 +106,8 @@ Loop-retained response blocks append to the next request and preserve its earlie
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape.
|
||||
- **`Config.apiKey` is schema-tagged `role('secret')` but not yet masked anywhere** — the settings `describe()` envelope returns values verbatim; the wire/UI layer that must redact secret-role fields ships with the settings RPC surface.
|
||||
- **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin).
|
||||
- **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`).
|
||||
- **Serialization flattens user and tool-result content to text blocks** — plugin-added block types are skipped, and empty tool output crosses the wire as the literal `(no output)`.
|
||||
|
||||
@@ -14,8 +14,9 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback
|
||||
baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com
|
||||
apiKeyEnv: DEEPSEEK_API_KEY # default; resolved per request via ctx.credentials, then the environment
|
||||
# apiKey: … # literal escape hatch; prefer the reference so no secret enters this file
|
||||
baseURL: https://api.deepseek.com # optional; $DEEPSEEK_BASE_URL then the public API when omitted
|
||||
thinking: enabled # optional; provider default is enabled
|
||||
reasoningEffort: high # optional; off | high | max — omitted ⇒ high
|
||||
streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default
|
||||
@@ -44,6 +45,15 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE
|
||||
|
||||
`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在 chunk 间花费的时间。一个稳定 abort 信号会在整个调用中达到请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用精确发起一次提供方请求;它把已配置策略注册为提供方元数据,再由 `dsh-llm-retry` 在持久 agent 步骤边界单独执行该策略。
|
||||
|
||||
## 动态配置(settings + credentials)
|
||||
|
||||
连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk:
|
||||
|
||||
- **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。
|
||||
- **`ctx.credentials`**——API 密钥按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
|
||||
|
||||
唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek')` 始终报告当前策略。
|
||||
|
||||
## 应用归因
|
||||
|
||||
每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,即用于识别 harness 的必需 `User-Agent` 基线(见 [dsh-llm § 应用归因](../llm/README.md#app-attribution-attributionts))。在该适配器契约下,直接 DeepSeek 请求与 OpenAI 兼容 gateway 请求都不会获得提供方特定应用归因标头;OpenRouter 应用归因暂缓到未来的显式 OpenRouter 适配器或模式。`GenerateOptions.purpose` 为 `compaction` 的请求(dsh-compact-basic 的辅助摘要调用)还会携带 `x-deepseek-harness-compact: 1`,让宿主可以将压缩流量与会话请求分开。
|
||||
@@ -62,7 +72,7 @@ harness LLM seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE
|
||||
|
||||
## 测试
|
||||
|
||||
单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,由 key 调节):V4 Flash + V4 Pro,覆盖 thinking 启用/禁用与两种官方 effort 级别,包括 thinking + 工具往返与 reasoning 回传。
|
||||
单元套件使用本地 `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,覆盖 thinking 启用/禁用与两种官方 effort 级别,包括 thinking + 工具往返与 reasoning 回传,以及密钥仅存在于 credentials-local 文档中的请求。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -96,6 +106,8 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **settings 的 `models` 列表会整体替换组合列表**:settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。
|
||||
- **`Config.apiKey` 已在 schema 中标注 `role('secret')`,但尚未在任何地方脱敏**:settings 的 `describe()` 信封原样返回值;负责对 secret 角色字段脱敏的 wire/UI 层将随 settings RPC 面一起交付。
|
||||
- **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。
|
||||
- **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。
|
||||
- **序列化会将 user 与工具结果内容展平为文本块**:会跳过插件添加的块类型,空工具输出会以字面 `(no output)` 跨越协议。
|
||||
|
||||
@@ -17,7 +17,7 @@ import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/ds
|
||||
import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { deepEqualJson, 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'
|
||||
@@ -231,18 +231,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.logger.warn('llm-deepseek: no API key resolved yet for route "deepseek"; requests will fail until one is configured')
|
||||
})
|
||||
|
||||
ctx.inject(['settings'], (sctx) => {
|
||||
const scope = sctx.settings.register(NS, Config, { base: config })
|
||||
current = () => scope.get()
|
||||
sctx.effect(() => () => {
|
||||
// Settings detached (provider disposed or reloading): fall back to the
|
||||
// composition entry so the plugin keeps working exactly as configured.
|
||||
current = () => config
|
||||
ensureRegistrationFacts()
|
||||
})
|
||||
ensureRegistrationFacts()
|
||||
scope.watch(() => {
|
||||
ensureRegistrationFacts()
|
||||
})
|
||||
installSettingsSection(ctx, NS, Config, config, {
|
||||
setSource: (source) => {
|
||||
current = source
|
||||
},
|
||||
onChange: ensureRegistrationFacts,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user