Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md
#	.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md
#	THIRD_PARTY_NOTICES.md
#	apps/cli/composition.md
#	apps/cli/config/base.cordis.yml
#	apps/cli/package.json
#	apps/cli/src/app-cli-entry.ts
#	apps/cli/src/bin.ts
#	apps/cli/tests/args.spec.ts
#	apps/web/tests/built-boot.snapshot.ts
#	apps/web/tests/navigation-panes.e2e.ts
#	docs/architecture.i18n.yaml
#	docs/architecture.md
#	docs/architecture.zh.md
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	packages/README.i18n.yaml
#	packages/bundle/README.i18n.yaml
#	packages/client/connection/README.i18n.yaml
#	packages/client/connection/README.md
#	packages/client/connection/README.zh.md
#	packages/client/connection/src/client/fixture.ts
#	packages/client/connection/src/http-bridge.ts
#	packages/client/connection/src/index.ts
#	packages/client/connection/tests/fixture.spec.ts
#	packages/client/connection/tests/node-half.spec.ts
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/README.md
#	packages/client/runtime/README.zh.md
#	packages/client/runtime/src/client/contract/session.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/README.md
#	packages/client/ui-conversation/README.zh.md
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/chat/MessageItem.module.css
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/src/client/input/contract.ts
#	packages/client/ui-conversation/src/client/input/facade.ts
#	packages/client/ui-conversation/src/client/input/hub.ts
#	packages/client/ui-conversation/src/client/locales.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/input-bar.spec.tsx
#	packages/client/ui-conversation/tests/input-matrix.spec.tsx
#	packages/client/ui-conversation/tests/input-scenarios.spec.tsx
#	packages/client/ui-conversation/tests/service-orchestration.spec.ts
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/client/ui-trajectory/tests/views.spec.tsx
#	packages/compact/compact-basic/README.i18n.yaml
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/README.md
#	packages/host/apiproxy/README.zh.md
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/rpc.ts
#	packages/host/apiproxy/src/api/sessions.ts
#	packages/host/apiproxy/src/index.ts
#	packages/host/apiproxy/tests/api-proxy-models.spec.ts
#	packages/host/apiproxy/tests/rpc-schemas.spec.ts
#	packages/llm/llm-pi-ai/README.i18n.yaml
#	packages/llm/llm-pi-ai/README.md
#	packages/llm/llm-pi-ai/README.zh.md
#	packages/llm/llm-pi-ai/src/adapter.ts
#	packages/llm/llm/README.i18n.yaml
#	packages/ui/tui/README.md
#	packages/ui/tui/README.zh.md
#	packages/ui/tui/src/components/content.ts
#	packages/ui/tui/src/components/transcript.ts
#	packages/ui/tui/tests/tui.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-08-08 01:56:29 +08:00
3973 changed files with 144864 additions and 60887 deletions
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md
README.md: 020aa65073495526be3f32912b7cd06667c52a2e
README.zh.md: 4c655e90ba00340c056f6ac16159621f7a8c1ddb
README.md: c6435d0bdfbb9758b6f86ef38e94159a9ccdc36d
README.zh.md: f37286ade023ceecf6bfc87eb08fd4d80a5a3912
+3 -7
View File
@@ -40,7 +40,7 @@ The plugin registers the single provider route `deepseek-official` together with
`contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek-official', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. The adapter default is 1,000,000; pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek-official` throws `LlmError('DUPLICATE_ADAPTER')`.
`maxTokens` is the adapter-configured output cap for conversation requests and defaults to 256,000. Exact-model resolution exposes it as `defaultMaxTokens`; `LlmService` materializes that value into `GenerateOptions.maxTokens` before the agent loop writes `request/header`, so the wire request remains reconstructable. An explicit request or `AgentOptions.maxTokens` value wins and is serialized as `max_tokens`. The adapter does not clamp this request budget against `contextWindow`; deployments with a smaller context or provider output limit must configure a compatible `maxTokens`.
`maxTokens` is the adapter-configured output cap for conversation requests and defaults to 256,000. A catalog entry may carry its own `maxTokens`, which wins for that model; an entry without one, and any unlisted pass-through id, resolve to the profile value, so adding a per-model cap changes one model rather than the route. Exact-model resolution exposes the winner as `defaultMaxTokens`; `LlmService` materializes that value into `GenerateOptions.maxTokens` before the agent loop writes `request/header`, so the wire request remains reconstructable. An explicit request or `AgentOptions.maxTokens` value wins and is serialized as `max_tokens`. The adapter does not clamp this request budget against `contextWindow`; deployments with a smaller context or provider output limit must configure a compatible `maxTokens`.
The same exact-model result exposes ordered `off`, `high`, and `max` efforts under `reasoning` for every pass-through model when deployment policy permits thinking. `reasoningEffort` selects the deployment default and falls back to `high` when omitted. `agent/request` can replace it on each conversation step; the resolved value is logged in `request/header`. `high` and `max` enable thinking and serialize as the official top-level `reasoning_effort`; adapter-owned `off` instead serializes `thinking.type: disabled` and omits `reasoning_effort`. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O.
@@ -53,7 +53,7 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und
Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk:
- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load.
- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between.
- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a trimmed, 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. Whitespace-only literals are absent rather than Authorization values. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. Every key is format-checked before use — a literal at connection-facts resolution (plugin load, or the next settings snapshot), a stored or ambient value at request time — so a value no HTTP header can carry is refused there instead of surfacing as an opaque `fetch` `TypeError`; the request-time check throws `LlmError('INVALID_CREDENTIAL')` naming the failing entry point but never any part of the key. 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-official')` always reports the current policy.
@@ -63,7 +63,7 @@ The plugin also declares its route in the configurable-provider directory (`ctx.
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.
## Wire-format notes (verified live + against the official docs)
## Wire-format notes
- Streaming only (`stream_options.include_usage` always on). `usage` may arrive attached to the finish chunk or as a trailing usage-only chunk — the translator defers both to `[DONE]`, so `usage` always precedes `finish` and nothing follows `finish`.
- The adapter-owned `off` effort maps to `thinking: {type: 'disabled'}` and never crosses the wire as `reasoning_effort: 'off'`.
@@ -75,10 +75,6 @@ Every request carries the shared attribution header from dsh-llm's `attributionH
Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_<status>` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks, and a completed stream whose `stop` (or absent) finish opened no content blocks becomes a `finish {kind: 'error'}` with code `EMPTY_RESPONSE` (retried by default policy).
## 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, 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
### DeepSeek request
+4 -8
View File
@@ -4,7 +4,7 @@
harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:直接 `fetch` + SSE(Server-Sent Events,由 `eventsource-parser` 分帧),将官方协议格式(wire format;真源:API 文档 guides/thinking_mode、guides/tool_calls、api/create-chat-completion)转换为 `StreamChunk` 协议。
同一 seam 的第二个基于库的实现位于 `@deepseek-ai/dsh-llm-pi-ai`。本包(package)拥有 `deepseek-official` 提供方路由——刻意区别于 pi-ai 的 catalog 名称 `deepseek`,因此同一组合可以并排挂载两条 DeepSeek 路径;而为 `deepseek-official` 本身注册另一个适配器仍会抛出 `LlmError('DUPLICATE_ADAPTER')`。
同一 seam 的第二个基于库的实现位于 `@deepseek-ai/dsh-llm-pi-ai`。本包拥有 `deepseek-official` 提供方路由——刻意区别于 pi-ai 的 catalog 名称 `deepseek`,因此同一组合可以并排挂载两条 DeepSeek 路径;而为 `deepseek-official` 本身注册另一个适配器仍会抛出 `LlmError('DUPLICATE_ADAPTER')`。
包根入口导出 Cordis 插件契约与 `DeepSeekAdapter`;协议序列化、SSE 解析与分片转换 helper 不属于该根契约。
@@ -40,7 +40,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
`contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek-official', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。适配器默认值为 1,000,000;因此,压力敏感插件可以获得由部署决定的容量,不会将模型 selector 视为权威。为 `deepseek-official` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。
`maxTokens` 是适配器为对话请求配置的输出上限,默认值为 256,000。确切模型解析会将其公开为 `defaultMaxTokens`;`LlmService` 会在 agent loop(智能体循环)写入 `request/header` 前,将该值填入 `GenerateOptions.maxTokens`,从而仍可根据持久记录重建协议请求。显式的请求值或 `AgentOptions.maxTokens` 值优先,并会序列化为 `max_tokens`。适配器不会根据 `contextWindow` 自动调低该请求预算;上下文或提供方输出上限较小的部署必须配置与其相容的 `maxTokens`。
`maxTokens` 是适配器为对话请求配置的输出上限,默认值为 256,000。Catalog 配置项可以自带 `maxTokens`,它对该模型胜出;不含该上限的配置项以及任何未列出原样传递 id 都解析为 profile 值,因此新增按模型的上限只改变一个模型,而非整条路由。确切模型解析会将胜出值公开为 `defaultMaxTokens`;`LlmService` 会在 agent loop(智能体循环)写入 `request/header` 前,将该值填入 `GenerateOptions.maxTokens`,从而仍可根据持久记录重建协议请求。显式的请求值或 `AgentOptions.maxTokens` 值优先,并会序列化为 `max_tokens`。适配器不会根据 `contextWindow` 自动调低该请求预算;上下文或提供方输出上限较小的部署必须配置与其相容的 `maxTokens`。
同一确切模型结果会在部署策略允许思考时,为每个原样传递模型在 `reasoning` 下公开有序的 `off`、`high` 和 `max` 推理(reasoning)强度。`reasoningEffort` 选择部署默认值,省略时回退为 `high`。`agent/request` 可以在每个会话步骤替换它;解析后的值会记录在 `request/header`。`high` 和 `max` 会启用思考,并序列化为官方顶层 `reasoning_effort`;适配器持有的 `off` 则序列化为 `thinking.type: disabled`,且省略 `reasoning_effort`。不支持的值会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。
@@ -53,7 +53,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk:
- **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。
- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:去除首尾空白后非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。纯空白字面值会被视为缺失,而不会成为 Authorization 值。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。每个密钥在使用前都会被校验格式——字面量在连接事实解析时(插件加载或下一次 settings 快照)校验,已存储的值或环境变量值则在请求时校验——因此 HTTP 标头无法承载的值会在这一步被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;请求时校验会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的入口,但绝不透露密钥的任何部分。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。
@@ -63,7 +63,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
每个请求都携带 dsh-llm `attributionHeaders()` 的共享归因标头,即用于识别 harness 的必需 `User-Agent` 基线(见 [dsh-llm § 应用归因](../llm/README.md#app-attribution-attributionts))。在该适配器契约(adapter contract)下,直接 DeepSeek 请求与 OpenAI 兼容 gateway 请求都不会获得提供方特定应用归因标头;OpenRouter 应用归因暂缓到未来的显式 OpenRouter 适配器或模式。`GenerateOptions.purpose` 为 `compaction` 的请求(dsh-compact-basic 的辅助摘要调用)还会携带 `x-deepseek-harness-compact: 1`,让宿主可以将压缩流量与会话请求分开。
## 协议格式说明(已通过实时请求与官方文档验证)
## 协议格式说明
- 只支持流式输出(`stream_options.include_usage` 始终开启)。`usage` 可能附着在 finish 分片上,也可能作为尾随的纯 usage 分片到达;转换器会将两者都延迟到 `[DONE]`,因此 `usage` 始终位于 `finish` 之前,`finish` 之后不会出现任何内容。
- 适配器持有的 `off` 推理强度映射为 `thinking: {type: 'disabled'}`,绝不会以 `reasoning_effort: 'off'` 通过协议发送。
@@ -75,10 +75,6 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
非 2xx 响应会抛出稳定 code 的 `LlmError`:`AUTH`(401/403)、`QUOTA`(提供方详细信息标识配额、余额或点数耗尽的响应)、`RATE_LIMIT`(其他 429)、`CONTEXT_WINDOW_EXCEEDED`(提供方 code、type 或 message 标识上下文溢出的 400)、`INVALID_REQUEST`(其他 400)、`SERVER`(5xx),其他情况为 `HTTP_<status>`。其可序列化 `failure` 保留 HTTP 状态,以及有效的正 `Retry-After` 秒数/日期延迟和存在时的 `x-request-id` / `x-deepseek-request-id`。响应前传输失败(DNS、连接被拒绝、TLS、proxy)会抛出命名已配置端点的 `TRANSPORT`,并将原始拒绝作为 `cause`;调用方 abort 抛出 `ABORTED`,仍以 loop 的取消信号为准。协议违例抛出 `STREAM_CLOSED`(没有 `[DONE]`)或 `MALFORMED_RESPONSE`(JSON payload 格式错误)。未知协议 `finish_reason`(例如 `content_filter`、`insufficient_system_resource`)会变为 `finish {kind: 'error', failure}` 分片;已完成流如果使用 `stop`(或缺失)finish 但没有开启内容块,就会变为 `finish {kind: 'error'}`,code 为 `EMPTY_RESPONSE`(默认策略会重试)。
## 测试
单元套件使用本地 `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 文档中的请求。
## 模型体验
### DeepSeek 请求
+1 -3
View File
@@ -21,9 +21,7 @@
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
+3 -1
View File
@@ -35,6 +35,8 @@ export interface DeepSeekCatalogModel {
description?: string
/** Known combined request/response context capacity; omitted when deployment metadata is unavailable. */
contextWindow?: number
/** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */
maxTokens?: number
}
/**
@@ -186,7 +188,7 @@ export class DeepSeekAdapter extends LlmAdapter {
? { provider, id: model, name: model, inputModalities: ['text' as const] }
: modelInfo(provider, configured),
context: { contextWindow },
defaultMaxTokens: connection.maxTokens,
defaultMaxTokens: configured?.maxTokens ?? connection.maxTokens,
...connection.defaults.thinking === 'disabled'
? {
reasoning: {
+37 -6
View File
@@ -13,7 +13,7 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
import { assertUsableApiKey, LlmError, normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
@@ -58,7 +58,13 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
* reasoning effort resolves to `high`.
*/
export interface Config {
/** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
/**
* Trimmed literal API key; whitespace-only is absent, so it resolves through
* {@link apiKeyEnv} like an omitted one. Prefer {@link apiKeyEnv} to keep
* secrets out of configuration files. {@link resolveAdapterOptions} also
* format-checks what remains: a value no HTTP header can carry fails there
* rather than inside `fetch`.
*/
apiKey?: string
/** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
apiKeyEnv?: string
@@ -68,7 +74,7 @@ export interface Config {
thinking?: 'enabled' | 'disabled'
/** Default thinking effort (default `high`); `off` disables thinking per request. */
reasoningEffort?: 'off' | 'high' | 'max'
/** Default per-request output cap (default 256,000); explicit request values win. */
/** Default per-request output cap (default 256,000); a model's own cap and explicit request values win. */
maxTokens?: number
/** Positive context capacity used when the selected model has no exact value (default 1,000,000). */
defaultContextWindow?: number
@@ -85,6 +91,7 @@ const catalogModel: z<DeepSeekCatalogModel> = z.object({
name: z.string(),
description: z.string(),
contextWindow: z.number().step(1).min(1),
maxTokens: z.number().step(1).min(1),
})
export const Config: z<Config> = z.object({
@@ -125,6 +132,12 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
`llm-deepseek: catalog model "${model.id}" contextWindow must be a positive integer`,
)
}
if (model.maxTokens !== undefined
&& (!Number.isInteger(model.maxTokens) || model.maxTokens <= 0)) {
throw new Error(
`llm-deepseek: catalog model "${model.id}" maxTokens must be a positive integer`,
)
}
if (seen.has(model.id)) throw new Error(`llm-deepseek: duplicate catalog model "${model.id}"`)
seen.add(model.id)
return {
@@ -132,6 +145,7 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee
...model.name === undefined ? {} : { name: model.name },
...model.description === undefined ? {} : { description: model.description },
...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
}
})
}
@@ -166,8 +180,25 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
`llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
)
}
// An absent apiKey is not a failure: it falls through to apiKeyEnv below.
// A supplied one must be usable, so a malformed literal fails here beside
// the other beyond-schema bounds instead of inside `fetch`.
// Absence is not a failure, and a blank literal is absence: both resolve
// through apiKeyEnv below, which is this adapter's defined fallback. (The
// pi-ai adapter refuses a blank one instead, because there absence selects a
// different authentication mode rather than a different source for the same
// key.) What a literal cannot be is unusable: a value no HTTP header can
// carry fails here beside the other beyond-schema bounds, not inside `fetch`.
let apiKey: string | undefined
if (config.apiKey !== undefined) {
const checked = normalizeApiKey(config.apiKey)
if (!checked.ok && checked.reason === 'illegalCharacters') {
throw new Error('llm-deepseek: apiKey contains characters no HTTP header can carry; paste the raw key only')
}
apiKey = checked.ok ? checked.value : undefined
}
return {
...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {},
...apiKey === undefined ? {} : { apiKey },
apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL,
defaults: {
@@ -215,12 +246,12 @@ export function apply(ctx: Context, config: Config): void {
const credentials = ctx.get('credentials')
if (credentials !== undefined) {
const hit = await credentials.resolve(ref)
if (hit !== undefined) return hit.value
if (hit !== undefined) return assertUsableApiKey(hit.value, 'llm-deepseek', ref)
} else {
// Without the seam, keep the historical ambient fallback so a plain
// cordis.yml composition works from the environment alone.
const ambient = process.env[ref]
if (ambient !== undefined && ambient.length > 0) return ambient
if (ambient !== undefined && ambient.length > 0) return assertUsableApiKey(ambient, 'llm-deepseek', ref)
}
throw new LlmError(
`llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials`
+157 -93
View File
@@ -2,8 +2,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { createUserMessage,
CONTEXT_WINDOW_EXCEEDED_CODE,
errorChain,
LlmError,
ProviderRequestId,
QUOTA_EXCEEDED_CODE,
ReasoningEffortId,
@@ -206,18 +204,22 @@ describe('DeepSeekAdapter against a mock server', () => {
})
})
it('rejects a per-request effort before I/O when thinking is disabled', async () => {
it('reports a per-request effort failure before I/O when thinking is disabled', async () => {
const server = await mockServer([])
const ctx = await harness(server.url, { thinking: 'disabled' })
await expect(assemble(ctx, {
const result = await assemble(ctx, {
model: 'deepseek-v4-flash',
reasoningEffort: ReasoningEffortId('high'),
messages: [createUserMessage({
content: [{ type: 'text', text: 'hi' }],
source: { kind: 'plugin', plugin: 'test' },
})],
})).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' })
})
expect(result.finish).toMatchObject({
kind: 'error',
failure: { code: 'UNSUPPORTED_REASONING_EFFORT' },
})
expect(server.requests).toHaveLength(0)
})
@@ -250,23 +252,22 @@ describe('DeepSeekAdapter against a mock server', () => {
[400, 'INVALID_REQUEST'],
[500, 'SERVER'],
[503, 'SERVER'],
])('maps HTTP %d to LlmError code %s with the body message', async (status, code) => {
])('maps HTTP %d to failure code %s with the body message', async (status, code) => {
const behavior: Behavior = {
kind: 'http-error',
status,
body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }),
}
const server = await mockServer([behavior, behavior])
const server = await mockServer([behavior])
const ctx = await harness(server.url)
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(`failed with ${status}`)
await expect(
assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
.catch((error: unknown) => (error as LlmError).code),
).resolves.toBe(code)
const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
expect(result.finish).toEqual({
kind: 'error',
failure: { message: `failed with ${status}`, code, status },
})
})
it('classifies a thrown HTTP context-window rejection with the canonical code', async () => {
it('classifies an HTTP context-window failure with the canonical code', async () => {
const server = await mockServer([{
kind: 'http-error',
status: 400,
@@ -279,9 +280,11 @@ describe('DeepSeekAdapter against a mock server', () => {
}),
}])
const ctx = await harness(server.url)
const code = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
.catch((error: unknown) => (error as LlmError).code)
expect(code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE)
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(result.finish).toMatchObject({
kind: 'error',
failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE },
})
})
it('retains status, Retry-After seconds, and provider request id as structured facts', async () => {
@@ -292,19 +295,16 @@ describe('DeepSeekAdapter against a mock server', () => {
headers: { 'retry-after': '2', 'x-request-id': 'req-429' },
}])
const ctx = await harness(server.url)
let thrown: unknown
try {
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
} catch (error: unknown) {
thrown = error
}
expect(thrown).toBeInstanceOf(LlmError)
expect((thrown as LlmError).failure).toEqual({
message: 'slow down',
code: 'RATE_LIMIT',
status: 429,
providerRetryAfterMs: 2_000,
requestId: ProviderRequestId('req-429'),
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(result.finish).toEqual({
kind: 'error',
failure: {
message: 'slow down',
code: 'RATE_LIMIT',
status: 429,
providerRetryAfterMs: 2_000,
requestId: ProviderRequestId('req-429'),
},
})
})
@@ -322,16 +322,17 @@ describe('DeepSeekAdapter against a mock server', () => {
},
}])
const ctx = await harness(server.url)
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toMatchObject({
failure: {
message: 'come back later',
code: 'SERVER',
status: 503,
providerRetryAfterMs: 3_000,
requestId: ProviderRequestId('deepseek-503'),
},
})
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(result.finish).toEqual({
kind: 'error',
failure: {
message: 'come back later',
code: 'SERVER',
status: 503,
providerRetryAfterMs: 3_000,
requestId: ProviderRequestId('deepseek-503'),
},
})
} finally {
dateNow.mockRestore()
}
@@ -352,13 +353,11 @@ describe('DeepSeekAdapter against a mock server', () => {
headers: { 'retry-after': value },
}])
const ctx = await harness(server.url)
let thrown: LlmError | undefined
try {
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
} catch (error: unknown) {
if (error instanceof LlmError) thrown = error
}
expect(thrown?.failure).toEqual({ message: 'retry later', code: 'RATE_LIMIT', status: 429 })
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(result.finish).toEqual({
kind: 'error',
failure: { message: 'retry later', code: 'RATE_LIMIT', status: 429 },
})
}
})
@@ -379,53 +378,50 @@ describe('DeepSeekAdapter against a mock server', () => {
it('keeps the status-line message for JSON error bodies without a message', async () => {
const server = await mockServer([{ kind: 'http-error', status: 500, body: '{"error":{"type":"x"}}' }])
const ctx = await harness(server.url)
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/HTTP 500/)
const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
expect(result.finish.kind).toBe('error')
if (result.finish.kind !== 'error') throw new Error('expected an error finish')
expect(result.finish.failure.code).toBe('SERVER')
expect(result.finish.failure.message).toMatch(/HTTP 500/)
})
it('keeps the status-line message for non-JSON error bodies', async () => {
const server = await mockServer([{ kind: 'http-error', status: 502, body: 'Bad Gateway', contentType: 'text/plain' }])
const ctx = await harness(server.url)
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/HTTP 502/)
const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
expect(result.finish.kind).toBe('error')
if (result.finish.kind !== 'error') throw new Error('expected an error finish')
expect(result.finish.failure.code).toBe('SERVER')
expect(result.finish.failure.message).toMatch(/HTTP 502/)
})
it('maps unusual statuses to HTTP_<status>', () => {
expect(httpErrorCode(418)).toBe('HTTP_418')
})
it('wraps a transport failure in TRANSPORT with the fetch cause chain in the message', async () => {
// Port 1 is reserved/unbound: fetch rejects with `TypeError: fetch failed`
// whose actionable detail (ECONNREFUSED) lives on `cause`.
it('reports a transport failure with the endpoint in the message', async () => {
// Port 1 is reserved/unbound, so the service normalizes the fetch failure.
const ctx = await harness('http://127.0.0.1:1')
let caught: unknown
try {
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
} catch (error: unknown) {
caught = error
}
expect(caught).toBeInstanceOf(LlmError)
const llmError = caught as LlmError
expect(llmError.code).toBe('TRANSPORT')
expect(llmError.message).toContain('http://127.0.0.1:1')
expect(llmError.cause).toBeInstanceOf(TypeError)
// The chain renderer reaches the transport diagnosis through the cause.
expect(errorChain(llmError)).toMatch(/ECONNREFUSED|EADDRNOTAVAIL|bad port/)
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(result.finish).toMatchObject({
kind: 'error',
failure: {
code: 'TRANSPORT',
message: 'DeepSeek API request to http://127.0.0.1:1 failed',
},
})
})
it('classifies an aborted request without losing the transport rejection', async () => {
it('classifies an aborted request as an aborted finish', async () => {
const controller = new AbortController()
controller.abort()
const ctx = await harness('http://127.0.0.1:1')
let caught: unknown
try {
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], signal: controller.signal })
} catch (error: unknown) {
caught = error
}
expect(caught).toBeInstanceOf(LlmError)
expect(caught).toMatchObject({ code: 'ABORTED' })
expect((caught as LlmError).cause).toMatchObject({ name: 'AbortError' })
const result = await assemble(ctx, {
model: 'deepseek-v4-flash',
messages: [],
signal: controller.signal,
})
expect(result.finish).toMatchObject({ kind: 'aborted', failure: { code: 'ABORTED' } })
})
it('throws EMPTY_RESPONSE when the response has no body', async () => {
@@ -443,20 +439,17 @@ describe('DeepSeekAdapter against a mock server', () => {
}
})
it('classifies an abrupt body close as TRANSPORT and retains its cause', async () => {
it('classifies an abrupt body close as TRANSPORT', async () => {
const server = await mockServer([{
kind: 'close-early',
events: ['{"choices":[{"delta":{"content":"par"}}]}'],
}])
const ctx = await harness(server.url)
let caught: unknown
try {
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
} catch (error: unknown) {
caught = error
}
expect(caught).toMatchObject({ code: 'TRANSPORT' })
expect(errorChain(caught)).toMatch(/terminated|socket|without \[DONE\]/)
const result = await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
expect(result.finish.kind).toBe('error')
if (result.finish.kind !== 'error') throw new Error('expected an error finish')
expect(result.finish.failure.code).toBe('TRANSPORT')
expect(result.finish.failure.message).toMatch(/^DeepSeek API stream from .* failed$/)
})
it('aborts mid-stream via the request signal', async () => {
@@ -478,7 +471,13 @@ describe('DeepSeekAdapter against a mock server', () => {
})()
setTimeout(() => { controller.abort() }, 30)
await expect(pending).rejects.toMatchObject({ code: 'ABORTED' })
const chunks = await pending
expect(chunks).toHaveLength(1)
expect(chunks[0]?.type).toBe('finish')
if (chunks[0]?.type !== 'finish') throw new Error('expected a finish chunk')
expect(chunks[0].reason.kind).toBe('aborted')
if (chunks[0].reason.kind !== 'aborted') throw new Error('expected an aborted finish')
expect(chunks[0].reason.failure.code).toBe('ABORTED')
})
it('maps connection failures to TRANSPORT without losing the cause', async () => {
@@ -700,6 +699,13 @@ describe('plugin registration and config', () => {
})
})
it('normalizes a literal API key and treats whitespace as absent', () => {
expect(resolveAdapterOptions({ apiKey: ' key ' }).apiKey).toBe('key')
const whitespace = resolveAdapterOptions({ apiKey: ' \t ', apiKeyEnv: 'CUSTOM_API_KEY' })
expect(whitespace.apiKey).toBeUndefined()
expect(whitespace.apiKeyEnv).toBe('CUSTOM_API_KEY')
})
it('uses the default model catalog when apply is called directly', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -793,6 +799,26 @@ describe('plugin registration and config', () => {
expect(ctx.llm.listProviders()).toEqual([])
})
it.each([0, 1.5])('rejects a per-model output cap of %s', (maxTokens) => {
expect(() => resolveAdapterOptions({ models: [{ id: 'bad-cap', maxTokens }] }))
.toThrow(/maxTokens must be a positive integer/)
})
it('prefers a model\'s own output cap over the profile default', async () => {
// The profile default stays what an unlisted or uncapped model resolves
// to, so adding a per-model cap changes one model rather than the route.
const adapter = adapterOf({ maxTokens: 4096, models: [
{ id: 'capped', maxTokens: 512 },
{ id: 'uncapped' },
] })
await expect(adapter.resolveModel('deepseek-official', 'capped'))
.resolves.toMatchObject({ defaultMaxTokens: 512 })
await expect(adapter.resolveModel('deepseek-official', 'uncapped'))
.resolves.toMatchObject({ defaultMaxTokens: 4096 })
await expect(adapter.resolveModel('deepseek-official', 'not-in-catalog'))
.resolves.toMatchObject({ defaultMaxTokens: 4096 })
})
it('rejects invalid context capacity when apply is called directly', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -858,12 +884,15 @@ describe('plugin registration and config', () => {
// only the request itself needs a key.
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
const first = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } })
// The guidance leads with the credential store — the path that keeps the
// secret out of configuration files — and mentions a literal key last.
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s)
const second = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(second.finish.kind).toBe('error')
if (second.finish.kind !== 'error') throw new Error('expected an error finish')
expect(second.finish.failure.message)
.toMatch(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s)
})
it('reads the ambient variable when no credentials seam is mounted', async () => {
@@ -883,8 +912,8 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' })
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } })
})
it('prefers explicit config over env for key and base URL', async () => {
@@ -969,3 +998,38 @@ describe('plugin registration and config', () => {
expect(ctx.llm.listProviders()).toEqual([])
})
})
describe('API key format', () => {
it('trims a padded literal apiKey', () => {
expect(resolveAdapterOptions({ apiKey: ' sk-abc ' }).apiKey).toBe('sk-abc')
})
it('leaves an omitted apiKey absent so apiKeyEnv still resolves it', () => {
expect(resolveAdapterOptions({}).apiKey).toBeUndefined()
})
it('treats a whitespace-only literal apiKey as absent, not as a failure', () => {
// This adapter's absence has a defined fallback, so a blank literal
// resolves through apiKeyEnv like an omitted one. (llm-pi-ai refuses a
// blank one instead: there, absence selects provider-native or OAuth
// authentication rather than a different source for the same key.)
const resolved = resolveAdapterOptions({ apiKey: ' ', apiKeyEnv: 'CUSTOM_API_KEY' })
expect(resolved.apiKey).toBeUndefined()
expect(resolved.apiKeyEnv).toBe('CUSTOM_API_KEY')
})
it('rejects a literal apiKey no header can carry', () => {
expect(() => resolveAdapterOptions({ apiKey: 'sk-\u{1F600}' }))
.toThrow(/no HTTP header can carry/)
})
it('never echoes the key in the rejection', () => {
const secret = 'sk-\u{1F600}supersecret'
expect(() => resolveAdapterOptions({ apiKey: secret })).toThrow()
try {
resolveAdapterOptions({ apiKey: secret })
} catch (error) {
expect((error as Error).message).not.toContain('supersecret')
}
})
})
@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import LlmService from '@deepseek-ai/dsh-llm'
import LlmService, { INVALID_CREDENTIAL_CODE } from '@deepseek-ai/dsh-llm'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
@@ -96,12 +96,32 @@ describe('request-level dynamic configuration', () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const { ctx } = await boot(dir, { baseURL: server.url })
await expect(prompt(ctx)).rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
const keyless = await prompt(ctx)
expect(keyless.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } })
await ctx.credentials.set(KEY_REF, 'sk-arrived')
await prompt(ctx)
expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived')
})
it('rejects a stored credential no header can carry, never echoing it in the failure', async () => {
vi.stubEnv('DEEPSEEK_API_KEY', '')
const dir = await home()
const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' })
const secret = 'sk-\u{1F600}supersecret'
// The real credentials seam (the path the web Models page writes through),
// not a hand-built stub: this package's own dynamic-config harness already
// boots one, and round-tripping the value through its actual store/read
// path is stronger evidence than a canned in-memory return would be.
await ctx.credentials.set(KEY_REF, secret)
const result = await prompt(ctx)
expect(result.finish).toMatchObject({ kind: 'error', failure: { code: INVALID_CREDENTIAL_CODE } })
if (result.finish.kind !== 'error') throw new Error('expected an error finish')
expect(result.finish.failure.message).not.toContain(secret)
expect(result.finish.failure.message).not.toContain('supersecret')
expect(result.finish.failure.message).not.toContain('ByteString')
})
it('advertises a live settings catalog without re-registration', async () => {
const dir = await home()
const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })