Forráskód Böngészése

feat(llm): sync V41 Messages catalog and keep Web on Chat Completions

Yichen Jiang 3 hete
szülő
commit
4af56cf808
37 módosított fájl, 161 hozzáadás és 128 törlés
  1. 2 2
      .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml
  2. 2 2
      .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md
  3. 2 2
      .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md
  4. 8 5
      apps/web/tests/deepseek-messages-settings.e2e.ts
  5. 16 6
      apps/web/tests/expected/deepseek-messages-settings/cards.expected.md
  6. 1 0
      apps/web/tests/expected/deepseek-messages-settings/picker.expected.md
  7. 3 3
      apps/web/tests/expected/onboarding-deepseek-config/models.expected.md
  8. 1 1
      apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md
  9. 2 2
      apps/web/tests/onboarding-usable-provider.e2e.ts
  10. 7 5
      apps/web/tests/scaffold.ts
  11. 6 4
      apps/web/tests/shipped-composition.e2e.ts
  12. 45 37
      apps/web/tests/smoke-real.e2e.ts
  13. 2 2
      docs/config-catalog.i18n.yaml
  14. 1 1
      docs/config-catalog.md
  15. 1 1
      docs/config-catalog.zh.md
  16. 2 2
      packages/bundle/web-app/README.i18n.yaml
  17. 2 2
      packages/bundle/web-app/README.md
  18. 2 2
      packages/bundle/web-app/README.zh.md
  19. 1 8
      packages/bundle/web-app/cordis.patch.yml
  20. 2 2
      packages/client/ui-settings-models/README.i18n.yaml
  21. 1 1
      packages/client/ui-settings-models/README.md
  22. 1 1
      packages/client/ui-settings-models/README.zh.md
  23. 3 3
      packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx
  24. 1 1
      packages/client/ui-settings-models/src/client/index.ts
  25. 2 2
      packages/client/ui-settings-models/src/client/store.ts
  26. 3 3
      packages/client/ui-settings-models/tests/apply.client.spec.ts
  27. 5 5
      packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx
  28. 2 2
      packages/client/ui-settings-models/tests/readiness.client.spec.ts
  29. 1 1
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  30. 2 2
      packages/llm/llm-deepseek-messages/README.i18n.yaml
  31. 4 4
      packages/llm/llm-deepseek-messages/README.md
  32. 4 4
      packages/llm/llm-deepseek-messages/README.zh.md
  33. 1 1
      packages/llm/llm-deepseek-messages/package.json
  34. 7 1
      packages/llm/llm-deepseek-messages/src/config.ts
  35. 13 4
      packages/llm/llm-deepseek-messages/tests/adapter.spec.ts
  36. 1 1
      packages/llm/llm-deepseek-messages/tests/serialize.spec.ts
  37. 2 3
      snapshots/web/deepseek-messages-chat/ui.expected.md

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml

@@ -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: 5f52c7899d8a2705a7f7f8438e19548af9b54581
-2026-09-07-deepseek-messages-adapter.zh.md: c87b5b6a3adf88e51155889960adc45ea961657e
+2026-09-07-deepseek-messages-adapter.md: 2c19db253c627737c23e3d838cdb0bfcd720c73c
+2026-09-07-deepseek-messages-adapter.zh.md: dc68397a369a37b3a7b82bcfaec7f922efb969c6

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md

@@ -20,7 +20,7 @@ Image requests use bounded inline base64 versions from the attachment service. S
 
 System updates use the existing [route capability](2026-09-02-in-history-system-prompt-replacement.md) when explicitly declared for an endpoint/model. Messages retains the initial top-level system and emits later snapshots as native system turns after the corresponding user/tool-result turn, preserving previously sent prefixes. This placement differs from the loop's system-before-user admission; serialization changes neither the durable log nor conversation-turn order. Undeclared routes consolidate the latest snapshot at the top level, including direct compaction calls. Capability inference from protocol or model names is insufficient because support and update semantics depend on the deployed endpoint.
 
-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.
+The Web profile retains Chat Completions and includes a disabled Messages row for explicit opt-in. First-run onboarding targets Chat Completions and reuses `DEEPSEEK_API_KEY`. An enabled Messages adapter displays DeepSeek while retaining its `deepseek-messages` provider id and `llm-deepseek-messages` settings namespace. Saved selections remain user-owned; enabling a protocol does not copy endpoint overrides or rewrite Session history. Both adapters advertise `deepseek-flash` as DeepSeek-V41-Flash with text/image input and system updates in history, while preserving the V4 catalog entries and their capabilities.
 
 ## Alternatives considered
 
@@ -34,6 +34,6 @@ The Web profile selects the Messages adapter and disables the Chat Completions a
 
 ## 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. The shared base keeps Chat Completions; the Web overlay owns the Messages default.
+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 and Web default to Chat Completions; Messages requires explicit opt-in.
 
 Verification covers wire fixtures, real Loader composition, per-file unit coverage, [recorded Session replay](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml) with [unknown replay versions](../../../../snapshots/session/deepseek-messages-degraded-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.

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md

@@ -20,7 +20,7 @@ Status: implemented
 
 系统提示词更新在端点与模型显式声明支持时,使用现有[路由能力](2026-09-02-in-history-system-prompt-replacement.zh.md)。Messages 保留初始顶层 system,在对应的用户或工具结果轮次之后,将后续快照发送为原生 system 轮次,保留此前发送的前缀。这个位置不同于循环先 system、后 user 的接纳顺序;序列化既不改写持久化日志,也不改变对话轮次的顺序。未声明能力的路由将最新快照归并到顶层,直接压缩调用也如此。仅凭协议或模型名称推断能力并不充分,因为支持情况和更新语义取决于实际部署的端点。
 
-Web profile 选择 Messages 适配器并禁用 Chat Completions 适配器。配置卡片和模型分组均显示 DeepSeek,提供方 ID 保持为 `deepseek-messages`,设置仍位于 `llm-deepseek-messages`。首次启动引导面向该路由并复用 `DEEPSEEK_API_KEY`。显示名称与提供方 ID 分离,使协议选择和已记录的请求标识保持明确。已保存的选择仍由用户控制,输入框可以切换它们而不改写 Session 历史。
+Web profile 保留 Chat Completions,并包含默认禁用、需显式启用的 Messages 行。首次启动引导面向 Chat Completions,并复用 `DEEPSEEK_API_KEY`。启用的 Messages 适配器显示 DeepSeek,同时保留 `deepseek-messages` 提供方 ID 和 `llm-deepseek-messages` 设置命名空间。已保存的选择仍由用户控制;启用协议不会复制端点覆盖值或改写 Session 历史。两个适配器均将 `deepseek-flash` 显示为 DeepSeek-V41-Flash,声明文本/图片输入与历史内 system 更新,同时保留 V4 目录条目及其能力。
 
 ## 考虑过的替代方案
 
@@ -34,6 +34,6 @@ Web profile 选择 Messages 适配器并禁用 Chat Completions 适配器。配
 
 ## 结果
 
-该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。共享 base 保留 Chat Completions,Web 覆盖层拥有 Messages 默认值。
+该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。共享 base 与 Web 默认使用 Chat Completions;Messages 需要显式启用。
 
 验证覆盖协议夹具、真实 Loader 组合、逐文件单元覆盖率、[已记录 Session 回放](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml)与[未知回放版本](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml),Web Messages Session 回放,以及凭证控制的文本、思考、工具续接、图片和取消请求。真实网关检查证明与已配置网关的兼容性,不能证明与所有 Anthropic 代理兼容。

+ 8 - 5
apps/web/tests/deepseek-messages-settings.e2e.ts

@@ -1,4 +1,4 @@
-/** Shipped Web Messages defaults, credential reuse, and recovery from a saved Chat Completions selection. */
+/** Opt-in Web Messages configuration, 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'
@@ -12,19 +12,18 @@ 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 Messages default', () => {
+describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages opt-in', () => {
   let scaffold: WebScaffold
   let browser: Browser
   let page: Page
   let tripwire: ReturnType<typeof watchConsole>
 
   beforeAll(async () => {
-    scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
+    scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, deepSeekMessages: true })
     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 page.getByRole('button', { name: '稍后配置', exact: true }).click()
   }, 120_000)
 
   afterAll(async () => {
@@ -39,7 +38,7 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages defa
     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' })
+    expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-messages', model: 'deepseek-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()
@@ -53,6 +52,7 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages defa
       await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode())
     await messages.getByLabel('API 密钥', { exact: true }).fill('sk-e2e-messages')
     await messages.getByLabel('API 地址', { exact: true }).fill('https://messages.example/anthropic')
+    expect(await messages.getByLabel('模型 ID 1').inputValue()).toBe('deepseek-flash')
     await messages.getByLabel('显示名称 1', { exact: true }).fill('Messages Flash')
     await messages.getByRole('button', { name: '保存', exact: true }).click()
     await dialog.getByText('已保存 DeepSeek (deepseek-messages)。', { exact: true }).waitFor()
@@ -60,6 +60,9 @@ describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek Messages defa
     const settings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
     expect(settings).toContain('https://messages.example/anthropic')
     expect(settings).toContain('llm-deepseek-messages:')
+    await expect(scaffold.ctx.llm.resolveModelInfo('deepseek-messages', 'deepseek-flash')).resolves.toMatchObject({
+      name: 'Messages Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history',
+    })
     expect(settings).not.toContain('llm-deepseek:')
     expect(settings).not.toContain('sk-e2e-')
     const credentials = await readFile(join(scaffold.harnessHome, '.credentials.yaml'), 'utf8')

+ 16 - 6
apps/web/tests/expected/deepseek-messages-settings/cards.expected.md

@@ -32,34 +32,44 @@
           - text: 模型目录 正在使用适配器默认模型
           - textbox "模型 ID 1":
             - /placeholder: 模型 ID
-            - text: deepseek-v4-flash
+            - text: deepseek-flash
           - textbox "显示名称 1":
             - /placeholder: 显示名称
-            - text: DeepSeek-V4-Flash
+            - text: DeepSeek-V41-Flash
           - button "容量 1":
             - img
           - button "删除模型 1":
             - img
           - textbox "模型 ID 2":
             - /placeholder: 模型 ID
-            - text: deepseek-v4-pro
+            - text: deepseek-v4-flash
           - textbox "显示名称 2":
             - /placeholder: 显示名称
-            - text: DeepSeek-V4-Pro
+            - text: DeepSeek-V4-Flash
           - button "容量 2":
             - img
           - button "删除模型 2":
             - img
           - textbox "模型 ID 3":
             - /placeholder: 模型 ID
-            - text: deepseek-v4-flash-vision-exp
+            - text: deepseek-v4-pro
           - textbox "显示名称 3":
             - /placeholder: 显示名称
-            - text: DeepSeek-V4-Flash-Vision-Exp
+            - text: DeepSeek-V4-Pro
           - button "容量 3":
             - img
           - button "删除模型 3":
             - img
+          - textbox "模型 ID 4":
+            - /placeholder: 模型 ID
+            - text: deepseek-v4-flash-vision-exp
+          - textbox "显示名称 4":
+            - /placeholder: 显示名称
+            - text: DeepSeek-V4-Flash-Vision-Exp
+          - button "容量 4":
+            - img
+          - button "删除模型 4":
+            - img
           - button "添加模型":
             - img
             - text: 添加模型

+ 1 - 0
apps/web/tests/expected/deepseek-messages-settings/picker.expected.md

@@ -4,5 +4,6 @@
     - menuitemradio "Messages Flash" [checked]:
       - text: Messages Flash
       - img
+    - menuitemradio "DeepSeek-V4-Flash"
     - menuitemradio "DeepSeek-V4-Pro"
     - menuitemradio "DeepSeek-V4-Flash-Vision-Exp"

+ 3 - 3
apps/web/tests/expected/onboarding-deepseek-config/models.expected.md

@@ -23,14 +23,14 @@
     - listitem:
       - text: DeepSeek
       - img "API 密钥已配置"
-      - button "编辑 DeepSeek (deepseek-messages)": 编辑
-      - text: DeepSeek deepseek-messages API 密钥
+      - button "编辑 DeepSeek (deepseek-official)": 编辑
+      - text: DeepSeek deepseek-official API 密钥
       - textbox "API 密钥":
         - /placeholder: 已配置——输入新值可替换
       - group:
         - text: 自定义设置 API 地址
         - textbox "API 地址":
-          - /placeholder: https://api.deepseek.com/anthropic
+          - /placeholder: https://api.deepseek.com
         - region "模型目录":
           - text: 模型目录 已自定义模型目录
           - button "恢复默认模型"

+ 1 - 1
apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md

@@ -23,7 +23,7 @@
     - listitem:
       - text: DeepSeek
       - img "API 密钥缺失"
-      - button "编辑 DeepSeek (deepseek-messages)": 编辑
+      - button "编辑 DeepSeek (deepseek-official)": 编辑
   - text: 提供方
   - combobox "提供方":
     - option "amazon-bedrock"

+ 2 - 2
apps/web/tests/onboarding-usable-provider.e2e.ts

@@ -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-messages)' }).waitFor({ timeout: 10_000 })
+    await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).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-messages)' }).waitFor({ timeout: 10_000 })
+    await settings.getByRole('button', { name: '编辑 DeepSeek (deepseek-official)' }).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)

+ 7 - 5
apps/web/tests/scaffold.ts

@@ -447,7 +447,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 messages = options.deepSeekMessages === true
   const originalDeepSeekCredential = process.env.DEEPSEEK_API_KEY
   let credentialEnvironmentRestored = false
   const restoreCredentialEnvironment = (): void => {
@@ -523,7 +523,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     ...surfacePatches,
     // Keyless scenarios retain the recorded default; explicit scenario overlays win.
     ...messages
-      ? [{ id: 'agent-default-model', config: { provider: 'deepseek-messages', model: 'deepseek-v4-flash' } }]
+      ? [{ id: 'agent-default-model', config: { provider: 'deepseek-messages', model: maskDeepSeekCredential ? 'deepseek-flash' : 'deepseek-v4-flash' } }]
       : mode === 'record' || options.deepSeekMissingCredential === true
         ? []
         : [{ id: 'agent-default-model', config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }],
@@ -641,8 +641,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
           baseURL: options.deepSeekSearch.baseURL,
         },
       }],
-    { id: 'llm-deepseek', disabled: messages || mode !== 'record' },
-    { id: 'llm-deepseek-messages', disabled: !messages || (mode !== 'record' && !maskDeepSeekCredential) },
+    ...maskDeepSeekCredential && !messages ? [] : [
+      { 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
@@ -731,7 +733,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 Messages but has no
+    // disable both direct adapters; the first-run lane keeps the selected adapter 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) {

+ 6 - 4
apps/web/tests/shipped-composition.e2e.ts

@@ -79,13 +79,15 @@ afterEach(async () => {
 it('assembles the shipped Web transport, catalog, guidance, and defaults', async () => {
   scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
   const ctx = scaffold.ctx
+  expect(ctx.llm.listProviders().some(provider => provider.id === 'deepseek-messages')).toBe(false)
+  expect(ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'deepseek-official', model: 'deepseek-flash' })
   const index = await fetch(`http://127.0.0.1:${String(ctx.webServer.port)}`, {
     headers: { 'accept-encoding': 'gzip' },
   })
   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-messages')).toMatchInlineSnapshot(`
+  expect(ctx.llm.providerRetryPolicy('deepseek-official')).toMatchInlineSnapshot(`
     {
       "initialDelayMs": 500,
       "jitterRatio": 0.1,
@@ -101,10 +103,10 @@ it('assembles the shipped Web transport, catalog, guidance, and defaults', async
       ],
     }
   `)
-  await ctx.settings.update('llm-deepseek-messages', {
+  await ctx.settings.update('llm-deepseek', {
     retryPolicy: { mode: 'always', maxRetries: 5 },
   })
-  expect(ctx.llm.providerRetryPolicy('deepseek-messages')).toMatchInlineSnapshot(`
+  expect(ctx.llm.providerRetryPolicy('deepseek-official')).toMatchInlineSnapshot(`
     {
       "initialDelayMs": 500,
       "jitterRatio": 0.1,
@@ -179,7 +181,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-messages', model: 'deepseek-v4-flash' },
+    agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
   })
   try {
     expect(scaffold.ctx.commands.list(commandHandle.agent)).toContainEqual({

+ 45 - 37
apps/web/tests/smoke-real.e2e.ts

@@ -32,21 +32,6 @@ 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)
@@ -407,9 +392,8 @@ describe('dsh web keyless CLI smoke', () => {
     writeFileSync(join(workspace, 'AGENTS.md'), 'web-workspace-context-probe\n')
 
     interface NativeProviderRequest {
-      system?: string
-      messages?: { role?: string; content?: { type?: string; text?: string }[] }[]
-      tools?: { name?: string }[]
+      messages?: { role?: string; content?: string }[]
+      tools?: { function?: { name?: string } }[]
     }
     let resolveProviderRequests!: (requests: NativeProviderRequest[]) => void
     const requests: NativeProviderRequest[] = []
@@ -425,7 +409,13 @@ 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(messagesResponse('done'))
+        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'))
       })
     })
     await new Promise<void>(resolve => provider.listen(0, '127.0.0.1', resolve))
@@ -440,7 +430,7 @@ describe('dsh web keyless CLI smoke', () => {
         env: {
           ...process.env,
           DEEPSEEK_API_KEY: 'keyless-web-workspace',
-          DEEPSEEK_MESSAGES_BASE_URL: `http://127.0.0.1:${address.port}`,
+          DEEPSEEK_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'),
@@ -467,14 +457,15 @@ describe('dsh web keyless CLI smoke', () => {
       if (captured === undefined) {
         throw new Error('provider did not receive the workspace projection request')
       }
-      const workspaceMessage = captured.messages?.flatMap(message => message.role === 'user' ? message.content ?? [] : [])
-        .find(block => block.type === 'text' && block.text?.includes('web-workspace-context-probe'))
+      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 expectedWebSection = readFileSync(WEB_SURFACE_PROMPT, 'utf8').trimEnd()
         .replace('{{webUrl}}', new URL(baseUrl).origin)
-      expect(captured.system).toContain(expectedWebSection)
+      expect(systemMessage?.content).toContain(expectedWebSection)
       expect(workspaceMessage).toMatchInlineSnapshot(`
         {
-          "text": "<system-reminder>
+          "content": "<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
@@ -482,10 +473,10 @@ describe('dsh web keyless CLI smoke', () => {
         web-workspace-context-probe
 
         </system-reminder>",
-          "type": "text",
+          "role": "user",
         }
       `)
-      expect(captured.tools?.map(tool => tool.name)
+      expect(captured.tools?.map(tool => tool.function?.name)
         .filter(name => name === 'web_search' || name === 'web_fetch'))
         .toMatchInlineSnapshot(`
           [
@@ -520,16 +511,26 @@ describe('dsh web keyless CLI smoke', () => {
         const mainRequest = !titleRequest && body.includes(promptMarker)
         response.writeHead(200, { 'content-type': 'text/event-stream' })
         if (!mainRequest) {
-          response.end(messagesResponse('Web retry title'))
+          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'))
           return
         }
         mainAttempts++
         if (mainAttempts === 1) {
-          response.write(messagesResponse('WEB_RETRY_DISCARDED', false))
+          response.write('data: {"choices":[{"delta":{"content":"WEB_RETRY_DISCARDED"}}]}\n\n')
           setTimeout(() => { response.destroy() }, 20)
           return
         }
-        response.end(messagesResponse(recoveredMarker))
+        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'))
       })
     })
     await new Promise<void>(resolve => provider.listen(0, '127.0.0.1', resolve))
@@ -544,7 +545,7 @@ describe('dsh web keyless CLI smoke', () => {
         env: {
           ...process.env,
           DEEPSEEK_API_KEY: 'keyless-web-retry',
-          DEEPSEEK_MESSAGES_BASE_URL: `http://127.0.0.1:${address.port}`,
+          DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
           DSH_HOME: join(workspace, '.dsh'),
           TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
         },
@@ -594,8 +595,8 @@ describe('dsh web keyless CLI smoke', () => {
     const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-ptc-'))
 
     interface PtcModeProviderRequest {
-      system?: string
-      tools?: { name?: string }[]
+      messages?: { role?: string; content?: string }[]
+      tools?: { function?: { name?: string } }[]
     }
     let resolveProviderRequest!: (request: PtcModeProviderRequest) => void
     const providerRequest = new Promise<PtcModeProviderRequest>((resolve) => {
@@ -608,7 +609,13 @@ 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(messagesResponse('done'))
+        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'))
       })
     })
     await new Promise<void>(resolve => provider.listen(0, '127.0.0.1', resolve))
@@ -623,7 +630,7 @@ describe('dsh web keyless CLI smoke', () => {
         env: {
           ...process.env,
           DEEPSEEK_API_KEY: 'keyless-web-ptc',
-          DEEPSEEK_MESSAGES_BASE_URL: `http://127.0.0.1:${address.port}`,
+          DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
           DSH_TOOLS_MODE: 'ptc',
           DSH_HOME: join(workspace, '.dsh'),
           DSH_AGENTS_HOME: join(workspace, '.agents'),
@@ -647,9 +654,10 @@ 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.name)).toEqual(['run_code'])
-      expect(captured.system).toContain('## Writing code for run_code')
-      expect(captured.system).toContain('declare const tools')
+      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')
     } finally {
       const closed = child.exitCode === null
         ? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -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 docs/config-catalog.md
-config-catalog.md: 2e31f0427973d764a3012feb91ec44537bcfcd81
-config-catalog.zh.md: 57210d507ad27cc3280d2b88c9b49a13e9d22979
+config-catalog.md: a7e7064cc065a20dd43aae27c4c7330c89b62685
+config-catalog.zh.md: cc3da4c77894e92c0620c6773993658bc7eff001

+ 1 - 1
docs/config-catalog.md

@@ -1117,7 +1117,7 @@ export interface Config {
   maxTokens?: number
   /** Context capacity for models without an explicit entry; defaults to 1000000. */
   defaultContextWindow?: number
-  /** Advisory catalog; omission advertises V4 Flash, Pro, and Flash Vision Exp. */
+  /** Advisory catalog; omission advertises V41 Flash, V4 Flash, Pro, and Flash Vision Exp. */
   models?: CatalogModel[]
   /** Maximum idle time while waiting on the provider; defaults to 300000 ms. */
   streamIdleTimeoutMs?: number

+ 1 - 1
docs/config-catalog.zh.md

@@ -1119,7 +1119,7 @@ export interface Config {
   maxTokens?: number
   /** Context capacity for models without an explicit entry; defaults to 1000000. */
   defaultContextWindow?: number
-  /** Advisory catalog; omission advertises V4 Flash, Pro, and Flash Vision Exp. */
+  /** Advisory catalog; omission advertises V41 Flash, V4 Flash, Pro, and Flash Vision Exp. */
   models?: CatalogModel[]
   /** Maximum idle time while waiting on the provider; defaults to 300000 ms. */
   streamIdleTimeoutMs?: number

+ 2 - 2
packages/bundle/web-app/README.i18n.yaml

@@ -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: f1841ae398ce340b43054cbe86b8e76392235cc9
-README.zh.md: 9ce1ba310bcbe5bcd021273cacdb698cdbc03f77
+README.md: 72ffaade9c4f1a6f1153ec2f6e0bbda137490a2f
+README.zh.md: d18182d0dcc700d5191ea9a5fb7c980e90d9742d

+ 2 - 2
packages/bundle/web-app/README.md

@@ -36,9 +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** 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.
+**Settings → Models** shows **DeepSeek** through Chat Completions, using `DEEPSEEK_API_KEY`. The Web default is `deepseek-official` / `deepseek-flash` (DeepSeek-V41-Flash). The [Messages adapter](../../llm/llm-deepseek-messages/README.md) is included with `disabled: true`; an explicit patch can enable its `llm-deepseek-messages` row and select `deepseek-messages` / `deepseek-flash` in `agent-default-model`. Disable the `llm-deepseek` row in that patch to show only the Messages provider.
 
-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.
+Saved model selections override the composition default. Select a model under **DeepSeek** in the composer to save the 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
 

+ 2 - 2
packages/bundle/web-app/README.zh.md

@@ -36,9 +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。
 
-**设置 → 模型**显示由 [Messages 适配器](../../llm/llm-deepseek-messages/README.zh.md) 提供的 **DeepSeek**。Web 默认选择 `deepseek-messages` / `deepseek-v4-flash`,使用 `DEEPSEEK_API_KEY`;Web 补丁禁用 Chat Completions 适配器,其他 profile 保持各自组合。
+**设置 → 模型**通过 Chat Completions 显示 **DeepSeek**,使用 `DEEPSEEK_API_KEY`。Web 默认选择 `deepseek-official` / `deepseek-flash`(DeepSeek-V41-Flash)。[Messages 适配器](../../llm/llm-deepseek-messages/README.zh.md)以 `disabled: true` 包含在组合中;显式补丁可启用 `llm-deepseek-messages` 行,并在 `agent-default-model` 中选择 `deepseek-messages` / `deepseek-flash`。在同一补丁中禁用 `llm-deepseek` 行可只显示 Messages 提供方。
 
-已保存的模型选择优先于组合默认值。要切换已保存的 `deepseek-official` 默认值或已有会话,在输入框的 **DeepSeek** 分组下选择模型即可;此操作会保存后续会话的默认模型,不改写此前的请求头。Messages 端点和模型设置属于 `llm-deepseek-messages`,不会复制 Chat Completions 的端点覆盖值。
+已保存的模型选择优先于组合默认值。在输入框的 **DeepSeek** 分组下选择模型会保存后续会话的默认模型,不改写此前的请求头。Messages 端点和模型设置属于 `llm-deepseek-messages`,不会复制 Chat Completions 的端点覆盖值。
 
 ### 配置
 

+ 1 - 8
packages/bundle/web-app/cordis.patch.yml

@@ -13,14 +13,6 @@
 
 # ── 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}}.
@@ -52,6 +44,7 @@
 - insert:
     - id: llm-deepseek-messages
       name: '@deepseek-ai/dsh-llm-deepseek-messages'
+      disabled: true
       config:
         apiKeyEnv: DEEPSEEK_API_KEY
 

+ 2 - 2
packages/client/ui-settings-models/README.i18n.yaml

@@ -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: b8aae57e829e5351669af9f05be35ed9b02e01a9
-README.zh.md: d1f40a797fc0306f724a49cd444aa5547407ad81
+README.md: d7a0846c9baba3a84216b2a50248ed16601f2b92
+README.zh.md: d01bdb7c89a602c36e8cb637e33a9e744fb9a2b5

+ 1 - 1
packages/client/ui-settings-models/README.md

@@ -71,7 +71,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 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.
+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-official` in `llm-deepseek` 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>
 

+ 1 - 1
packages/client/ui-settings-models/README.zh.md

@@ -71,7 +71,7 @@ kind: "package-reference"
 
 ### 引导协调器
 
-声明步骤在 `src/client/locales.ts` 中持有精确文案,并在 `src/onboarding-copy.ts` 中持有确认版本;回环时它通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`,且只有显式点击「继续」才会记录当前版本。非回环浏览器无法使用这个仅限宿主的 namespace,因此确认只保留在进程内,刷新后声明会再次出现。DeepSeek 步骤面向 `llm-deepseek-messages` 中的 `deepseek-messages`,在共享引导模态框内以仅凭据模式渲染既有 `ProviderEditor`;`credentials.set` 仍是唯一的机密写入,且不改变任何提供方设置。
+声明步骤在 `src/client/locales.ts` 中持有精确文案,并在 `src/onboarding-copy.ts` 中持有确认版本;回环时它通过既有 settings API 比较并写入 `ui-onboarding.welcomeNoticeVersion`,且只有显式点击「继续」才会记录当前版本。非回环浏览器无法使用这个仅限宿主的 namespace,因此确认只保留在进程内,刷新后声明会再次出现。DeepSeek 步骤面向 `llm-deepseek` 中的 `deepseek-official`,在共享引导模态框内以仅凭据模式渲染既有 `ProviderEditor`;`credentials.set` 仍是唯一的机密写入,且不改变任何提供方设置。
 
 </details>
 

+ 3 - 3
packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx

@@ -81,10 +81,10 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
   }
 
   const row = state.rows.find(candidate =>
-    candidate.entry.provider === 'deepseek-messages'
-    && candidate.entry.settingsNs === 'llm-deepseek-messages'
+    candidate.entry.provider === 'deepseek-official'
+    && candidate.entry.settingsNs === 'llm-deepseek'
     && candidate.entry.settingsPath.length === 0)
-  const namespace = state.namespaces.get('llm-deepseek-messages')
+  const namespace = state.namespaces.get('llm-deepseek')
   /* v8 ignore next 2 -- credential-missing is derived only from this exact joined row. */
   if (row === undefined || namespace === undefined) return null
 

+ 1 - 1
packages/client/ui-settings-models/src/client/index.ts

@@ -147,7 +147,7 @@ export function apply(ctx: ClientContext): void {
   }, WelcomeNotice))
   ctx.slots.inject('settings.onboarding', () => ctx.slots.register({
     name: 'settings.onboarding',
-    id: 'deepseek-messages',
+    id: 'deepseek-official',
     order: 0,
     inject: deepSeekOnboardingInjected,
   }, DeepSeekOnboardingDialog))

+ 2 - 2
packages/client/ui-settings-models/src/client/store.ts

@@ -306,8 +306,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-messages'
-    && candidate.entry.settingsNs === 'llm-deepseek-messages'
+    candidate.entry.provider === 'deepseek-official'
+    && candidate.entry.settingsNs === 'llm-deepseek'
     && candidate.entry.settingsPath.length === 0)
   if (row === undefined) return { kind: 'adapter-absent' }
   if (!row.entry.active) {

+ 3 - 3
packages/client/ui-settings-models/tests/apply.client.spec.ts

@@ -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-messages')!
+    const deepSeek = onboarding.find(entry => entry.options.id === 'deepseek-official')!
     expect(deepSeek.component).toBe(DeepSeekOnboardingDialog)
-    expect(deepSeek.options).toMatchObject({ id: 'deepseek-messages', order: 0 })
+    expect(deepSeek.options).toMatchObject({ id: 'deepseek-official', 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-messages')!
+      .find(candidate => candidate.options.id === 'deepseek-official')!
     const injected = (
       entry.inject as unknown as
       () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected

+ 5 - 5
packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx

@@ -52,7 +52,7 @@ const useSessionPendingInteraction: DeepSeekOnboardingDialogProps['useSessionPen
 function deepSeekNamespace(apiKeyEnv: string | null): SettingsNamespaceView {
   const value = apiKeyEnv === null ? {} : { apiKeyEnv }
   return {
-    ns: 'llm-deepseek-messages',
+    ns: 'llm-deepseek',
     schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as JsonValue,
     value,
     base: value,
@@ -97,16 +97,16 @@ function harness(options: {
         return Promise.resolve(remoteOk(
           options.provider === false || options.providerActive === false
             ? []
-            : [{ id: 'deepseek-messages', name: 'DeepSeek' }],
+            : [{ id: 'deepseek-official', name: 'DeepSeek' }],
         ))
       },
       listConfigurableProviders: () => Promise.resolve(remoteOk(
         options.provider === false
           ? []
           : [{
-            provider: 'deepseek-messages',
+            provider: 'deepseek-official',
             displayName: 'DeepSeek',
-            settingsNs: options.providerSettingsNs ?? 'llm-deepseek-messages',
+            settingsNs: options.providerSettingsNs ?? 'llm-deepseek',
             settingsPath: [],
           }],
       )),
@@ -143,7 +143,7 @@ function harness(options: {
   const complete = vi.fn()
   const unusedHook = (() => { throw new Error('unused standard hook') }) as never
   const props: DeepSeekOnboardingDialogProps = {
-    stepId: 'deepseek-messages',
+    stepId: 'deepseek-official',
     complete,
     openSection,
     useSessions: unusedHook,

+ 2 - 2
packages/client/ui-settings-models/tests/readiness.client.spec.ts

@@ -9,9 +9,9 @@ const missingCredential: CredentialInfo = { configured: false, writable: true }
 function row(overrides: Partial<ProviderRow> = {}): ProviderRow {
   return {
     entry: {
-      provider: 'deepseek-messages',
+      provider: 'deepseek-official',
       displayName: 'DeepSeek',
-      settingsNs: 'llm-deepseek-messages',
+      settingsNs: 'llm-deepseek',
       settingsPath: [],
       active: true,
     },

+ 1 - 1
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -1814,7 +1814,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-messages\'',
+      'client-ui-settings-models DeepSeekOnboardingDialog id \'deepseek-official\'',
     ],
     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 - 2
packages/llm/llm-deepseek-messages/README.i18n.yaml

@@ -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: ee561262615926a3df07272bba1286bba73f8cde
-README.zh.md: 71c198b2f6b7d3b2f071e6f1d22b506b6e3fc333
+README.md: ad15409879e4c4d3f4a8d0c748a3602839f16b44
+README.zh.md: efff98339a3e965e8e1a6501a58e1e8cd241e999

+ 4 - 4
packages/llm/llm-deepseek-messages/README.md

@@ -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) 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.
+The [Web profile](../../bundle/web-app/README.md) includes this adapter disabled by default and uses Chat Completions. When enabled, the **DeepSeek** card uses `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
 
@@ -48,7 +48,7 @@ The [Web profile](../../bundle/web-app/README.md) uses this adapter as its defau
 |---|---|---|
 | `apiKeyEnv` | `DEEPSEEK_API_KEY` | Credential reference; credentials service, or launch environment when that service is absent |
 | `thinking` / `reasoningEffort` | enabled / high | `off`, `low`, `high`, `max`; disabled deployment policy permits only off |
-| `models` | V4 Flash, Pro, Flash Vision Exp | Advisory catalog and exact-model capacity/image overrides |
+| `models` | V41 Flash, V4 Flash, Pro, Flash Vision Exp | Advisory catalog and exact-model capacity/image overrides |
 | `maxTokens` / `defaultContextWindow` | 256000 / 1000000 | Default output cap and context capacity |
 | `maxInlineRequestImageBytes` / `maxImagesPerRequest` | 20 MiB / 600 | Retained base64 bytes and image occurrences |
 | `streamIdleTimeoutMs` | 300000 | Maximum idle wait for the provider |
@@ -61,11 +61,11 @@ Tools use native `tool_use` and `tool_result` blocks. Adjacent user messages are
 
 Without an in-history capability declaration, the latest system message supplies the complete top-level `system` prompt, including for direct calls carrying multiple snapshots. An empty latest snapshot clears the historical prompt. The agent loop also consolidates updates at the system head when continuing or resuming these routes, including after switching from a capable route. One-shot `GenerateOptions.system` remains a separate prefix. Non-text system content is rejected.
 
-Set `models[].systemPromptUpdate: in-history` only for an endpoint/model that treats the latest system update as the complete effective prompt. Default and unlisted models do not enable it. Capable routes keep the initial prompt in the top-level field and serialize later snapshots as native `role: system` messages. [Anthropic placement rules](https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages) put updates after the user turn, including all tool results, and before the next assistant. The adapter maps the loop's earlier system admission to that position without changing durable messages or the relative order of conversation turns. An update without a user turn to follow, or an empty in-history update, is rejected; loop-owned clearing consolidates the history before serialization.
+Set `models[].systemPromptUpdate: in-history` only for an endpoint/model that treats the latest system update as the complete effective prompt. The default `deepseek-flash` entry (DeepSeek-V41-Flash) enables it and accepts text and images; V4 and unlisted models do not enable system updates in history. A custom `models` list replaces the defaults. Capable routes keep the initial prompt in the top-level field and serialize later snapshots as native `role: system` messages. [Anthropic placement rules](https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages) put updates after the user turn, including all tool results, and before the next assistant. The adapter maps the loop's earlier system admission to that position without changing durable messages or the relative order of conversation turns. An update without a user turn to follow, or an empty in-history update, is rejected; loop-owned clearing consolidates the history before serialization.
 
 ```yaml
 models:
-  - id: deepseek-v4-flash
+  - id: my-model
     systemPromptUpdate: in-history
 ```
 

+ 4 - 4
packages/llm/llm-deepseek-messages/README.zh.md

@@ -27,7 +27,7 @@ kind: "package-reference"
 
 在 Cordis 组合中将本插件与 `dsh-llm` 一起挂载,并选择 `provider: deepseek-messages`。模型 ID 原样发送,目录仅供发现使用。
 
-[Web profile](../../bundle/web-app/README.zh.md) 默认使用本适配器作为 DeepSeek 提供方,并禁用 Chat Completions。**DeepSeek** 卡片和首次启动引导使用 `DEEPSEEK_API_KEY`;端点和模型修改通过 `llm-deepseek-messages` 即时生效。提供方 ID 保持为 `deepseek-messages`,与显示名称相互独立。
+[Web profile](../../bundle/web-app/README.zh.md) 包含本适配器但默认禁用,使用 Chat Completions。启用后,**DeepSeek** 卡片使用 `DEEPSEEK_API_KEY`;端点和模型修改通过 `llm-deepseek-messages` 即时生效。提供方 ID 保持为 `deepseek-messages`,与显示名称相互独立。
 
 ### 最小配置
 
@@ -48,7 +48,7 @@ kind: "package-reference"
 |---|---|---|
 | `apiKeyEnv` | `DEEPSEEK_API_KEY` | 凭据引用;通过 credentials 服务解析,服务未挂载时读取启动环境 |
 | `thinking` / `reasoningEffort` | enabled / high | `off`、`low`、`high`、`max`;禁用思考的部署仅允许 off |
-| `models` | V4 Flash、Pro、Flash Vision Exp | 发现目录及模型容量、图片配置覆盖 |
+| `models` | V41 Flash、V4 Flash、Pro、Flash Vision Exp | 发现目录及模型容量、图片配置覆盖 |
 | `maxTokens` / `defaultContextWindow` | 256000 / 1000000 | 默认输出上限和上下文容量 |
 | `maxInlineRequestImageBytes` / `maxImagesPerRequest` | 20 MiB / 600 | 保留的 base64 字节数和图片出现次数 |
 | `streamIdleTimeoutMs` | 300000 | 等待服务端响应的最长空闲时间 |
@@ -61,11 +61,11 @@ kind: "package-reference"
 
 未声明历史追加能力时,最后一条 system 消息提供完整的顶层 `system` 提示词,也适用于携带多版快照的直接调用。最后一条快照为空时,清空历史提示词。代理循环在继续或恢复这些路由时,也会将更新归并到系统头节点;从支持追加的路由切换过来时也如此。单次调用的 `GenerateOptions.system` 仍作为独立前缀。非文本 system 内容会被拒绝。
 
-仅当端点与模型将最后一次 system 更新视为完整的有效提示词时,设置 `models[].systemPromptUpdate: in-history`。默认模型和未列入目录的模型均不启用。支持该能力的路由将初始提示词保留在顶层字段中,把后续快照序列化为原生 `role: system` 消息。[Anthropic 位置规则](https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages)要求更新位于用户轮次(包括全部工具结果)之后、下一条助手消息之前。适配器将循环较早接纳的 system 映射到该位置,不改写持久化消息或对话轮次的相对顺序。缺少前置用户轮次的更新、空的历史内更新会被拒绝;循环负责的清空操作会在序列化前归并历史。
+仅当端点与模型将最后一次 system 更新视为完整的有效提示词时,设置 `models[].systemPromptUpdate: in-history`。默认的 `deepseek-flash` 条目(DeepSeek-V41-Flash)启用该能力,并接受文本和图片;V4 与未列入目录的模型不启用历史内 system 更新。自定义 `models` 列表会替换默认目录。支持该能力的路由将初始提示词保留在顶层字段中,把后续快照序列化为原生 `role: system` 消息。[Anthropic 位置规则](https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages)要求更新位于用户轮次(包括全部工具结果)之后、下一条助手消息之前。适配器将循环较早接纳的 system 映射到该位置,不改写持久化消息或对话轮次的相对顺序。缺少前置用户轮次的更新、空的历史内更新会被拒绝;循环负责的清空操作会在序列化前归并历史。
 
 ```yaml
 models:
-  - id: deepseek-v4-flash
+  - id: my-model
     systemPromptUpdate: in-history
 ```
 

+ 1 - 1
packages/llm/llm-deepseek-messages/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@deepseek-ai/dsh-llm-deepseek-messages",
   "description": "DeepSeek Anthropic Messages adapter with durable thinking replay",
-  "version": "0.1.5-alpha.1",
+  "version": "0.1.5-rc.1",
   "publishConfig": {
     "access": "public"
   },

+ 7 - 1
packages/llm/llm-deepseek-messages/src/config.ts

@@ -41,7 +41,7 @@ export interface Config {
   maxTokens?: number
   /** Context capacity for models without an explicit entry; defaults to 1000000. */
   defaultContextWindow?: number
-  /** Advisory catalog; omission advertises V4 Flash, Pro, and Flash Vision Exp. */
+  /** Advisory catalog; omission advertises V41 Flash, V4 Flash, Pro, and Flash Vision Exp. */
   models?: CatalogModel[]
   /** Maximum idle time while waiting on the provider; defaults to 300000 ms. */
   streamIdleTimeoutMs?: number
@@ -70,6 +70,12 @@ const modelSchema: z<CatalogModel> = z.object({
 })
 
 const catalog: CatalogModel[] = [
+  {
+    id: 'deepseek-flash', name: 'DeepSeek-V41-Flash',
+    inputModalities: ['text', 'image'],
+    imagePixelBudget: 640_000, imageMaxBytes: 1024 * 1024,
+    systemPromptUpdate: 'in-history',
+  },
   { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
   { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
   { id: 'deepseek-v4-flash-vision-exp', name: 'DeepSeek-V4-Flash-Vision-Exp', inputModalities: ['text', 'image'] },

+ 13 - 4
packages/llm/llm-deepseek-messages/tests/adapter.spec.ts

@@ -77,7 +77,12 @@ describe('direct Messages HTTP', () => {
       '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' })
-    expect(await llm.listModels('deepseek-messages')).toHaveLength(3)
+    expect((await llm.listModels('deepseek-messages')).map(model => model.id)).toEqual([
+      'deepseek-flash', 'deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-v4-flash-vision-exp',
+    ])
+    expect(await llm.resolveModel('deepseek-messages', 'deepseek-flash')).toMatchObject({
+      name: 'DeepSeek-V41-Flash', inputModalities: ['text', 'image'], systemPromptUpdate: 'in-history',
+    })
     expect(await llm.resolveModel('deepseek-messages', MODEL)).toMatchObject({ id: MODEL })
     expect(llm.imageRequestPricing('deepseek-messages', MODEL)).toBeDefined()
   })
@@ -187,14 +192,18 @@ describe('Cordis provider composition', () => {
     return { ctx, http }
   }
 
-  it.each([false, true])('updates, clears and restores prompts across continued and resumed sessions, in-history=%s', async (inHistory) => {
+  it.each([
+    { model: MODEL, inHistory: false },
+    { model: MODEL, inHistory: true },
+    { model: 'deepseek-flash', inHistory: true },
+  ])('updates, clears and restores prompts across continued and resumed sessions, model=$model in-history=$inHistory', async ({ model, inHistory }) => {
     const { ctx, http } = await boot()
-    if (inHistory) await ctx.settings.update(Messages.name, { models: [{ id: MODEL, systemPromptUpdate: 'in-history' }] })
+    if (inHistory && model === MODEL) await ctx.settings.update(Messages.name, { models: [{ id: model, systemPromptUpdate: 'in-history' }] })
     let prompt = 'first prompt'
     ctx.on('system-prompt/assemble', async (_assembly, _context, next) => ({
       ...await next(), sections: [{ name: 'test', text: prompt, order: 0 }],
     }))
-    const agentOptions = { provider: 'deepseek-messages', model: MODEL }
+    const agentOptions = { provider: 'deepseek-messages', model }
     const agent = await ctx.agentLoop.create(SessionId('prompt-update'), agentOptions)
     await send(agent, 'first')
     prompt = 'second prompt'

+ 1 - 1
packages/llm/llm-deepseek-messages/tests/serialize.spec.ts

@@ -253,7 +253,7 @@ describe('inline images', () => {
   // Only the read operation is consumed by image preparation; the transport is mocked, not durable content.
   const attachments = { readImageRequest: async () => version } as unknown as AttachmentStore
   const signal = new AbortController().signal
-  it('keeps image bytes inside tool results and deduplicates normalization', async () => {
+  it.each(['deepseek-flash', model])('keeps image bytes inside tool results and deduplicates normalization for %s', async (model) => {
     const history = [assistant([call()]), result('a', [image, image])]
     const prepared = await prepareImages(history, connection, model, attachments, access, signal)
     expect(prepared.versions.size).toBe(1)

+ 2 - 3
snapshots/web/deepseek-messages-chat/ui.expected.md

@@ -3,10 +3,9 @@
     - button "只回复 MESSAGES_WEB_READY,不调用" [disabled]
   - img
   - text: 标准模式
-  - button "Session 日志":
-    - text: Session 日志
+  - button "更多操作":
     - img
-  - button "展开侧栏":
+  - button "打开右侧边栏":
     - img
   - tablist:
     - tab "对话" [selected]