feat(web): use Messages for the default DeepSeek provider

This commit is contained in:
Yichen Jiang committed 2026-09-07 20:57:23 +08:00
1 parent 57c6e7e9b2
commit c258d3cf4e
38 files changed
+316 -148

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: 75a68ebcda03ea2c5850ad38c5124f81c6e9577f
2026-09-07-deepseek-messages-adapter.zh.md: c0a091e8d0b18cf90fa50d49dec48fc0500f7400
2026-09-07-deepseek-messages-adapter.md: f0fcaf96264bd70648c59cfd3de05c1edbda40aa
2026-09-07-deepseek-messages-adapter.zh.md: 4cb2ef79eb8596955f76d4bda024926c3870c33d
@@ -18,7 +18,7 @@ Assistant blocks remain the durable model-visible content. A versioned `ReplayEn
Image requests use bounded inline base64 versions from the attachment service. Shared attachment offload and DeepSeek token measurement keep request and measurement policy consistent. Files uploads remain outside this adapter because their endpoints and cache ownership differ from chat-completions; adding them requires a Messages-specific lifetime and error policy.
The Web profile mounts both direct adapters and leaves its selected default unchanged. Its Messages row uses `DEEPSEEK_MESSAGES_API_KEY`, so the two configuration cards can store different credentials. The shared DeepSeek editor dispatches writes by settings namespace and displays the appropriate protocol root; model identity remains provider-qualified in the composer.
The Web profile selects the Messages adapter and disables the Chat Completions adapter. Both its settings card and model group display DeepSeek, while the provider id remains `deepseek-messages` and settings remain under `llm-deepseek-messages`. First-run onboarding targets that route and reuses `DEEPSEEK_API_KEY`. Separating display names from provider ids preserves explicit protocol selection and recorded request identity. Saved selections remain user-owned; the composer switches them without rewriting Session history.
## Alternatives considered
@@ -30,6 +30,6 @@ The Web profile mounts both direct adapters and leaves its selected default unch
## Consequences
The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. Unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. Default compositions retain their selected provider.
The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. Unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. The shared base keeps Chat Completions; the Web overlay owns the Messages default.
Verification covers wire fixtures, real Loader composition, per-file unit coverage, a [recorded Session replay](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml), and credential-gated text, thinking, tool continuation, image, and cancellation requests. Live gateway checks establish compatibility with the configured gateway; they do not establish compatibility with every Anthropic proxy.
Verification covers wire fixtures, real Loader composition, per-file unit coverage, a [recorded Session replay](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml), a Web Messages Session replay, and credential-gated text, thinking, tool continuation, image, and cancellation requests. Live gateway checks establish compatibility with the configured gateway; they do not establish compatibility with every Anthropic proxy.
@@ -18,7 +18,7 @@ Status: implemented
图片请求使用附件服务生成的、有预算限制的内联 base64 版本。共享附件卸载机制和 DeepSeek token 计量使请求与计量策略保持一致。此适配器不负责 Files 上传,因为其端点和缓存所有权与 chat-completions 不同;增加上传支持需要定义 Messages 专属的生命周期和错误策略。
Web profile 同时挂载两个直接适配器,保留已选择的默认模型。Messages 行使用 `DEEPSEEK_MESSAGES_API_KEY`,使两张配置卡片可以存储不同凭据。共享 DeepSeek 编辑器按设置命名空间写入,并展示相应协议根地址;输入框中的模型标识仍包含提供方。
Web profile 选择 Messages 适配器并禁用 Chat Completions 适配器。配置卡片和模型分组均显示 DeepSeek,提供方 ID 保持为 `deepseek-messages`,设置仍位于 `llm-deepseek-messages`。首次启动引导面向该路由并复用 `DEEPSEEK_API_KEY`。显示名称与提供方 ID 分离,使协议选择和已记录的请求标识保持明确。已保存的选择仍由用户控制,输入框可以切换它们而不改写 Session 历史。
## 考虑过的替代方案
@@ -30,6 +30,6 @@ Web profile 同时挂载两个直接适配器,保留已选择的默认模型
## 结果
该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。默认组合保留已选择的提供者。
该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。共享 base 保留 Chat Completions,Web 覆盖层拥有 Messages 默认值。
验证覆盖协议夹具、真实 Loader 组合、逐文件单元覆盖率、[已记录 Session 回放](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml),以及凭证控制的文本、思考、工具续接、图片和取消请求。真实网关检查证明与已配置网关的兼容性,不能证明与所有 Anthropic 代理兼容。
验证覆盖协议夹具、真实 Loader 组合、逐文件单元覆盖率、[已记录 Session 回放](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml),Web Messages Session 回放,以及凭证控制的文本、思考、工具续接、图片和取消请求。真实网关检查证明与已配置网关的兼容性,不能证明与所有 Anthropic 代理兼容。
@@ -0,0 +1,80 @@
/** Recorded Web conversation through the shipped Messages route and DeepSeek model group. */
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { chromium, type Browser, type Page } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/deepseek-messages-chat', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl')
const MODE = webSnapshotMode()
const RECORD_PROMPT = '只回复 MESSAGES_WEB_READY,不调用工具。'
describe('web e2e: DeepSeek Messages conversation', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({
deepSeekMessages: true,
...(MODE === 'record' ? {} : {
replayFixture: FIXTURE,
paceMs: 5,
replayProviders: [{
id: 'deepseek-messages', name: 'DeepSeek',
models: [{
id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash',
contextWindow: 1_000_000, defaultMaxTokens: 256_000,
reasoningEfforts: ['off', 'low', 'high', 'max'], defaultReasoningEffort: 'high',
}],
}],
}),
})
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
}, 120_000)
afterAll(async () => {
try { await browser?.close() } finally { await scaffold?.close() }
})
it('records the Messages provider while displaying DeepSeek in the model selector', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-chat'))
const prompts = MODE === 'record' ? [RECORD_PROMPT] : fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))
expect(prompts).toHaveLength(1)
expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-messages', model: 'deepseek-v4-flash' })
await page.getByRole('button', { name: /^选择模型/ }).click()
await page.getByRole('menuitem', { name: /模型/ }).click()
await page.getByText('DeepSeek', { exact: true }).waitFor()
await page.getByRole('button', { name: /^选择模型/ }).click()
const input = page.locator('[data-composer-input]').first()
const settled = scaffold.whenTurnSettled()
await input.fill(prompts[0]!)
await input.press('Enter')
const sessionId = await settled
const session = scaffold.ctx.sessions.get(sessionId)!
expect(session.requestHeader()?.config.provider).toBe('deepseek-messages')
await page.getByText('MESSAGES_WEB_READY', { exact: true }).waitFor()
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
} else {
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'ui.expected.md'),
await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE)
}
expect(tripwire.pageErrors).toEqual([])
})
it.skipIf(MODE === 'record')('keeps the recorded-session inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['session.v2.jsonl', 'ui.expected.md'])
})
})
@@ -1,9 +1,9 @@
/** Web composition: both DeepSeek protocols keep independent settings, credentials, and model identities. */
/** Shipped Web Messages defaults, credential reuse, and recovery from a saved Chat Completions selection. */
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Browser, type Page } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
captureStableAria, compareOrRefreshGolden, launchWebScaffold,
watchConsole, webSnapshotMode, type WebScaffold,
@@ -12,16 +12,14 @@ import { connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCALE } from './s
const EXPECTED = fileURLToPath(new URL('./expected/deepseek-messages-settings/', import.meta.url))
describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek protocol coexistence', () => {
describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages default', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
vi.stubEnv('DEEPSEEK_MESSAGES_API_KEY', undefined)
vi.stubEnv('DEEPSEEK_MESSAGES_BASE_URL', undefined)
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, deepSeekMessages: true })
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
browser = await chromium.launch()
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
tripwire = watchConsole(page)
@@ -33,18 +31,21 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek protocol coex
try {
await browser?.close()
} finally {
try { await scaffold?.close() } finally { vi.unstubAllEnvs() }
await scaffold?.close()
}
})
it('saves each protocol independently and exposes both model catalogs', async () => {
it('offers one DeepSeek card and saves Messages settings using the existing credential reference', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-settings'))
expect(scaffold.ctx.llm.listProviders()).toContainEqual({ id: 'deepseek-messages', name: 'DeepSeek' })
expect(scaffold.ctx.llm.listProviders().some(provider => provider.id === 'deepseek-official')).toBe(false)
expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-messages', model: 'deepseek-v4-flash' })
await page.getByRole('button', { name: '设置', exact: true }).click()
const dialog = page.getByRole('dialog', { name: '设置', exact: true })
await dialog.getByRole('button', { name: '模型', exact: true }).click()
await dialog.getByText('DeepSeek Messages', { exact: true }).waitFor()
// Unconfigured composition routes appear as setup cards together.
const messages = dialog.getByText('DeepSeek Messages', { exact: true }).locator('..').locator('..')
await dialog.getByText('DeepSeek', { exact: true }).waitFor()
expect(await dialog.getByText('DeepSeek', { exact: true }).count()).toBe(1)
const messages = dialog.getByText('DeepSeek', { exact: true }).locator('..').locator('..')
await messages.getByText('自定义设置', { exact: true }).click()
expect(await messages.getByLabel('API 地址', { exact: true }).getAttribute('placeholder'))
.toBe('https://api.deepseek.com/anthropic')
@@ -54,22 +55,16 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek protocol coex
await messages.getByLabel('API 地址', { exact: true }).fill('https://messages.example/anthropic')
await messages.getByLabel('显示名称 1', { exact: true }).fill('Messages Flash')
await messages.getByRole('button', { name: '保存', exact: true }).click()
await dialog.getByText('已保存 DeepSeek Messages (deepseek-messages)。', { exact: true }).waitFor()
await dialog.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)', exact: true }).click()
const original = dialog.getByText('DeepSeek', { exact: true }).locator('..').locator('..')
await original.getByLabel('API 密钥', { exact: true }).fill('sk-e2e-completions')
await original.getByText('自定义设置', { exact: true }).click()
await original.getByLabel('API 地址', { exact: true }).fill('https://completions.example/v1')
await original.getByRole('button', { name: '保存', exact: true }).click()
await dialog.getByText('已保存 DeepSeek (deepseek-official)。', { exact: true }).waitFor()
await dialog.getByText('已保存 DeepSeek (deepseek-messages)。', { exact: true }).waitFor()
const settings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(settings).toContain('https://messages.example/anthropic')
expect(settings).toContain('https://completions.example/v1')
expect(settings).toContain('llm-deepseek-messages:')
expect(settings).not.toContain('llm-deepseek:')
expect(settings).not.toContain('sk-e2e-')
const credentials = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')
expect(credentials).toContain('DEEPSEEK_MESSAGES_API_KEY: sk-e2e-messages')
expect(credentials).toContain('DEEPSEEK_API_KEY: sk-e2e-completions')
expect(credentials).toContain('DEEPSEEK_API_KEY: sk-e2e-messages')
expect(credentials).not.toContain('DEEPSEEK_MESSAGES_API_KEY')
expect(await page.locator('body').innerText()).not.toContain('sk-e2e-')
await page.keyboard.press('Escape')
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'messages-settings-e2e')
@@ -80,4 +75,22 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek protocol coex
await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), webSnapshotMode())
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('replaces a saved Chat Completions default through the model selector', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-default'))
await page.keyboard.press('Escape')
await scaffold.ctx.agentDefaultModel.saveSelection({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
await page.reload({ waitUntil: 'load' })
const input = page.locator('[data-composer-input]').first()
await expect.poll(() => input.isEnabled()).toBe(false)
await expect.poll(() => input.getAttribute('data-placeholder')).toBe('当前模型不可用,请先选择模型')
await page.getByRole('button', { name: /^选择模型/ }).click()
await page.getByRole('menuitem', { name: /模型/ }).click()
await page.getByRole('menuitemradio', { name: 'Messages Flash', exact: true }).click()
await expect.poll(() => input.isEnabled()).toBe(true)
await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection().provider).toBe('deepseek-messages')
const settings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(settings).toContain('provider: deepseek-messages')
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
})
@@ -21,14 +21,7 @@
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- list:
- listitem:
- text: DeepSeek deepseek-official API 密钥
- textbox "API 密钥":
- /placeholder: 输入 API 密钥
- group: 自定义设置
- button "取消"
- button "保存"
- listitem:
- text: DeepSeek Messages deepseek-messages API 密钥
- text: DeepSeek deepseek-messages API 密钥
- textbox "API 密钥":
- /placeholder: 输入 API 密钥
- group:
@@ -1,13 +1,8 @@
- menu "模型与推理等级":
- group "DeepSeek":
- text: DeepSeek
- menuitemradio "DeepSeek-V4-Flash" [checked]:
- text: DeepSeek-V4-Flash
- menuitemradio "Messages Flash" [checked]:
- text: Messages Flash
- img
- menuitemradio "DeepSeek-V4-Pro"
- menuitemradio "DeepSeek-V4-Flash-Vision-Exp"
- group "DeepSeek Messages":
- text: DeepSeek Messages
- menuitemradio "Messages Flash"
- menuitemradio "DeepSeek-V4-Pro"
- menuitemradio "DeepSeek-V4-Flash-Vision-Exp"
@@ -23,14 +23,14 @@
- listitem:
- text: DeepSeek
- img "API 密钥已配置"
- button "编辑 DeepSeek (deepseek-official)": 编辑
- text: DeepSeek deepseek-official API 密钥
- button "编辑 DeepSeek (deepseek-messages)": 编辑
- text: DeepSeek deepseek-messages API 密钥
- textbox "API 密钥":
- /placeholder: 已配置——输入新值可替换
- group:
- text: 自定义设置 API 地址
- textbox "API 地址":
- /placeholder: https://api.deepseek.com
- /placeholder: https://api.deepseek.com/anthropic
- region "模型目录":
- text: 模型目录 已自定义模型目录
- button "恢复默认模型"
@@ -23,7 +23,7 @@
- listitem:
- text: DeepSeek
- img "API 密钥缺失"
- button "编辑 DeepSeek (deepseek-official)": 编辑
- button "编辑 DeepSeek (deepseek-messages)": 编辑
- text: 提供方
- combobox "提供方":
- option "amazon-bedrock"
@@ -77,7 +77,7 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-
async () => settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count(),
{ timeout: 10_000 },
).toBe(1)
await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 })
await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-messages)' }).waitFor({ timeout: 10_000 })
const dismissed = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(DISMISSED_EXPECTED, dismissed, MODE)
@@ -116,7 +116,7 @@ describe.skipIf(MODE === 'record')('web e2e: another usable provider ends first-
await page.getByRole('button', { name: '设置', exact: true }).click()
await settings.waitFor({ timeout: 10_000 })
await settings.getByRole('button', { name: '模型' }).click()
await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).waitFor({ timeout: 10_000 })
await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-messages)' }).waitFor({ timeout: 10_000 })
expect(await settings.getByRole('textbox', { name: 'API 密钥', exact: true }).count()).toBe(0)
expect((await page.content()).includes('sk-e2e-minimax')).toBe(false)
+20 -14
View File
@@ -246,11 +246,14 @@ class RouteOnlyAdapter extends LlmAdapter {
}
}
function replayProviders(contextWindow: number | undefined): typeof REPLAY_PROVIDERS {
if (contextWindow === undefined) return REPLAY_PROVIDERS
function replayProviders(contextWindow: number | undefined, messages: boolean): typeof REPLAY_PROVIDERS {
return REPLAY_PROVIDERS.map(provider => ({
...provider,
models: provider.models.map(model => ({ ...model, contextWindow })),
id: messages ? 'deepseek-messages' : provider.id,
models: provider.models.map(model => ({
...model,
...contextWindow === undefined ? {} : { contextWindow },
})),
}))
}
@@ -358,7 +361,7 @@ export interface LaunchOptions {
* keyless first-run configuration lane; the default disables the adapter.
*/
deepSeekMissingCredential?: boolean
/** Keep the shipped Messages adapter mounted for configuration scenarios; disabled in ordinary keyless replays. */
/** Record or replay a Messages scenario; older scenarios explicitly retain their recorded Chat Completions route. */
deepSeekMessages?: boolean
/** Leave the current welcome notice pending; ordinary scenarios pre-acknowledge it before browser boot. */
welcomeNoticePending?: boolean
@@ -441,6 +444,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
throw new Error('deepSeekMissingCredential is a keyless replay/refresh option')
}
const maskDeepSeekCredential = mode !== 'record' && options.deepSeekMissingCredential === true
const messages = options.deepSeekMessages === true || options.deepSeekMissingCredential === true
const originalDeepSeekCredential = process.env.DEEPSEEK_API_KEY
let credentialEnvironmentRestored = false
const restoreCredentialEnvironment = (): void => {
@@ -514,6 +518,12 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
const patches: PatchOptions[] = [
...basePatches,
...surfacePatches,
// Historical Session fixtures retain their provider identity. Shipped
// configuration scenarios and new Messages recordings use the Web default.
...messages ? [] : [{
id: 'agent-default-model',
config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}],
...extraOverlayPatches,
// The roster's shipped presets are the plugin's own, bundled inside
// `dsh-agent-presets` and prepended by it. Pin only the machine-local
@@ -631,12 +641,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
baseURL: options.deepSeekSearch.baseURL,
},
}],
...mode === 'record' || options.deepSeekMissingCredential === true
? []
: [{ id: 'llm-deepseek', disabled: true }],
...options.deepSeekMessages === true
? []
: [{ id: 'llm-deepseek-messages', disabled: true }],
{ id: 'llm-deepseek', disabled: messages || mode !== 'record' },
{ id: 'llm-deepseek-messages', disabled: !messages || (mode !== 'record' && !maskDeepSeekCredential) },
]
// Sessions inherit the gateway's process.cwd() default; run the boot from
@@ -724,7 +730,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
port = boundPort
// Fill the open llm seam on the settled root ctx. Ordinary keyless modes
// disable both direct adapters; the first-run lane keeps llm-deepseek but has no
// disable both direct adapters; the first-run lane keeps Messages but has no
// replay fixture and never streams. The direct install, unlike the plugin
// row, returns the ReplayHandle for the teardown consumption check.
if (options.replayProvidersOnly) {
@@ -761,7 +767,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
if (mode !== 'record' && replayFixture !== undefined) {
replayHandle = installLlmReplay(ctx, {
file: replayFixture,
providers: (options.replayProviders ?? replayProviders(options.replayContextWindow)).map(provider => ({
providers: (options.replayProviders ?? replayProviders(options.replayContextWindow, messages)).map(provider => ({
...provider,
...(options.replayRetryPolicy === undefined ? {} : { retryPolicy: options.replayRetryPolicy }),
})),
@@ -776,8 +782,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
// a fixture would, with streaming that still fails loud: the scenario
// issues no model calls, and one that slipped in must not pass quietly.
ctx.effect(() => ctx.llm.registerAdapter(
replayProviders(options.replayContextWindow).map(provider => provider.id),
new RouteOnlyAdapter(replayProviders(options.replayContextWindow)),
replayProviders(options.replayContextWindow, messages).map(provider => provider.id),
new RouteOnlyAdapter(replayProviders(options.replayContextWindow, messages)),
), 'web e2e scaffold: route-only adapter')
}
baseUrl = `http://${browserHost}:${String(port)}`
+4 -4
View File
@@ -84,7 +84,7 @@ it('assembles the shipped Web transport, catalog, guidance, and defaults', async
expect(index.headers.get('content-encoding')).toBe('gzip')
expect(index.headers.get('vary')).toContain('Accept-Encoding')
await index.body?.cancel()
expect(ctx.llm.providerRetryPolicy('deepseek-official')).toMatchInlineSnapshot(`
expect(ctx.llm.providerRetryPolicy('deepseek-messages')).toMatchInlineSnapshot(`
{
"initialDelayMs": 500,
"jitterRatio": 0.1,
@@ -100,10 +100,10 @@ it('assembles the shipped Web transport, catalog, guidance, and defaults', async
],
}
`)
await ctx.settings.update('llm-deepseek', {
await ctx.settings.update('llm-deepseek-messages', {
retryPolicy: { mode: 'always', maxRetries: 5 },
})
expect(ctx.llm.providerRetryPolicy('deepseek-official')).toMatchInlineSnapshot(`
expect(ctx.llm.providerRetryPolicy('deepseek-messages')).toMatchInlineSnapshot(`
{
"initialDelayMs": 500,
"jitterRatio": 0.1,
@@ -178,7 +178,7 @@ it('assembles the shipped Web transport, catalog, guidance, and defaults', async
const commandHandle = await scaffold.ctx.agents.create({
sessionId: SessionId('shipped-command-catalog'),
meta: { cwd: scaffold.workspaceCwd },
agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
agentOptions: { provider: 'deepseek-messages', model: 'deepseek-v4-flash' },
})
try {
expect(scaffold.ctx.commands.list(commandHandle.agent)).toContainEqual({
+37 -45
View File
@@ -32,6 +32,21 @@ import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requir
const WEB_SURFACE_PROMPT = fileURLToPath(new URL('./expected/web-runtime-context/web-surface-prompt.expected.md', import.meta.url))
const authenticatedCookies = new Map<string, Promise<{ origin: string; cookie: string }>>()
/** Messages SSE text, optionally truncated before content and message completion. */
function messagesResponse(text: string, complete = true): string {
const events: Array<{ type: string; [key: string]: unknown }> = [
{ type: 'message_start', message: { id: 'msg_smoke', model: 'deepseek-v4-flash', usage: { input_tokens: 3, output_tokens: 0 } } },
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } },
]
if (complete) events.push(
{ type: 'content_block_stop', index: 0 },
{ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } },
{ type: 'message_stop' },
)
return events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join('')
}
/** Exchange a printed process token once for Node-side HTTP/WebSocket probes. */
function authenticatedWeb(launchUrl: string): Promise<{ origin: string; cookie: string }> {
const existing = authenticatedCookies.get(launchUrl)
@@ -392,8 +407,9 @@ describe('dsh web keyless CLI smoke', () => {
writeFileSync(join(workspace, 'AGENTS.md'), 'web-workspace-context-probe\n')
interface NativeProviderRequest {
messages?: { role?: string; content?: string }[]
tools?: { function?: { name?: string } }[]
system?: string
messages?: { role?: string; content?: { type?: string; text?: string }[] }[]
tools?: { name?: string }[]
}
let resolveProviderRequests!: (requests: NativeProviderRequest[]) => void
const requests: NativeProviderRequest[] = []
@@ -409,13 +425,7 @@ describe('dsh web keyless CLI smoke', () => {
if ((parsed.tools?.length ?? 0) > 0) requests.push(parsed)
if (requests.length === 1) resolveProviderRequests(requests)
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end([
'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
'data: {"choices":[{"delta":{"content":"done"}}]}',
'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
'data: [DONE]',
'',
].join('\n\n'))
response.end(messagesResponse('done'))
})
})
await new Promise<void>(resolve => provider.listen(0, '127.0.0.1', resolve))
@@ -430,7 +440,7 @@ describe('dsh web keyless CLI smoke', () => {
env: {
...process.env,
DEEPSEEK_API_KEY: 'keyless-web-workspace',
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
DEEPSEEK_MESSAGES_BASE_URL: `http://127.0.0.1:${address.port}`,
DSH_HOME: join(workspace, '.dsh'),
DSH_AGENTS_HOME: join(workspace, '.agents'),
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
@@ -457,15 +467,14 @@ describe('dsh web keyless CLI smoke', () => {
if (captured === undefined) {
throw new Error('provider did not receive the workspace projection request')
}
const workspaceMessage = captured.messages?.find(message =>
message.role === 'user' && message.content?.includes('web-workspace-context-probe'))
const systemMessage = captured.messages?.find(message => message.role === 'system')
const workspaceMessage = captured.messages?.flatMap(message => message.role === 'user' ? message.content ?? [] : [])
.find(block => block.type === 'text' && block.text?.includes('web-workspace-context-probe'))
const expectedWebSection = readFileSync(WEB_SURFACE_PROMPT, 'utf8').trimEnd()
.replace('{{webUrl}}', new URL(baseUrl).origin)
expect(systemMessage?.content).toContain(expectedWebSection)
expect(captured.system).toContain(expectedWebSection)
expect(workspaceMessage).toMatchInlineSnapshot(`
{
"content": "<system-reminder>
"text": "<system-reminder>
The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.
Instructions from: AGENTS.md
@@ -473,10 +482,10 @@ describe('dsh web keyless CLI smoke', () => {
web-workspace-context-probe
</system-reminder>",
"role": "user",
"type": "text",
}
`)
expect(captured.tools?.map(tool => tool.function?.name)
expect(captured.tools?.map(tool => tool.name)
.filter(name => name === 'web_search' || name === 'web_fetch'))
.toMatchInlineSnapshot(`
[
@@ -511,26 +520,16 @@ describe('dsh web keyless CLI smoke', () => {
const mainRequest = !titleRequest && body.includes(promptMarker)
response.writeHead(200, { 'content-type': 'text/event-stream' })
if (!mainRequest) {
response.end([
'data: {"choices":[{"delta":{"content":"Web retry title"}}]}',
'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}',
'data: [DONE]',
'',
].join('\n\n'))
response.end(messagesResponse('Web retry title'))
return
}
mainAttempts++
if (mainAttempts === 1) {
response.write('data: {"choices":[{"delta":{"content":"WEB_RETRY_DISCARDED"}}]}\n\n')
response.write(messagesResponse('WEB_RETRY_DISCARDED', false))
setTimeout(() => { response.destroy() }, 20)
return
}
response.end([
`data: {"choices":[{"delta":{"content":"${recoveredMarker}"}}]}`,
'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
'data: [DONE]',
'',
].join('\n\n'))
response.end(messagesResponse(recoveredMarker))
})
})
await new Promise<void>(resolve => provider.listen(0, '127.0.0.1', resolve))
@@ -545,7 +544,7 @@ describe('dsh web keyless CLI smoke', () => {
env: {
...process.env,
DEEPSEEK_API_KEY: 'keyless-web-retry',
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
DEEPSEEK_MESSAGES_BASE_URL: `http://127.0.0.1:${address.port}`,
DSH_HOME: join(workspace, '.dsh'),
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
},
@@ -595,8 +594,8 @@ describe('dsh web keyless CLI smoke', () => {
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-ptc-'))
interface PtcModeProviderRequest {
messages?: { role?: string; content?: string }[]
tools?: { function?: { name?: string } }[]
system?: string
tools?: { name?: string }[]
}
let resolveProviderRequest!: (request: PtcModeProviderRequest) => void
const providerRequest = new Promise<PtcModeProviderRequest>((resolve) => {
@@ -609,13 +608,7 @@ describe('dsh web keyless CLI smoke', () => {
request.on('end', () => {
resolveProviderRequest(JSON.parse(body) as PtcModeProviderRequest)
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end([
'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
'data: {"choices":[{"delta":{"content":"done"}}]}',
'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
'data: [DONE]',
'',
].join('\n\n'))
response.end(messagesResponse('done'))
})
})
await new Promise<void>(resolve => provider.listen(0, '127.0.0.1', resolve))
@@ -630,7 +623,7 @@ describe('dsh web keyless CLI smoke', () => {
env: {
...process.env,
DEEPSEEK_API_KEY: 'keyless-web-ptc',
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
DEEPSEEK_MESSAGES_BASE_URL: `http://127.0.0.1:${address.port}`,
DSH_TOOLS_MODE: 'ptc',
DSH_HOME: join(workspace, '.dsh'),
DSH_AGENTS_HOME: join(workspace, '.agents'),
@@ -654,10 +647,9 @@ describe('dsh web keyless CLI smoke', () => {
setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref()
}),
])
expect(captured.tools?.map(tool => tool.function?.name)).toEqual(['run_code'])
const system = captured.messages?.find(message => message.role === 'system')
expect(system?.content).toContain('## Writing code for run_code')
expect(system?.content).toContain('declare const tools')
expect(captured.tools?.map(tool => tool.name)).toEqual(['run_code'])
expect(captured.system).toContain('## Writing code for run_code')
expect(captured.system).toContain('declare const tools')
} finally {
const closed = child.exitCode === null
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
+1
View File
@@ -43,6 +43,7 @@
"tests/settings-chrome.e2e.ts",
"tests/models-settings.e2e.ts",
"tests/deepseek-messages-settings.e2e.ts",
"tests/deepseek-messages-chat.e2e.ts",
"tests/default-model.e2e.ts",
"tests/github-ready-review.e2e.ts",
"tests/streaming-fence-highlight.e2e.ts",
+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: fb71f5f8ac89d513619d0473349b0942d8d4cba0
README.zh.md: 4ada766c902b402983ac05df1d2c06b41c361506
README.md: da51f7c4506ad87dac9d5e720470a2a6aa3fb3d9
README.zh.md: e55063fe10e274658a6c46e879bc9f2ac92b35da
+3 -1
View File
@@ -36,7 +36,9 @@ 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** offers separate DeepSeek and [DeepSeek Messages](../../llm/llm-deepseek-messages/README.md) cards. Both protocols can remain configured together; choose the provider in the model selector.
**Settings → Models** shows **DeepSeek**, backed by the [Messages adapter](../../llm/llm-deepseek-messages/README.md). The Web default is `deepseek-messages` / `deepseek-v4-flash`, using `DEEPSEEK_API_KEY`; the Web patch disables the Chat Completions adapter while other profiles retain their own composition.
Saved model selections override the composition default. To switch a saved `deepseek-official` default or an existing conversation, select a model under **DeepSeek** in the composer; this saves the new default for later sessions without rewriting earlier request headers. Messages endpoint and model settings belong to `llm-deepseek-messages`; Chat Completions endpoint overrides are not copied.
### Configuration
+3 -1
View File
@@ -36,7 +36,9 @@ 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 Messages](../../llm/llm-deepseek-messages/README.zh.md) 卡片。两种协议可以同时配置,在模型选择器中切换提供方。
**设置 → 模型**显示由 [Messages 适配器](../../llm/llm-deepseek-messages/README.zh.md) 提供的 **DeepSeek**。Web 默认选择 `deepseek-messages` / `deepseek-v4-flash`,使用 `DEEPSEEK_API_KEY`;Web 补丁禁用 Chat Completions 适配器,其他 profile 保持各自组合。
已保存的模型选择优先于组合默认值。要切换已保存的 `deepseek-official` 默认值或已有会话,在输入框的 **DeepSeek** 分组下选择模型即可;此操作会保存后续会话的默认模型,不改写此前的请求头。Messages 端点和模型设置属于 `llm-deepseek-messages`,不会复制 Chat Completions 的端点覆盖值。
### 配置
+9 -2
View File
@@ -13,6 +13,14 @@
# ── surface-specific values the base deliberately omits ─────────────────────
- id: llm-deepseek
disabled: true
- id: agent-default-model
config:
provider: deepseek-messages
model: deepseek-v4-flash
- id: system-prompt
config:
personaSuffix: Your working directory is {{cwd}}.
@@ -42,11 +50,10 @@
# `dsh.client` rows are the browser roster the modules node half scans into
# window.__DSH_BOOT__; the modules row is simultaneously a host row.
- insert:
# Keep Messages credentials independent from the Chat Completions route.
- id: llm-deepseek-messages
name: '@deepseek-ai/dsh-llm-deepseek-messages'
config:
apiKeyEnv: DEEPSEEK_MESSAGES_API_KEY
apiKeyEnv: DEEPSEEK_API_KEY
# Host-owned opt-in sampled when a new Web session receives its preset
# delegation tools. The Plugins page edits this settings namespace.
@@ -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/client/ui-settings-models/README.md
README.md: d012d9846f6a18078b3557b6c026293d999836f4
README.zh.md: 047679cfaa23ebe119fcea88731a9063e8f7009f
README.md: bfc58338423d943ecbe42dec95ac25d1b1408cb3
README.zh.md: d356a97a9674ae87175ba784a3786633239e6c97
+1 -1
View File
@@ -69,7 +69,7 @@ Each settings write carries the card's current `revision`, so a concurrent write
### Onboarding coordinator
The notice step owns its exact copy in `src/client/locales.ts` and its acknowledgement version in `src/onboarding-copy.ts`; on loopback it compares and writes `ui-onboarding.welcomeNoticeVersion` through the existing settings API, and only an explicit Continue records the current version. A non-loopback browser cannot use that Host-only namespace, so acknowledgement is process-local and the notice returns after reload. The DeepSeek step renders the existing `ProviderEditor` in credential-only mode inside the shared onboarding modal; `credentials.set` stays the only secret write, and no provider settings are changed.
The notice step owns its exact copy in `src/client/locales.ts` and its acknowledgement version in `src/onboarding-copy.ts`; on loopback it compares and writes `ui-onboarding.welcomeNoticeVersion` through the existing settings API, and only an explicit Continue records the current version. A non-loopback browser cannot use that Host-only namespace, so acknowledgement is process-local and the notice returns after reload. The DeepSeek step targets `deepseek-messages` in `llm-deepseek-messages` and renders the existing `ProviderEditor` in credential-only mode inside the shared onboarding modal; `credentials.set` stays the only secret write, and no provider settings are changed.
</details>
@@ -69,7 +69,7 @@ kind: "package-reference"
### 引导协调器
声明步骤在 `src/client/locales.ts` 中持有精确文案,并在 `src/onboarding-copy.ts` 中持有确认版本;回环时它通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`,且只有显式点击「继续」才会记录当前版本。非回环浏览器无法使用这个仅限宿主的 namespace,因此确认只保留在进程内,刷新后声明会再次出现。DeepSeek 步骤在共享引导模态框内以仅凭据模式渲染既有 `ProviderEditor`;`credentials.set` 仍是唯一的机密写入,且不改变任何提供方设置。
声明步骤在 `src/client/locales.ts` 中持有精确文案,并在 `src/onboarding-copy.ts` 中持有确认版本;回环时它通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`,且只有显式点击「继续」才会记录当前版本。非回环浏览器无法使用这个仅限宿主的 namespace,因此确认只保留在进程内,刷新后声明会再次出现。DeepSeek 步骤面向 `llm-deepseek-messages` 中的 `deepseek-messages`,在共享引导模态框内以仅凭据模式渲染既有 `ProviderEditor`;`credentials.set` 仍是唯一的机密写入,且不改变任何提供方设置。
</details>
@@ -81,10 +81,10 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
}
const row = state.rows.find(candidate =>
candidate.entry.provider === 'deepseek-official'
&& candidate.entry.settingsNs === 'llm-deepseek'
candidate.entry.provider === 'deepseek-messages'
&& candidate.entry.settingsNs === 'llm-deepseek-messages'
&& candidate.entry.settingsPath.length === 0)
const namespace = state.namespaces.get('llm-deepseek')
const namespace = state.namespaces.get('llm-deepseek-messages')
/* v8 ignore next 2 -- credential-missing is derived only from this exact joined row. */
if (row === undefined || namespace === undefined) return null
@@ -147,7 +147,7 @@ export function apply(ctx: ClientContext): void {
}, WelcomeNotice))
ctx.slots.inject('settings.onboarding', () => ctx.slots.register({
name: 'settings.onboarding',
id: 'deepseek-official',
id: 'deepseek-messages',
order: 0,
inject: deepSeekOnboardingInjected,
}, DeepSeekOnboardingDialog))
@@ -304,8 +304,8 @@ export function onboardingReadiness(state: ModelsSettingsState): OnboardingReadi
}
if (state.rows.some(providerUsable)) return { kind: 'provider-ready' }
const row = state.rows.find(candidate =>
candidate.entry.provider === 'deepseek-official'
&& candidate.entry.settingsNs === 'llm-deepseek'
candidate.entry.provider === 'deepseek-messages'
&& candidate.entry.settingsNs === 'llm-deepseek-messages'
&& candidate.entry.settingsPath.length === 0)
if (row === undefined) return { kind: 'adapter-absent' }
if (!row.entry.active) {
@@ -97,9 +97,9 @@ describe('ui-settings-models apply', () => {
component: WelcomeNotice,
options: { id: 'welcome-notice', order: -100 },
})
const deepSeek = onboarding.find(entry => entry.options.id === 'deepseek-official')!
const deepSeek = onboarding.find(entry => entry.options.id === 'deepseek-messages')!
expect(deepSeek.component).toBe(DeepSeekOnboardingDialog)
expect(deepSeek.options).toMatchObject({ id: 'deepseek-official', order: 0 })
expect(deepSeek.options).toMatchObject({ id: 'deepseek-messages', order: 0 })
const deepSeekInjected = (
deepSeek.inject as unknown as () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected
)()
@@ -245,7 +245,7 @@ describe('pushed invalidations', () => {
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const entry = b.slots.entries('settings.onboarding')
.find(candidate => candidate.options.id === 'deepseek-official')!
.find(candidate => candidate.options.id === 'deepseek-messages')!
const injected = (
entry.inject as unknown as
() => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected
@@ -47,7 +47,7 @@ const useSessionPendingInteraction: DeepSeekOnboardingDialogProps['useSessionPen
function deepSeekNamespace(apiKeyEnv: string | null): SettingsNamespaceView {
const value = apiKeyEnv === null ? {} : { apiKeyEnv }
return {
ns: 'llm-deepseek',
ns: 'llm-deepseek-messages',
schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue,
value,
base: value,
@@ -92,16 +92,16 @@ function harness(options: {
return Promise.resolve(remoteOk(
options.provider === false || options.providerActive === false
? []
: [{ id: 'deepseek-official', name: 'DeepSeek' }],
: [{ id: 'deepseek-messages', name: 'DeepSeek' }],
))
},
listConfigurableProviders: () => Promise.resolve(remoteOk(
options.provider === false
? []
: [{
provider: 'deepseek-official',
provider: 'deepseek-messages',
displayName: 'DeepSeek',
settingsNs: options.providerSettingsNs ?? 'llm-deepseek',
settingsNs: options.providerSettingsNs ?? 'llm-deepseek-messages',
settingsPath: [],
}],
)),
@@ -138,7 +138,7 @@ function harness(options: {
const complete = vi.fn()
const unusedHook = (() => { throw new Error('unused standard hook') }) as never
const props: DeepSeekOnboardingDialogProps = {
stepId: 'deepseek-official',
stepId: 'deepseek-messages',
complete,
openSection,
useSessions: unusedHook,
@@ -9,9 +9,9 @@ const missingCredential: CredentialInfo = { configured: false, writable: true }
function row(overrides: Partial<ProviderRow> = {}): ProviderRow {
return {
entry: {
provider: 'deepseek-official',
provider: 'deepseek-messages',
displayName: 'DeepSeek',
settingsNs: 'llm-deepseek',
settingsNs: 'llm-deepseek-messages',
settingsPath: [],
active: true,
},
@@ -1673,7 +1673,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
declaredBy: 'an entry in \'sidebar.settings\' (client-ui-settings-general), so it exists while that entry is mounted',
occupants: [
'client-ui-settings-models WelcomeNotice id \'welcome-notice\'',
'client-ui-settings-models DeepSeekOnboardingDialog id \'deepseek-official\'',
'client-ui-settings-models DeepSeekOnboardingDialog id \'deepseek-messages\'',
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'settings.onboarding\', () => ctx.slots.register(\n { name: \'settings.onboarding\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
@@ -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-messages/README.md
README.md: bad9d561a8d08e48a1ce75e9a670cbf640da4a6f
README.zh.md: 9da666d981209a28cf9f3e01b2ad3a5cfa746ce4
README.md: 4ff26629899b4f03c64e782cb82ddaf7928b1866
README.zh.md: 07825fa8b2e9b8534e8f7a2a7d2bd89c8423be09
+1 -1
View File
@@ -27,7 +27,7 @@ Use DeepSeek through an Anthropic Messages endpoint while retaining Harness tool
Mount this plugin beside `dsh-llm` in a Cordis composition and select `provider: deepseek-messages`. Model ids pass through unchanged; the catalog is advisory.
The [Web profile](../../bundle/web-app/README.md) mounts this adapter beside Chat Completions. In **Settings → Models**, configure **DeepSeek Messages**, then select its model in the composer. Web uses the independent `DEEPSEEK_MESSAGES_API_KEY` credential reference; entering a key on this card does not replace `DEEPSEEK_API_KEY`. Its endpoint and model edits apply live under `llm-deepseek-messages`. The default selected provider remains `deepseek-official`.
The [Web profile](../../bundle/web-app/README.md) uses this adapter as its default DeepSeek provider and disables Chat Completions. The **DeepSeek** card and first-run prompt use `DEEPSEEK_API_KEY`; endpoint and model edits apply live under `llm-deepseek-messages`. The provider id remains `deepseek-messages` independently of its display name.
### Minimal configuration
@@ -27,7 +27,7 @@ kind: "package-reference"
在 Cordis 组合中将本插件与 `dsh-llm` 一起挂载,并选择 `provider: deepseek-messages`。模型 ID 原样发送,目录仅供发现使用。
[Web profile](../../bundle/web-app/README.zh.md) 同时挂载本适配器与 Chat Completions。在**设置 → 模型**中配置 **DeepSeek Messages**,然后在输入框的模型选择器中选择它的模型。Web 使用独立的 `DEEPSEEK_MESSAGES_API_KEY` 凭据引用;在此卡片输入密钥不会替换 `DEEPSEEK_API_KEY`。端点和模型修改通过 `llm-deepseek-messages` 即时生效。默认选择的提供方仍为 `deepseek-official`。
[Web profile](../../bundle/web-app/README.zh.md) 默认使用本适配器作为 DeepSeek 提供方,并禁用 Chat Completions。**DeepSeek** 卡片和首次启动引导使用 `DEEPSEEK_API_KEY`;端点和模型修改通过 `llm-deepseek-messages` 即时生效。提供方 ID 保持为 `deepseek-messages`,与显示名称相互独立。
### 最小配置
@@ -30,7 +30,7 @@ export interface AdapterDependencies {
export class DeepSeekMessagesAdapter extends LlmAdapter {
constructor(private readonly dependencies: AdapterDependencies) { super() }
override providerInfo(provider: string) { return { id: provider, name: 'DeepSeek Messages' } }
override providerInfo(provider: string) { return { id: provider, name: 'DeepSeek' } }
override providerRetryPolicy(_provider: string) { return this.dependencies.connection().retryPolicy }
override listModels(provider: string) {
const connection = this.dependencies.connection()
@@ -54,7 +54,7 @@ export function apply(ctx: Context, config: Config): void {
return attachments === undefined ? undefined : resolveImageAttachmentAccess(attachments, path => ctx.get('fs')?.processPathFromHostPath(path), ref)
},
})
ctx.llm.registerConfigurableProviders([{ provider: PROVIDER, displayName: 'DeepSeek Messages', settingsNs: name, settingsPath: [] }])
ctx.llm.registerConfigurableProviders([{ provider: PROVIDER, displayName: 'DeepSeek', settingsNs: name, settingsPath: [] }])
const registration = ctx.llm.registerAdapter([PROVIDER], adapter)
let retryPolicy = current.retryPolicy
ctx.inject(['settings'], (child) => {
@@ -49,7 +49,7 @@ describe('direct Messages HTTP', () => {
'user-agent': expect.stringContaining('deepseek-harness/') as string, 'x-deepseek-harness-user-id': 'test-user',
'x-deepseek-harness-session-id': 'session-test', 'x-deepseek-harness-compact': '1',
}, body: { thinking: { type: 'enabled' }, output_config: { effort: 'high' } } })
expect(llm.providerInfo('deepseek-messages')).toEqual({ id: 'deepseek-messages', name: 'DeepSeek Messages' })
expect(llm.providerInfo('deepseek-messages')).toEqual({ id: 'deepseek-messages', name: 'DeepSeek' })
expect(await llm.listModels('deepseek-messages')).toHaveLength(3)
expect(await llm.resolveModel('deepseek-messages', MODEL)).toMatchObject({ id: MODEL })
expect(llm.imageRequestPricing('deepseek-messages', MODEL)).toBeDefined()
@@ -0,0 +1,16 @@
{"type":"session","version":2,"id":"{{session:1}}","createdAt":1788785092857,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0,"agentPreset":"standard"}
{"type":"permission/preset","data":{"preset":"workspace-write"}}
{"type":"sandbox/mode","data":{"mode":"workspace-write"}}
{"type":"approval/policy","data":{"policy":"ask"}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"只回复 MESSAGES_WEB_READY,不调用工具。"}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"}]}}
{"type":"turn/start","data":{"turn":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"user/message","data":{"content":[{"type":"text","text":"只回复 MESSAGES_WEB_READY,不调用工具。"}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"}
{"type":"session/title","data":{"title":"只回复 MESSAGES_WEB_READY,不调用","messageSeqs":[7],"source":{"kind":"fallback"}}}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-messages","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-messages","model":"deepseek-v4-flash","contextWindow":1000000}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":""},{"type":"text","text":"MESSAGES_WEB_READY"}],"source":{"kind":"model","provider":"deepseek-messages","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"27635dca-411b-4bd0-bcbb-b8a7e27c2265"},{"type":"text"}]}},"id":"{{message:3}}"},"usage":{"inputTokens":6885,"outputTokens":9,"cacheReadTokens":1280,"cacheWriteTokens":0,"totalTokens":8174},"stream":[{"type":"chunk","time":1788785093863,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"chunk","time":1788785094864,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":""}}},{"type":"chunk","time":1788785094864,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788785094864,"index":1,"dt":[21,0,0,0,0,22,0],"texts":["M","ESS","AGES","_W","EB","_","READ","Y"]},{"type":"chunk","time":1788785094907,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MESSAGES_WEB_READY"}}},{"type":"chunk","time":1788785094907,"chunk":{"type":"usage","usage":{"inputTokens":6885,"outputTokens":9,"cacheReadTokens":1280,"cacheWriteTokens":0,"totalTokens":8174}}},{"type":"chunk","time":1788785094908,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"27635dca-411b-4bd0-bcbb-b8a7e27c2265"},{"type":"text"}]}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,7 @@
version: 1
scenario: deepseek-messages-chat
profile: web
composition: web-default
recording: live
header:
class: web-default
@@ -0,0 +1,53 @@
- banner:
- navigation "会话层级":
- button "只回复 MESSAGES_WEB_READY,不调用" [disabled]
- img
- text: 标准模式
- button "Session 日志":
- text: Session 日志
- img
- tablist:
- tab "对话" [selected]
- tab "轨迹"
- button "系统提示词":
- img
- img
- text: 系统提示词
- text: 只回复 MESSAGES_WEB_READY,不调用工具。 {{clock}}
- button "复制":
- img
- button "已思考":
- text: 已思考
- img
- button "思考":
- img
- img
- text: 思考
- paragraph: MESSAGES_WEB_READY
- button "复制":
- img
- button "好的回答":
- img
- button "有问题的回答":
- img
- button "在新对话中分支":
- img
- button "用量 8.2K tok":
- img
- text: 用量 8.2K tok
- button "用时 {{duration}}":
- img
- text: 用时 {{duration}}
- text: {{clock}}
- textbox "发消息或做任务… / 调用指令 @ 文件或对话"
- button "指令":
- img
- button "添加附件":
- img
- button "访问模式,当前:工作区内修改": 工作区内修改
- button "选择模型,当前 DeepSeek-V4-Flash,推理等级 high":
- text: DeepSeek-V4-Flash high
- img
- button "上下文已用 1%"
- button "发送消息" [disabled]
- text: 1 轮 · 1 步 LLM {{duration}} 首 token 平均 {{duration}} · {{throughput}} tok/s 缓存命中 16% 输入 8.2K tok · 输出 9 tok
+1
View File
@@ -34,6 +34,7 @@
"apps/web/tests/settings-chrome.e2e.ts",
"apps/web/tests/models-settings.e2e.ts",
"apps/web/tests/deepseek-messages-settings.e2e.ts",
"apps/web/tests/deepseek-messages-chat.e2e.ts",
"apps/web/tests/onboarding-deepseek-config.e2e.ts",
"apps/web/tests/onboarding-usable-provider.e2e.ts",
"apps/web/tests/remote-welcome.e2e.ts",