refactor(llm): inherit Messages defaults and verify live parity

This commit is contained in:
Tianyi Cui committed 2026-09-13 20:36:50 +08:00
1 parent aef56a3108
commit accafa5fb2
47 files changed
+128 -68

No files matched your search

@@ -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/feature/2026-09-07-deepseek-messages-adapter.md
2026-09-07-deepseek-messages-adapter.md: 80da9229d043246da097d9ce9458816d2b8506d9
2026-09-07-deepseek-messages-adapter.zh.md: cc30bd6eb197d4de7d740e772e7661dc15eecb34
2026-09-07-deepseek-messages-adapter.md: 9a92f9e95931bebfa9fa6e7e64fbd7d27ec8306c
2026-09-07-deepseek-messages-adapter.zh.md: 67f6c97c0a416cbddb7ec9d0de01e47e658036b7
@@ -10,7 +10,7 @@ Deployments expose DeepSeek through Anthropic Messages gateways as well as chat-
## Decision
The [DeepSeek adapter](../../../../packages/llm/llm-deepseek/README.md) serves multiple protocols under one `deepseek-official` route and `llm-deepseek` settings namespace. `common/` shares configuration, the model catalog, capability resolution, and Files lifecycle; `protocols/chat-completions/` and `protocols/messages/` own serialization, stream conversion, and transport. Cordis YAML selects the implementation through `protocol`, defaulting to `messages`; shipped first-party compositions also declare this choice explicitly. The existing `PreparedAdapterCall` freezes protocol, endpoint, credential reference, and model capabilities; retries retain that generation while subsequent calls read new configuration.
The [DeepSeek adapter](../../../../packages/llm/llm-deepseek/README.md) serves multiple protocols under one `deepseek-official` route and `llm-deepseek` settings namespace. `common/` shares configuration, the model catalog, capability resolution, and Files lifecycle; `protocols/chat-completions/` and `protocols/messages/` own serialization, stream conversion, and transport. Cordis YAML selects the implementation through `protocol`, defaulting to `messages`; shipped first-party compositions inherit that default. The existing `PreparedAdapterCall` freezes protocol, endpoint, credential reference, and model capabilities; retries retain that generation while subsequent calls read new configuration.
The adapter follows the [DeepSeek compatibility documentation](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) and [Anthropic streaming protocol](https://platform.claude.com/docs/en/build-with-claude/streaming). The pi-ai Anthropic implementation informed the handling of adjacent user messages, cumulative usage, fragmented tool arguments, and optional thinking signatures. DeepSeek effort uses `output_config.effort`; an Anthropic thinking token budget does not control DeepSeek effort. Both protocols forward explicit `temperature` values; DeepSeek accepts that parameter with thinking enabled and ignores its value, so callers retain their existing thinking configuration.
@@ -10,7 +10,7 @@ Status: implemented
## 决策
[DeepSeek 适配器](../../../../packages/llm/llm-deepseek/README.zh.md)通过一个 `deepseek-official` 路由和 `llm-deepseek` 设置命名空间支持多个协议。`common/` 共享配置、模型目录、能力解析和 Files 生命周期;`protocols/chat-completions/` 与 `protocols/messages/` 分别负责协议序列化、流转换和传输。`protocol` 配置在 Cordis YAML 中选择实现,默认 `messages`;随产品交付的官方组合也显式声明该选择。已有 `PreparedAdapterCall` 冻结协议、端点、凭据引用与模型能力,重试保持同一代配置,后续调用读取新配置。
[DeepSeek 适配器](../../../../packages/llm/llm-deepseek/README.zh.md)通过一个 `deepseek-official` 路由和 `llm-deepseek` 设置命名空间支持多个协议。`common/` 共享配置、模型目录、能力解析和 Files 生命周期;`protocols/chat-completions/` 与 `protocols/messages/` 分别负责协议序列化、流转换和传输。`protocol` 配置在 Cordis YAML 中选择实现,默认 `messages`;随产品交付的官方组合继承该默认值。已有 `PreparedAdapterCall` 冻结协议、端点、凭据引用与模型能力,重试保持同一代配置,后续调用读取新配置。
适配器遵循 [DeepSeek 兼容文档](https://api-docs.deepseek.com/zh-cn/guides/anthropic_api) 和 [Anthropic 流协议](https://platform.claude.com/docs/en/build-with-claude/streaming)。pi-ai 的 Anthropic 实现为相邻用户消息、累计用量、工具参数分片和可选思考签名的处理提供参考。DeepSeek 通过 `output_config.effort` 设置思考强度;Anthropic 思考 token 预算不控制 DeepSeek 思考强度。两种协议都转发显式 `temperature` 值;DeepSeek 在启用思考时接受该参数但忽略其值,因此调用方可以保留已有思考配置。
@@ -26,5 +26,4 @@
- id: llm-deepseek-keyless
name: '@deepseek-ai/dsh-llm-deepseek'
config:
protocol: messages
baseURL: 'http://127.0.0.1:9'
@@ -8,7 +8,6 @@
- id: llm-deepseek
config:
protocol: messages
thinking: enabled
reasoningEffort: max
models:
@@ -7,7 +7,6 @@
- id: llm-deepseek
config:
protocol: messages
baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL
thinking: enabled
reasoningEffort: low
@@ -60,7 +60,6 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio
})
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek, {
protocol: 'messages',
...options.modelContextWindow === undefined ? {} : {
models: [{ id: 'deepseek-v4-flash', contextWindow: options.modelContextWindow }],
},
@@ -61,7 +61,7 @@ async function ptcModeHarness(cwd: string): Promise<Context> {
await harness.plugin(ToolRuntime, { mode: 'ptc' })
await harness.plugin(AgentRegistry)
await harness.plugin(AgentLoop, { agents: [] })
await harness.plugin(LlmDeepSeek, { protocol: 'messages' })
await harness.plugin(LlmDeepSeek)
if (harness.get('subprocess') === undefined) await harness.plugin(LocalSubprocessRuntime)
await harness.plugin(BashEnvPlugin)
await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 })
@@ -82,7 +82,7 @@ async function workspacePtcModeHarness(): Promise<Context> {
await harness.plugin(ToolFs)
await harness.plugin(AgentInstructions, { maxBytes: 65536 })
await harness.plugin(AgentLoop, { agents: [] })
await harness.plugin(LlmDeepSeek, { protocol: 'messages', models: [{ id: 'deepseek-v4-flash' }] })
await harness.plugin(LlmDeepSeek, { models: [{ id: 'deepseek-v4-flash' }] })
await mountRuntime(harness)
return harness
}
+1 -1
View File
@@ -644,7 +644,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
}],
...maskDeepSeekCredential && !messages ? [] : [
{ id: 'llm-deepseek', disabled: mode !== 'record' && !maskDeepSeekCredential,
config: { protocol: messages ? 'messages' : 'chat-completions' } },
config: messages ? {} : { protocol: 'chat-completions' } },
],
]
-2
View File
@@ -498,5 +498,3 @@
# choice.
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
protocol: messages
@@ -26,7 +26,6 @@
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
protocol: messages
apiKeyEnv: DEEPSEEK_API_KEY
defaultContextWindow: !!js Number(process.env.DSH_CONTEXT_WINDOW ?? 1000000)
streamIdleTimeoutMs: 172800000
@@ -65,7 +65,6 @@ describe('dsh-sdk-minimal bundle', () => {
config: { maxTokensAsSuccess: false },
})
expect(rows.find(row => row.id === 'llm-deepseek')?.config).toEqual({
protocol: 'messages',
apiKeyEnv: 'DEEPSEEK_API_KEY',
defaultContextWindow: { __jsExpr: 'Number(process.env.DSH_CONTEXT_WINDOW ?? 1000000)' },
streamIdleTimeoutMs: 172800000,
+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/bundle/web-app/README.md
README.md: 84630f1d9142b1e2300baa0cde0513acdb602da9
README.zh.md: 0488c74f433b0ef1d7e6b1677b7f50fd21a198a2
README.md: 23ff6e925eb097eb99e5fa7ce8678c87c61aeb9c
README.zh.md: 79b6335798e2bf522ce2bc1e201be745bb91e085
+1 -1
View File
@@ -36,7 +36,7 @@ dsh --profile web --no-open --port 8080
After startup you see a `dsh web:` line whose root URL carries a fresh process token. Unless `--no-open` or an SSH session suppresses it, the default browser opens that URL, receives a signed cookie, and redirects to the clean root page. You know it worked when the page loads and you can chat with the agent. Two failures to expect: if the frontend is not built, startup stops with a build hint (`pnpm run build` in a checkout); if the browser cannot be opened, a credential-free diagnostic prints to stderr while the server keeps running — open the printed startup URL yourself.
**Settings → Models** displays **DeepSeek**, using `DEEPSEEK_API_KEY`. The default is `deepseek-official` / `deepseek-flash` (DeepSeek-V41-Flash). The [DeepSeek plugin](../../llm/llm-deepseek/README.md#choose-a-protocol) defaults to Chat Completions; set `protocol: messages` in Cordis YAML to switch. Web has no protocol selector.
**Settings → Models** displays **DeepSeek**, using `DEEPSEEK_API_KEY`. The default is `deepseek-official` / `deepseek-flash` (DeepSeek-V41-Flash). The [DeepSeek plugin](../../llm/llm-deepseek/README.md#choose-a-protocol) defaults to Messages; set `protocol: chat-completions` in Cordis YAML to select Chat Completions. Web has no protocol selector.
Saved model selections override the composition default. Both protocols share `deepseek-official` and `llm-deepseek` settings, so switching preserves model selections and credential references. Endpoint overrides retain their values; the settings card lets users supply a compatible API address.
+1 -1
View File
@@ -36,7 +36,7 @@ dsh --profile web --no-open --port 8080
启动后你会看到 `dsh web:` 行,其根 URL 携带新的进程 token。除非 `--no-open` 或 SSH 会话抑制,否则默认浏览器会打开该 URL、取得签名 cookie,再重定向到不含认证参数的根页面。页面加载且你可以与 agent 对话,就说明成功了。两种可预期的失败:前端未构建时,启动会以构建提示停止(checkout 中运行 `pnpm run build`);浏览器无法打开时,stderr 会打印不含凭据的诊断,但服务器会继续运行——请自行打开已打印的启动 URL。
**设置 → 模型**显示 **DeepSeek**,使用 `DEEPSEEK_API_KEY`。默认模型为 `deepseek-official` / `deepseek-flash`(DeepSeek-V41-Flash)。[DeepSeek 插件](../../llm/llm-deepseek/README.zh.md#choose-a-protocol)默认使用 Chat Completions;在 Cordis YAML 中设置 `protocol: messages` 可切换协议。Web 不提供协议选择器。
**设置 → 模型**显示 **DeepSeek**,使用 `DEEPSEEK_API_KEY`。默认模型为 `deepseek-official` / `deepseek-flash`(DeepSeek-V41-Flash)。[DeepSeek 插件](../../llm/llm-deepseek/README.zh.md#choose-a-protocol)默认使用 Messages;在 Cordis YAML 中设置 `protocol: chat-completions` 可选择 Chat Completions。Web 不提供协议选择器。
已保存的模型选择优先于组合默认值。两种协议共用 `deepseek-official` 与 `llm-deepseek` 设置,因此切换协议不改变模型选择或复制凭据。端点覆盖保持原值;设置卡片允许用户填写兼容的 API 地址。
@@ -41,7 +41,7 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> {
await ctx.plugin(ToolFs)
await ctx.plugin(AgentInstructions, { maxBytes: 65536 })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek, { protocol: 'messages', models: [{ id: 'deepseek-v4-flash' }] })
await ctx.plugin(LlmDeepSeek, { models: [{ id: 'deepseek-v4-flash' }] })
const handle = await ctx.agents.create({
sessionId: SessionId('workspace-context-e2e-session'),
meta: { cwd: workdir },
@@ -47,7 +47,7 @@ async function loopHarness(): Promise<Context> {
await created.plugin(ToolRuntime)
await created.plugin(AgentRegistry)
await created.plugin(AgentLoop, { agents: [] })
await created.plugin(LlmDeepSeek, { protocol: 'messages' })
await created.plugin(LlmDeepSeek)
created.tools.register(defineContentToolFixture({
name: 'lookup',
description: 'Look up the stored value for a key.',
@@ -133,7 +133,7 @@ async function mount(ctx: Context, workspace: string, dshHome: string): Promise<
systemPrompt: {}, tools: { mode: 'both' },
})
// No reasoning or output-budget override: use each shipped model's defaults.
await ctx.plugin(LlmDeepSeek, { protocol: 'messages', retryPolicy: { mode: 'normal', maxRetries: 0 } })
await ctx.plugin(LlmDeepSeek, { retryPolicy: { mode: 'normal', maxRetries: 0 } })
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: workspace })
await ctx.plugin(UnusedSandbox)
await ctx.plugin(SandboxedFileSystem, { cwd: workspace })
+1 -1
View File
@@ -16,7 +16,7 @@ export async function fsHarness(fsCwd: string, persona = ''): Promise<Context> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx, { systemPrompt: { personaPrefix: persona } })
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek, { protocol: 'messages' })
await ctx.plugin(LlmDeepSeek)
await ctx.plugin(LocalFileSystem, { cwd: fsCwd })
await ctx.plugin(FsPolicy)
await ctx.plugin(ToolFs)
+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: 8c7d2d083a8a315ea2d7bf5190b77222901488b1
README.zh.md: e7904b221f7573e997abba126892b83f73a5b4f6
README.md: c03fa8ab740f9ffd315ee84b029e132bc40882b4
README.zh.md: 80630f363aa4a065f0a2bbedc033fbca03fc641f
+1 -2
View File
@@ -36,7 +36,6 @@ Choose this adapter for DeepSeek's official API or a gateway that supports the s
```yaml
- name: '@deepseek-ai/dsh-llm-deepseek'
config:
protocol: messages # messages | chat-completions
apiKeyEnv: DEEPSEEK_API_KEY # credential reference, resolved per request
reasoningEffort: high # optional; off | low | high | max
maxTokens: 256000 # optional per-request output cap
@@ -84,7 +83,7 @@ To select Chat Completions explicitly, patch the existing plugin:
protocol: chat-completions
```
`protocol` defaults to `messages`, with official root `https://api.deepseek.com/anthropic`; `chat-completions` uses `https://api.deepseek.com`. Shipped first-party compositions explicitly select Messages. Neither protocol requires `baseURL`: its official default applies when both `baseURL` and `$DEEPSEEK_BASE_URL` are absent. Switching protocols retains endpoint overrides, so users must supply an address compatible with the selected protocol. An explicit `https://api.deepseek.com` override selects the Chat root: remove that override to use the official Messages default, or set it to `https://api.deepseek.com/anthropic`. Chat appends `/chat/completions`; Messages appends `/v1/messages`. Apart from trailing slashes, neither infers or removes custom path suffixes such as `/v1`. Both share the `llm-deepseek` settings section, `apiKeyEnv`, and `deepseek-official`, so saved model selections remain valid.
`protocol` defaults to `messages`, with official root `https://api.deepseek.com/anthropic`; `chat-completions` uses `https://api.deepseek.com`. Shipped first-party compositions inherit this default. Neither protocol requires `baseURL`: its official default applies when both `baseURL` and `$DEEPSEEK_BASE_URL` are absent. Switching protocols retains endpoint overrides, so users must supply an address compatible with the selected protocol. An explicit `https://api.deepseek.com` override selects the Chat root: remove that override to use the official Messages default, or set it to `https://api.deepseek.com/anthropic`. Chat appends `/chat/completions`; Messages appends `/v1/messages`. Apart from trailing slashes, neither infers or removes custom path suffixes such as `/v1`. Both share the `llm-deepseek` settings section, `apiKeyEnv`, and `deepseek-official`, so saved model selections remain valid.
Messages sends text, thinking, tool calls, and tool results as content blocks, reasoning effort as `output_config.effort`, and images as Files references or inline base64. Models declaring `systemPromptUpdate: in-history` retain the initial top-level system and send new system snapshots after their corresponding user/tool-result turn; undeclared models use the latest snapshot as the top-level system. Replay metadata identifies the Messages format, model, and signatures. Chat requests serialize durable content without those signatures. Invalid Messages replay metadata emits a warning and omits signatures while retaining text and tool history.
+1 -2
View File
@@ -36,7 +36,6 @@ kind: "package-reference"
```yaml
- name: '@deepseek-ai/dsh-llm-deepseek'
config:
protocol: messages # messages | chat-completions
apiKeyEnv: DEEPSEEK_API_KEY # credential reference, resolved per request
reasoningEffort: high # optional; off | low | high | max
maxTokens: 256000 # optional per-request output cap
@@ -84,7 +83,7 @@ kind: "package-reference"
protocol: chat-completions
```
`protocol` 默认为 `messages`,官方根地址为 `https://api.deepseek.com/anthropic`;`chat-completions` 使用 `https://api.deepseek.com`。随产品交付的官方组合显式选择 Messages。两种协议都不要求填写 `baseURL`:当 `baseURL` 与 `$DEEPSEEK_BASE_URL` 均未设置时使用当前协议的官方默认值。切换协议保留已有端点覆盖,用户需要填写与选定协议兼容的地址。显式填写的 `https://api.deepseek.com` 是 Chat 根地址:删除该覆盖即可使用官方 Messages 默认值,也可以改填 `https://api.deepseek.com/anthropic`。Chat 追加 `/chat/completions`,Messages 追加 `/v1/messages`;除去末尾斜线之外,不推测或删除自定义路径中的 `/v1` 等后缀。两种协议共用 `llm-deepseek` 设置、`apiKeyEnv` 与 `deepseek-official`,因此已保存的模型选择仍然有效。
`protocol` 默认为 `messages`,官方根地址为 `https://api.deepseek.com/anthropic`;`chat-completions` 使用 `https://api.deepseek.com`。随产品交付的官方组合继承该默认值。两种协议都不要求填写 `baseURL`:当 `baseURL` 与 `$DEEPSEEK_BASE_URL` 均未设置时使用当前协议的官方默认值。切换协议保留已有端点覆盖,用户需要填写与选定协议兼容的地址。显式填写的 `https://api.deepseek.com` 是 Chat 根地址:删除该覆盖即可使用官方 Messages 默认值,也可以改填 `https://api.deepseek.com/anthropic`。Chat 追加 `/chat/completions`,Messages 追加 `/v1/messages`;除去末尾斜线之外,不推测或删除自定义路径中的 `/v1` 等后缀。两种协议共用 `llm-deepseek` 设置、`apiKeyEnv` 与 `deepseek-official`,因此已保存的模型选择仍然有效。
Messages 以内容块发送文本、思考、工具调用和工具结果,以 `output_config.effort` 发送推理强度,并以 Files 引用或内联 base64 发送图片。声明 `systemPromptUpdate: in-history` 的模型保留初始顶层 system,在对应 user/tool-result 轮次之后发送新的 system 快照;未声明能力时,使用最新快照作为顶层 system。回放元数据记录 Messages 格式、模型和签名;Chat 请求只序列化持久化内容,不发送这些签名。无效的 Messages 回放元数据产生警告并省略签名,不丢弃文本或工具历史。
@@ -2,14 +2,21 @@
* Real Messages round trips use the official root and require credentials.
* System-update checks additionally require DEEPSEEK_IN_HISTORY_MODEL.
*/
import { randomUUID } from 'node:crypto'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context, LoggerLevel } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import LocalAttachments from '@deepseek-ai/dsh-attachment-local'
import LlmRuntime, { createSystemMessage, createToolResultMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
import LlmRuntime, { BlockAssembler, createSystemMessage, createToolResultMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { Message } from '@deepseek-ai/dsh-llm'
import * as PluginPackageInventoryDeepSeek from '@deepseek-ai/dsh-plugin-package-inventory-deepseek'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import * as SessionLogDeepSeek from '@deepseek-ai/dsh-session-log-deepseek'
import * as Messages from '../../src/index.ts'
import { DeepSeekFilesClient, MESSAGES_FILES_BETA } from '../../src/common/files-api.ts'
import { assemble, options, user } from './helpers.ts'
@@ -29,7 +36,6 @@ async function boot(models?: Messages.Config['models']) {
cleanups.push(() => ctx.fiber.dispose())
await ctx.plugin(LlmRuntime)
await ctx.plugin(Messages, {
protocol: 'messages',
baseURL: Messages.MESSAGES_BASE_URL,
maxTokens: 4096,
...models === undefined ? {} : { models },
@@ -60,20 +66,24 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', ()
await reply('PROMPT_CLEARED')
})
it('uploads, lists, retrieves, and reuses a Files image across Messages requests', async () => {
it('uploads, lists, retrieves, reuses, and replaces a deleted Files image across Messages requests', async () => {
const ctx = await boot()
await ctx.plugin(LocalAttachments)
const fetchImpl = globalThis.fetch
const uploads: string[] = []
const bodies: string[] = []
const files = new DeepSeekFilesClient({ baseURL: Messages.MESSAGES_BASE_URL, protocol: 'messages', apiKey: process.env.DEEPSEEK_API_KEY as string, fetch: fetchImpl })
const ownedFiles = new Set<ReturnType<typeof Messages.DeepSeekFileId>>()
cleanups.push(async () => {
for (const id of ownedFiles) await files.delete(id)
})
vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
const response = await fetchImpl(input, init)
if (url === `${Messages.MESSAGES_BASE_URL}/v1/files` && init?.method === 'POST' && response.ok) {
const file = await response.clone().json() as { id: string }
uploads.push(file.id)
cleanups.push(() => files.delete(Messages.DeepSeekFileId(file.id)))
ownedFiles.add(Messages.DeepSeekFileId(file.id))
}
if (url === `${Messages.MESSAGES_BASE_URL}/v1/messages`) {
expect(new Headers(init?.headers).get('anthropic-beta')).toBe(MESSAGES_FILES_BETA)
@@ -110,6 +120,80 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('DeepSeek Messages real API', ()
page = await files.list({ after, limit: 1_000 })
}
expect(page.data).toContainEqual(retrieved)
await files.delete(fileId)
ownedFiles.delete(fileId)
const recovered = await assemble(ctx.llm.stream(request), request.model)
expect(recovered.assembler.finish.kind).toBe('stop')
expect(recovered.message.content.filter(block => block.type === 'text').map(block => block.text).join('').toLowerCase()).toContain('red')
expect(uploads).toHaveLength(2)
expect(uploads[1]).not.toBe(fileId)
expect(bodies).toHaveLength(4)
expect(bodies[2]).toContain(`"file_id":"${fileId}"`)
expect(bodies[3]).toContain(`"file_id":"${uploads[1]}"`)
expect(bodies[3]).not.toContain('"type":"base64"')
})
it.each([false, true])('submits Loader package inventory and records HTTP acceptance with session-log enabled=%s', async (enabled) => {
const ctx = await boot()
await ctx.plugin(Loader)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionStore)
await ctx.plugin(DeepSeekLlmApiExtensionRegistry)
await ctx.plugin(SessionLogDeepSeek, enabled ? { enabled: true } : {})
ctx.baseUrl = import.meta.url
// Select the source module while Loader owns its active package entry.
ctx.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (specifier !== '@deepseek-ai/dsh-plugin-package-inventory-deepseek') throw new Error(`unexpected Loader import: ${specifier}`)
return PluginPackageInventoryDeepSeek
},
} as unknown as NonNullable<typeof ctx.loader.internal>
await ctx.loader.create({ name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' })
await ctx.loader.await()
const packageIdentity = JSON.parse(await readFile(new URL('../../../plugin-package-inventory-deepseek/package.json', import.meta.url), 'utf8')) as { name: string; version: string }
const session = ctx.sessions.create(SessionId(`real-messages-extensions-${randomUUID()}`))
session.append('turn/start', { turn: 1 })
const fetchImpl = globalThis.fetch
let afterSeq: number = -1
let throughSeq = 0
let requests = 0
vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
if (url !== `${Messages.MESSAGES_BASE_URL}/v1/messages`) return fetchImpl(input, init)
if (typeof init?.body !== 'string') throw new Error('expected a JSON Messages request')
const body = JSON.parse(init.body) as Record<string, unknown>
expect(body).toMatchObject({ dsh_plugin_packages: {
version: 1, packages: [{ name: packageIdentity.name, version: packageIdentity.version }],
} })
if (enabled) {
expect(body).toMatchObject({ dsh_session_log: {
version: 1, sessionFormatVersion: session.header.version, session: { id: session.id },
afterSeq, throughSeq,
events: Array.from({ length: throughSeq - afterSeq }, (_, index) => ({ seq: afterSeq + index + 1 })),
} })
} else expect(body).not.toHaveProperty('dsh_session_log')
expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(afterSeq)
const response = await fetchImpl(input, init)
expect(response.ok).toBe(true)
expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(afterSeq)
requests += 1
return response
})
for (let run = 0; run < 2; run++) {
afterSeq = SessionLogDeepSeek.acceptedThrough(session)
throughSeq = Number(session.seq) - 1
const assembler = new BlockAssembler()
for await (const chunk of ctx.llm.stream(options({ sessionId: session.id, reasoningEffort: ReasoningEffortId('off'), messages: [user('Reply with exactly PONG.')] }))) {
expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(enabled ? throughSeq : -1)
assembler.push(chunk)
}
expect(assembler.finish.kind).toBe('stop')
expect(assembler.blocks().filter(block => block.type === 'text').map(block => block.text).join('')).toContain('PONG')
expect(requests).toBe(run + 1)
if (run === 0) session.append('step/start', { turn: 1, step: 1 })
}
expect(session.snapshotEvents().filter(event => event.type === 'session-log-deepseek/delivery-accepted')).toHaveLength(enabled ? 2 : 0)
})
it.each(['off', 'low', 'high', 'max'])('streams text with %s effort', async (effort) => {
@@ -106,7 +106,7 @@ describe('direct Messages HTTP', () => {
const prepare = vi.fn(async () => ({ fields: {}, accept }))
const files = new DeepSeekFileStore()
const llm = new DeepSeekMessagesAdapter({
connection: () => Messages.resolveAdapterOptions({ protocol: 'messages', baseURL: source.url }),
connection: () => Messages.resolveAdapterOptions({ baseURL: source.url }),
apiKey: () => Promise.resolve('test-key'), userId: () => 'test-user',
attachments: () => undefined, imageAccess: () => undefined, files: () => files,
prepareExtensions: prepare,
@@ -122,11 +122,11 @@ describe('direct Messages HTTP', () => {
it('freezes endpoint and defaults for a prepared call while the next call sees new settings', async () => {
const first = await endpoint(), second = await endpoint()
let config = Messages.resolveAdapterOptions({ protocol: 'messages', baseURL: first.url, maxTokens: 10, models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] })
let config = Messages.resolveAdapterOptions({ baseURL: first.url, maxTokens: 10, models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] })
const files = new DeepSeekFileStore()
const llm = new DeepSeekMessagesAdapter({ connection: () => config, apiKey: snapshot => Promise.resolve(snapshot.maxTokens === 10 ? 'first' : 'second'), userId: () => 'user', attachments: () => undefined, imageAccess: () => undefined, files: () => files, prepareExtensions })
const prepared = await llm.prepareCall('deepseek-official', MODEL)
config = Messages.resolveAdapterOptions({ protocol: 'messages', baseURL: second.url, maxTokens: 20 })
config = Messages.resolveAdapterOptions({ baseURL: second.url, maxTokens: 20 })
expect(prepared.model.systemPromptUpdate).toBe('in-history')
expect((await llm.resolveModel('deepseek-official', MODEL)).systemPromptUpdate).toBeUndefined()
await chunks(prepared.stream(options()))
@@ -169,7 +169,7 @@ describe('Cordis provider composition', () => {
const { ctx, home } = await context()
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
await ctx.plugin(LlmRuntime)
await ctx.plugin(Messages, { protocol: 'messages', baseURL: http.url })
await ctx.plugin(Messages, { baseURL: http.url })
const model = 'deepseek-v4-flash-vision-exp'
const price = () => ctx.llm.imageRequestPricing('deepseek-official', model)!
const dummy = { attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), width: 1, height: 1, bytes: 3, mediaType: 'image/png' as const }
@@ -369,7 +369,7 @@ describe('Cordis provider composition', () => {
vi.stubEnv('DEEPSEEK_BASE_URL', http.url)
vi.stubEnv('DEEPSEEK_API_KEY', 'env-key')
await ctx.plugin(LlmRuntime)
const fiber = ctx.plugin(Messages, { protocol: 'messages' })
const fiber = ctx.plugin(Messages)
await fiber
await chunks(ctx.llm.stream(options()))
expect(http.requests[0]?.headers['x-api-key']).toBe('env-key')
@@ -33,7 +33,7 @@ async function boot() {
cleanup.push(() => ctx.fiber.dispose())
await ctx.plugin(LlmRuntime)
await ctx.plugin(DeepSeekLlmApiExtensionRegistry)
await ctx.plugin(DeepSeek, { protocol: 'messages', baseURL: 'https://messages.example.test/root' })
await ctx.plugin(DeepSeek, { baseURL: 'https://messages.example.test/root' })
return ctx
}
@@ -36,7 +36,7 @@ function harness(config: Config = {}) {
const attachments = { readImageRequest } as unknown as AttachmentStore
const prepare = vi.fn(prepareExtensions)
const adapter = new DeepSeekMessagesAdapter({
connection: () => resolveAdapterOptions(Object.assign({ protocol: 'messages' as const, baseURL: 'https://gateway.example/custom' }, config)),
connection: () => resolveAdapterOptions(Object.assign({ baseURL: 'https://gateway.example/custom' }, config)),
apiKey: async () => 'test-key', userId: () => 'test-user', attachments: () => attachments,
imageAccess: () => ({ readonlyPath: '/workspace/image.png' }), files: () => files, prepareExtensions: prepare,
})
@@ -17,7 +17,6 @@
watch: false
- name: '@deepseek-ai/dsh-llm-deepseek'
config:
protocol: messages
apiKeyEnv: DEEPSEEK_API_KEY
baseURL: {{endpoint}}
thinking: enabled
@@ -40,7 +40,7 @@ export async function assemble(stream: AsyncIterable<StreamChunk>, model = MODEL
}
export function adapter(config: Config = {}) {
const files = new DeepSeekFileStore()
return new DeepSeekMessagesAdapter({ connection: () => resolveAdapterOptions(Object.assign({}, config, { protocol: 'messages' as const })), apiKey: () => Promise.resolve('test-key'), userId: () => 'test-user', attachments: () => undefined, imageAccess: () => undefined, files: () => files, prepareExtensions })
return new DeepSeekMessagesAdapter({ connection: () => resolveAdapterOptions(config), apiKey: () => Promise.resolve('test-key'), userId: () => 'test-user', attachments: () => undefined, imageAccess: () => undefined, files: () => files, prepareExtensions })
}
export async function server(reply: (response: ServerResponse, count: number) => void = response => response.end(sse(textEvents))) {
const requests: { path: string; headers: IncomingHttpHeaders; body: Record<string, unknown> }[] = []
@@ -12,14 +12,14 @@ import { readReplay, replayState } from '../../src/protocols/messages/replay.ts'
import { serialize } from '../../src/protocols/messages/serialize.ts'
import { MODEL, options, user } from './helpers.ts'
const connection = resolveAdapterOptions({ protocol: 'messages' })
const connection = resolveAdapterOptions({})
const call = (id = 'a'): ContentBlock => ({ type: 'tool-call', id: ToolCallId(id), name: 'read', arguments: '{"path":"a"}' })
const assistant = (content: ContentBlock[]) => createAssistantMessage({ content, source: { provider: 'deepseek-official', model: MODEL } })
const result = (id = 'a', content: ContentBlock[] = [{ type: 'text', text: 'result' }]) => createToolResultMessage({ callId: ToolCallId(id), content, isError: false })
const body = (messages: Message[] = [user()], overrides: Partial<GenerateOptions> = {}) => serialize(
options({ messages, ...overrides }), connection, messages, new Map(), () => undefined,
)
const capable = resolveAdapterOptions({ protocol: 'messages', models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] })
const capable = resolveAdapterOptions({ models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] })
const nativeBody = (messages: Message[]) => serialize(options({ messages }), capable, messages, new Map(), () => undefined)
describe('Messages request conversion', () => {
@@ -134,10 +134,10 @@ describe('Messages request conversion', () => {
expect(body([user()], { purpose: 'session-title', temperature: 0 })).toMatchObject({ thinking: { type: 'disabled' }, temperature: 0 })
expect(body([user()], { temperature: 0 })).toMatchObject({ thinking: { type: 'enabled' }, temperature: 0 })
expect(() => body([user()], { reasoningEffort: ReasoningEffortId('medium') })).toThrow(/effort/)
const disabled = resolveAdapterOptions({ protocol: 'messages', thinking: 'disabled' })
const disabled = resolveAdapterOptions({ thinking: 'disabled' })
expect(serialize(options(), disabled, [user()], new Map(), () => undefined).thinking).toEqual({ type: 'disabled' })
expect(() => serialize(options({ reasoningEffort: ReasoningEffortId('high') }), disabled, [user()], new Map(), () => undefined)).toThrow(/effort/)
const capped = resolveAdapterOptions({ protocol: 'messages', models: [{ id: MODEL, maxTokens: 321 }] })
const capped = resolveAdapterOptions({ models: [{ id: MODEL, maxTokens: 321 }] })
expect(serialize(options(), capped, [user()], new Map(), () => undefined).max_tokens).toBe(321)
})
@@ -230,8 +230,8 @@ describe('validated configuration', () => {
expect(modelInfo(connection, 'deepseek-official', 'custom').systemPromptUpdate).toBeUndefined()
expect(modelInfo(capable, 'deepseek-official', MODEL).systemPromptUpdate).toBe('in-history')
expect(modelInfo(capable, 'deepseek-official', 'custom').systemPromptUpdate).toBeUndefined()
expect(modelInfo(resolveAdapterOptions({ protocol: 'messages', thinking: 'disabled' }), 'deepseek-official', MODEL).reasoning?.efforts).toMatchObject([{ id: 'off', name: 'Off' }])
expect(resolveAdapterOptions({ protocol: 'messages', baseURL: 'https://example.com/anthropic///' }).baseURL).toBe('https://example.com/anthropic///')
expect(modelInfo(resolveAdapterOptions({ thinking: 'disabled' }), 'deepseek-official', MODEL).reasoning?.efforts).toMatchObject([{ id: 'off', name: 'Off' }])
expect(resolveAdapterOptions({ baseURL: 'https://example.com/anthropic///' }).baseURL).toBe('https://example.com/anthropic///')
})
it.each([
{ thinking: 'disabled', reasoningEffort: 'high' }, { models: [{ id: '' }] },
@@ -242,7 +242,7 @@ describe('validated configuration', () => {
{ maxTokens: 0 }, { streamIdleTimeoutMs: 0 },
{ models: [{ id: MODEL, systemPromptUpdate: 'unsupported' }] },
])('rejects invalid composition input %#', (value) => {
expect(() => resolveAdapterOptions({ ...value, protocol: 'messages' } as Config)).toThrow()
expect(() => resolveAdapterOptions(value as Config)).toThrow()
})
})
@@ -268,7 +268,7 @@ describe('Messages images', () => {
expect(imagePricing(connection, MODEL, access).priceImages([ref])[0]?.visualTokens).toBe(0)
})
it('offloads an oldest prefix using exact encoded bytes and preserves durable references', async () => {
const config = resolveAdapterOptions({ protocol: 'messages',
const config = resolveAdapterOptions({
maxInlineRequestImageBytes: 4, inlineImageOffloadByteQuantum: 1, maxImagesPerRequest: 2, imageOffloadCountQuantum: 1,
})
const history = [result('a', [image, image])]
+1 -1
View File
@@ -153,7 +153,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () =>
const deepseekCtx = new Context()
contexts.push(deepseekCtx)
await deepseekCtx.plugin(LlmRuntime)
await deepseekCtx.plugin(LlmDeepSeek, { protocol: 'messages', thinking: 'disabled' })
await deepseekCtx.plugin(LlmDeepSeek, { thinking: 'disabled' })
const piCtx = await harness(FLASH)
+1 -1
View File
@@ -149,7 +149,7 @@ export class HarnessSdkJsonRpcServer {
: ReasoningEffortId(params.reasoningEffort)
if (!this.hasAdapterFor(provider)) {
if (provider !== 'deepseek-official') throw new Error(`no adapter registered for provider "${provider}"`)
this.llmFiber = await this.ctx.plugin(LlmDeepSeek, { protocol: 'messages' })
this.llmFiber = await this.ctx.plugin(LlmDeepSeek)
}
// Adapter presence was read from this service above; a successful fallback mount also requires it.
const llm = this.ctx.get('llm') as LlmRuntime
@@ -19,7 +19,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('first-prompt title provider with
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LlmRuntime)
await ctx.plugin(LlmDeepSeek, { protocol: 'messages', thinking: 'disabled' })
await ctx.plugin(LlmDeepSeek, { thinking: 'disabled' })
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SessionTitleService, {
@@ -28,7 +28,7 @@ export async function spawnHarness(workdir: string): Promise<Context> {
systemPrompt: { personaPrefix: 'You are a coding agent. Report only when the requested work is done.' },
})
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek, { protocol: 'messages' })
await ctx.plugin(LlmDeepSeek)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(BashEnvPlugin)
await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 })
@@ -22,7 +22,7 @@ async function harness(): Promise<Context> {
await mountAgentLoopTestDependencies(built)
await mountPtcRuntime(built)
await built.plugin(AgentLoop, { agents: [] })
await built.plugin(LlmDeepSeek, { protocol: 'messages' })
await built.plugin(LlmDeepSeek)
await built.plugin(SubagentRuntime)
await built.plugin(Spawn, { providerName: 'spawn' })
await built.plugin(PtcWorkflowEngine, { provider: 'spawn' })
@@ -5,7 +5,6 @@
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
protocol: messages
thinking: enabled
reasoningEffort: max
models:
-1
View File
@@ -8,7 +8,6 @@
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
protocol: messages
thinking: enabled
reasoningEffort: max
@@ -11,7 +11,6 @@
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
protocol: messages
apiKeyEnv: DEEPSEEK_API_KEY
streamIdleTimeoutMs: 172800000
models:
@@ -8,7 +8,6 @@
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
protocol: messages
thinking: enabled
reasoningEffort: max
-1
View File
@@ -10,7 +10,6 @@
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
protocol: messages
thinking: enabled
reasoningEffort: max
@@ -1,6 +1,5 @@
- id: llm-deepseek
config:
protocol: messages
models:
- id: deepseek-v4-flash
systemPromptUpdate: in-history
@@ -1,6 +1,5 @@
- id: llm-deepseek
config:
protocol: messages
models:
- id: deepseek-v4-flash
systemPromptUpdate: in-history
@@ -8,7 +8,6 @@
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
protocol: messages
thinking: enabled
reasoningEffort: max
retryPolicy:
@@ -2,7 +2,6 @@
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
protocol: messages
models:
- id: deepseek-v4-pro
@@ -2,7 +2,6 @@
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
protocol: messages
models:
- id: deepseek-v4-pro
@@ -2,7 +2,6 @@
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
protocol: messages
models:
- id: deepseek-v4-pro
@@ -5,7 +5,6 @@
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
protocol: messages
thinking: enabled
reasoningEffort: max
-1
View File
@@ -5,7 +5,6 @@
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
protocol: messages
thinking: enabled
reasoningEffort: max
models: