Przeglądaj źródła

feat(web): configure DeepSeek Messages beside Chat Completions

Yichen Jiang 1 tydzień temu
rodzic
commit
d28286938d
27 zmienionych plików z 303 dodań i 21 usunięć
  1. 2 2
      .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml
  2. 2 0
      .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md
  3. 2 0
      .agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md
  4. 83 0
      apps/web/tests/deepseek-messages-settings.e2e.ts
  5. 81 0
      apps/web/tests/expected/deepseek-messages-settings/cards.expected.md
  6. 13 0
      apps/web/tests/expected/deepseek-messages-settings/picker.expected.md
  7. 10 5
      apps/web/tests/scaffold.ts
  8. 1 0
      apps/web/tsconfig.json
  9. 2 2
      docs/module-graph.i18n.yaml
  10. 10 0
      docs/module-graph.md
  11. 10 0
      docs/module-graph.zh.md
  12. 2 2
      packages/bundle/web-app/README.i18n.yaml
  13. 2 0
      packages/bundle/web-app/README.md
  14. 2 0
      packages/bundle/web-app/README.zh.md
  15. 6 0
      packages/bundle/web-app/cordis.patch.yml
  16. 1 0
      packages/bundle/web-app/package.json
  17. 2 2
      packages/client/ui-settings-models/README.i18n.yaml
  18. 3 1
      packages/client/ui-settings-models/README.md
  19. 3 1
      packages/client/ui-settings-models/README.zh.md
  20. 10 4
      packages/client/ui-settings-models/src/client/ProviderEditor.tsx
  21. 2 0
      packages/client/ui-settings-models/src/client/locales.ts
  22. 44 0
      packages/client/ui-settings-models/tests/components.client.spec.tsx
  23. 2 2
      packages/llm/llm-deepseek-messages/README.i18n.yaml
  24. 2 0
      packages/llm/llm-deepseek-messages/README.md
  25. 2 0
      packages/llm/llm-deepseek-messages/README.zh.md
  26. 3 0
      pnpm-lock.yaml
  27. 1 0
      tsconfig.host.json

+ 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: 9d1c7cde89e1c6c21176a14365be8b27ef485dac
-2026-09-07-deepseek-messages-adapter.zh.md: d007195c61bf33fd10524f9ef39ab2582bfdbe3a
+2026-09-07-deepseek-messages-adapter.md: 75a68ebcda03ea2c5850ad38c5124f81c6e9577f
+2026-09-07-deepseek-messages-adapter.zh.md: c0a091e8d0b18cf90fa50d49dec48fc0500f7400

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

@@ -18,6 +18,8 @@ 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.
+
 ## Alternatives considered
 
 **Add a protocol switch to `llm-deepseek`.** This couples two serializers, endpoint roots, and replay formats under one route. Separate registration allows deployments to select the protocol explicitly and mount both adapters.

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

@@ -18,6 +18,8 @@ Status: implemented
 
 图片请求使用附件服务生成的、有预算限制的内联 base64 版本。共享附件卸载机制和 DeepSeek token 计量使请求与计量策略保持一致。此适配器不负责 Files 上传,因为其端点和缓存所有权与 chat-completions 不同;增加上传支持需要定义 Messages 专属的生命周期和错误策略。
 
+Web profile 同时挂载两个直接适配器,保留已选择的默认模型。Messages 行使用 `DEEPSEEK_MESSAGES_API_KEY`,使两张配置卡片可以存储不同凭据。共享 DeepSeek 编辑器按设置命名空间写入,并展示相应协议根地址;输入框中的模型标识仍包含提供方。
+
 ## 考虑过的替代方案
 
 **在 `llm-deepseek` 中增加协议开关。** 这会把两套序列化、端点根路径和回放格式绑定到同一路由。独立注册使部署能够明确选择协议并同时挂载两个适配器。

+ 83 - 0
apps/web/tests/deepseek-messages-settings.e2e.ts

@@ -0,0 +1,83 @@
+/** Web composition: both DeepSeek protocols keep independent settings, credentials, and model identities. */
+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 {
+  captureStableAria, compareOrRefreshGolden, launchWebScaffold,
+  watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspaceZh, saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
+
+const EXPECTED = fileURLToPath(new URL('./expected/deepseek-messages-settings/', import.meta.url))
+
+describe.skipIf(webSnapshotMode() === 'record')('web e2e: DeepSeek protocol coexistence', () => {
+  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 })
+    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 () => {
+    try {
+      await browser?.close()
+    } finally {
+      try { await scaffold?.close() } finally { vi.unstubAllEnvs() }
+    }
+  })
+
+  it('saves each protocol independently and exposes both model catalogs', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-deepseek-messages-settings'))
+    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 messages.getByText('自定义设置', { exact: true }).click()
+    expect(await messages.getByLabel('API 地址', { exact: true }).getAttribute('placeholder'))
+      .toBe('https://api.deepseek.com/anthropic')
+    await compareOrRefreshGolden(join(EXPECTED, 'cards.expected.md'),
+      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')
+    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()
+
+    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).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(await page.locator('body').innerText()).not.toContain('sk-e2e-')
+    await page.keyboard.press('Escape')
+    await connectFreshWorkspaceZh(page, scaffold.workspaceCwd, 'messages-settings-e2e')
+    await page.getByRole('button', { name: /^选择模型/ }).click()
+    await page.getByRole('menuitem', { name: /模型/ }).click()
+    await page.getByRole('menuitemradio', { name: 'Messages Flash', exact: true }).waitFor()
+    await compareOrRefreshGolden(join(EXPECTED, 'picker.expected.md'),
+      await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), webSnapshotMode())
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+})

+ 81 - 0
apps/web/tests/expected/deepseek-messages-settings/cards.expected.md

@@ -0,0 +1,81 @@
+- dialog "设置":
+  - navigation:
+    - text: 设置
+    - button "通用设置":
+      - img
+      - text: 通用设置
+    - button "模型":
+      - img
+      - text: 模型
+    - button "插件":
+      - img
+      - text: 插件
+    - button "Agent 预设":
+      - img
+      - text: Agent 预设
+  - button "打开配置文件"
+  - button "关闭":
+    - img
+    - text: 关闭
+  - heading "模型" [level=2]
+  - paragraph: 填入各提供方的 API 密钥即可使用其模型。
+  - list:
+    - listitem:
+      - text: DeepSeek deepseek-official API 密钥
+      - textbox "API 密钥":
+        - /placeholder: 输入 API 密钥
+      - group: 自定义设置
+      - button "取消"
+      - button "保存"
+    - listitem:
+      - text: DeepSeek Messages deepseek-messages API 密钥
+      - textbox "API 密钥":
+        - /placeholder: 输入 API 密钥
+      - group:
+        - text: 自定义设置 API 地址
+        - textbox "API 地址":
+          - /placeholder: https://api.deepseek.com/anthropic
+        - paragraph: 填写 Anthropic 协议根地址,请求会自动追加 /v1/messages。
+        - region "模型目录":
+          - text: 模型目录 正在使用适配器默认模型
+          - textbox "模型 ID 1":
+            - /placeholder: 模型 ID
+            - text: deepseek-v4-flash
+          - textbox "显示名称 1":
+            - /placeholder: 显示名称
+            - text: DeepSeek-V4-Flash
+          - button "容量 1":
+            - img
+          - button "删除模型 1":
+            - img
+          - textbox "模型 ID 2":
+            - /placeholder: 模型 ID
+            - text: deepseek-v4-pro
+          - textbox "显示名称 2":
+            - /placeholder: 显示名称
+            - text: DeepSeek-V4-Pro
+          - button "容量 2":
+            - img
+          - button "删除模型 2":
+            - img
+          - textbox "模型 ID 3":
+            - /placeholder: 模型 ID
+            - text: deepseek-v4-flash-vision-exp
+          - textbox "显示名称 3":
+            - /placeholder: 显示名称
+            - text: DeepSeek-V4-Flash-Vision-Exp
+          - button "容量 3":
+            - img
+          - button "删除模型 3":
+            - img
+          - button "添加模型":
+            - img
+            - text: 添加模型
+      - button "取消"
+      - button "保存"
+  - button "添加提供方":
+    - img
+    - text: 添加提供方
+  - button "添加自定义提供方":
+    - img
+    - text: 添加自定义提供方

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

@@ -0,0 +1,13 @@
+- menu "模型与推理等级":
+  - group "DeepSeek":
+    - text: DeepSeek
+    - menuitemradio "DeepSeek-V4-Flash" [checked]:
+      - text: DeepSeek-V4-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"

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

@@ -5,7 +5,7 @@
 // layer stack the profile boot composes), patched the
 // snapshot way — so a real chromium exercises the real HTTP uplink/WebSocket
 // downlink, api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT:
-// replay (default, keyless: normally disables the llm-deepseek row and
+// replay (default, keyless: normally disables the direct DeepSeek rows and
 // inserts dsh-llm-replay in providers mode), record (real adapter + key,
 // harvests fixtures from live session memory), refresh (keyless replay that
 // rewrites goldens). A first-run option keeps the real adapter mounted while
@@ -18,7 +18,7 @@
 // disabled (recorded fixtures must not embed this repo's AGENTS.md);
 // session-title-llm disabled (its fire-and-forget title call would race the
 // loop for the session's replay cursor); webserver pinned to port 0 with the
-// built dist; ordinary keyless modes disable llm-deepseek and fill the open
+// built dist; ordinary keyless modes disable both direct adapters and fill the open
 // llm seam post-boot with installLlmReplay on the settled root ctx
 // (the plugin-row path discards the ReplayHandle; the direct install keeps
 // assertConsumed for the teardown fixture-consumption check).
@@ -185,7 +185,7 @@ const WEB_PATCH_PATH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml
 const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json')
 
 // Replay publishes the provider catalog the gateway routes to (providers
-// mode, never catch-all: with llm-deepseek disabled no adapter exists, so a
+// mode, never catch-all: with both direct adapters disabled no adapter exists, so a
 // catch-all would leave resolveModelInfo unroutable and compaction-basic's
 // post-step pressure check would warn every step). The published
 // contextWindow keeps that pressure path provably inert for small fixtures.
@@ -301,7 +301,7 @@ export interface LaunchOptions {
    * Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row
    * in replay/refresh modes; ignored in record mode (the real adapter
    * answers). Omit for scenarios issuing no model calls — a stray stream then
-   * fails loud with NO_ADAPTER (llm-deepseek is disabled and no replay row
+   * fails loud with NO_ADAPTER (both direct adapters are disabled and no replay row
    * mounts). With {@link replayProvidersOnly}, the fixture must record no
    * model calls (its header alone mounts the catalog).
    */
@@ -358,6 +358,8 @@ 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. */
+  deepSeekMessages?: boolean
   /** Leave the current welcome notice pending; ordinary scenarios pre-acknowledge it before browser boot. */
   welcomeNoticePending?: boolean
   /**
@@ -632,6 +634,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     ...mode === 'record' || options.deepSeekMissingCredential === true
       ? []
       : [{ id: 'llm-deepseek', disabled: true }],
+    ...options.deepSeekMessages === true
+      ? []
+      : [{ id: 'llm-deepseek-messages', disabled: true }],
   ]
 
   // Sessions inherit the gateway's process.cwd() default; run the boot from
@@ -719,7 +724,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 llm-deepseek; the first-run lane keeps it mounted but has no
+    // disable both direct adapters; the first-run lane keeps llm-deepseek 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) {

+ 1 - 0
apps/web/tsconfig.json

@@ -42,6 +42,7 @@
     "tests/plugin-config.e2e.ts",
     "tests/settings-chrome.e2e.ts",
     "tests/models-settings.e2e.ts",
+    "tests/deepseek-messages-settings.e2e.ts",
     "tests/default-model.e2e.ts",
     "tests/github-ready-review.e2e.ts",
     "tests/streaming-fence-highlight.e2e.ts",

+ 2 - 2
docs/module-graph.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/module-graph.md
-module-graph.md: 0951780b0bf53b35f75ef13c50a3875138d756cc
-module-graph.zh.md: 2a073370bd4de913f3a1293fcbebb9b9e25954f4
+module-graph.md: b42c47d6f8f926bec53f5229368d87c57822e94d
+module-graph.zh.md: 65c9155bd22a8dca3afe799fb1722e213c1e0b5e

+ 10 - 0
docs/module-graph.md

@@ -27,6 +27,7 @@ flowchart TD
     pkg_deepseek_llm_api_extensions["deepseek-llm-api-extensions"]
     pkg_llm["llm"]
     pkg_llm_deepseek["llm-deepseek"]
+    pkg_llm_deepseek_messages["llm-deepseek-messages"]
     pkg_llm_pi_ai["llm-pi-ai"]
     pkg_llm_retry["llm-retry"]
     pkg_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek"]
@@ -506,6 +507,14 @@ flowchart TD
   pkg_llm_deepseek --> pkg_llm
   pkg_llm_deepseek --> pkg_settings
   pkg_llm_deepseek --> pkg_timeout
+  pkg_llm_deepseek_messages --> pkg_anonymous_user_id
+  pkg_llm_deepseek_messages --> pkg_attachment
+  pkg_llm_deepseek_messages --> pkg_credentials
+  pkg_llm_deepseek_messages --> pkg_fs
+  pkg_llm_deepseek_messages --> pkg_launch_environment
+  pkg_llm_deepseek_messages --> pkg_llm
+  pkg_llm_deepseek_messages --> pkg_settings
+  pkg_llm_deepseek_messages --> pkg_timeout
   pkg_llm_pi_ai --> pkg_attachment
   pkg_llm_pi_ai --> pkg_authorization
   pkg_llm_pi_ai --> pkg_credentials
@@ -1304,6 +1313,7 @@ flowchart TD
 | [`shell`](../packages/shell/shell) | `shell` | [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) |
 | [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) |
 | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
+| [`llm-deepseek-messages`](../packages/llm/llm-deepseek-messages) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
 | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
 | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) |
 | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) |

+ 10 - 0
docs/module-graph.zh.md

@@ -29,6 +29,7 @@ flowchart TD
     pkg_deepseek_llm_api_extensions["deepseek-llm-api-extensions"]
     pkg_llm["llm"]
     pkg_llm_deepseek["llm-deepseek"]
+    pkg_llm_deepseek_messages["llm-deepseek-messages"]
     pkg_llm_pi_ai["llm-pi-ai"]
     pkg_llm_retry["llm-retry"]
     pkg_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek"]
@@ -508,6 +509,14 @@ flowchart TD
   pkg_llm_deepseek --> pkg_llm
   pkg_llm_deepseek --> pkg_settings
   pkg_llm_deepseek --> pkg_timeout
+  pkg_llm_deepseek_messages --> pkg_anonymous_user_id
+  pkg_llm_deepseek_messages --> pkg_attachment
+  pkg_llm_deepseek_messages --> pkg_credentials
+  pkg_llm_deepseek_messages --> pkg_fs
+  pkg_llm_deepseek_messages --> pkg_launch_environment
+  pkg_llm_deepseek_messages --> pkg_llm
+  pkg_llm_deepseek_messages --> pkg_settings
+  pkg_llm_deepseek_messages --> pkg_timeout
   pkg_llm_pi_ai --> pkg_attachment
   pkg_llm_pi_ai --> pkg_authorization
   pkg_llm_pi_ai --> pkg_credentials
@@ -1306,6 +1315,7 @@ flowchart TD
 | [`shell`](../packages/shell/shell) | `shell` | [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) |
 | [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) |
 | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
+| [`llm-deepseek-messages`](../packages/llm/llm-deepseek-messages) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
 | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
 | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) |
 | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) |

+ 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: 0f71be178c25c0e6687a6e51ff777a9d6ac76a5a
-README.zh.md: ea7747c0b814dc36d222d0d7445732159f589d7b
+README.md: fb71f5f8ac89d513619d0473349b0942d8d4cba0
+README.zh.md: 4ada766c902b402983ac05df1d2c06b41c361506

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

@@ -36,6 +36,8 @@ 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.
+
 ### Configuration
 
 Most users never set these; the command-line flags feed the four settings below — `--host`, `--port`, and `--trusted-host` come from the invocation, and `--no-open` turns the browser handoff off for that invocation:

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

@@ -36,6 +36,8 @@ 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) 卡片。两种协议可以同时配置,在模型选择器中切换提供方。
+
 ### 配置
 
 大多数用户不需要设置这些;命令行 flag 会提供给下面四个设置——`--host`、`--port` 与 `--trusted-host` 来自本次调用,`--no-open` 仅对本次调用关闭浏览器交接:

+ 6 - 0
packages/bundle/web-app/cordis.patch.yml

@@ -42,6 +42,12 @@
 # `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
+
     # Host-owned opt-in sampled when a new Web session receives its preset
     # delegation tools. The Plugins page edits this settings namespace.
     - id: subagent-model-selection-settings

+ 1 - 0
packages/bundle/web-app/package.json

@@ -101,6 +101,7 @@
     "@deepseek-ai/dsh-file-reference": "workspace:^",
     "@deepseek-ai/dsh-file-reference-local": "workspace:^",
     "@deepseek-ai/dsh-launch-environment": "workspace:^",
+    "@deepseek-ai/dsh-llm-deepseek-messages": "workspace:^",
     "@deepseek-ai/dsh-message-feedback": "workspace:^",
     "@deepseek-ai/dsh-session-reference": "workspace:^",
     "@deepseek-ai/dsh-session-log-export": "workspace:^",

+ 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: 64a295e9ebacdd8cbb2b5a0a10b14c1c1dfb24ee
-README.zh.md: a75b6f2300eb59e2961b5ca85799b28b32d0856a
+README.md: e1fa302ccf0a20703157d116ed11c8e46a2e8f12
+README.zh.md: 0933c5fd597ddd83e0d12aa8ab6ccee777177d31

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

@@ -35,6 +35,8 @@ The primary field on an editor card is a single **API key** input — the page n
 
 The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `settings.yaml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits.
 
+`llm-deepseek` and `llm-deepseek-messages` share the DeepSeek editor fields while retaining separate settings and credential references. The Messages card shows `https://api.deepseek.com/anthropic` as its public endpoint and explains the automatically appended `/v1/messages` path.
+
 ### Adding and deleting providers
 
 The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each selected candidate copies its id, display name, context window, and output-token cap into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
@@ -104,7 +106,7 @@ These limits define the editor's field coverage and the page's reach; they are c
 
 - **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved.
 - **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `<ROUTE>_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them.
-- **Only pi-ai routes can be hand-declared** — the custom-provider card writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create.
+- **Only pi-ai routes can be hand-declared** — the custom-provider card writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` or `llm-deepseek-messages` route is a composition fact, not something this page can create.
 - **Interrogation covers OpenAI-compatible and Anthropic Messages endpoints** — OpenAI protocols accept a standard `data` array or an enriched `models` map, while Anthropic uses its native model-listing route; every other protocol reports that it cannot be asked and its models are entered by hand.
 - **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows.
 

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

@@ -35,6 +35,8 @@ kind: "package-reference"
 
 收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `settings.yaml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供方级的控件只可能被设成某些模型会拒绝的值。每个 DeepSeek 行编辑 `id`、可选显示 `name` 与可选 `contextWindow`/`maxTokens`;该精选集之外的现有字段在编辑后仍会保留。
 
+`llm-deepseek` 与 `llm-deepseek-messages` 共用 DeepSeek 编辑字段,同时保留独立的设置和凭据引用。Messages 卡片以 `https://api.deepseek.com/anthropic` 为公共端点占位符,并说明自动追加的 `/v1/messages` 路径。
+
 ### 新增与删除提供方
 
 「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。每个选中候选会在提供方公布相应信息时,把 id、显示名、上下文窗口与最大输出 token 数复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。
@@ -104,7 +106,7 @@ kind: "package-reference"
 
 - **卡片上只有 API 密钥与精选折叠字段可编辑**:手写编辑器以 schema 通用字段覆盖换取了 mockup 布局。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。
 - **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `<ROUTE>_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据与无法识别的目标会保留,因为该行无法证明自己拥有它们。
-- **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。
+- **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 或 `llm-deepseek-messages` 路由是组合面的事实,不是本页能创建的东西。
 - **询问覆盖 OpenAI 兼容与 Anthropic Messages 端点**:OpenAI 协议接受标准 `data` 数组或富信息 `models` 对象,Anthropic 则使用原生模型列表路由;其余协议会报告自己无法被询问,其模型需手工填写。
 - **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。
 

+ 10 - 4
packages/client/ui-settings-models/src/client/ProviderEditor.tsx

@@ -42,8 +42,11 @@ import styles from './ModelsSection.module.css'
 /** Per-adapter-family curated field sets (unknown namespaces get the hint alone). */
 type EditorLayout = 'deepseek' | 'pi-ai' | 'unknown'
 
-/** The public DeepSeek endpoint shown as the deepseek base-URL placeholder. */
-const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com'
+/** Public protocol roots shown independently of launch-environment overrides. */
+const DEEPSEEK_PUBLIC_BASE_URLS = {
+  'llm-deepseek': 'https://api.deepseek.com',
+  'llm-deepseek-messages': 'https://api.deepseek.com/anthropic',
+}
 
 /** Props of {@link ProviderEditor}. */
 export interface ProviderEditorProps {
@@ -131,7 +134,7 @@ export function pathOps(
 
 /** The editor layout the owning namespace selects. */
 function layoutOf(ns: string): EditorLayout {
-  if (ns === 'llm-deepseek') return 'deepseek'
+  if (ns === 'llm-deepseek' || ns === 'llm-deepseek-messages') return 'deepseek'
   if (ns === 'llm-pi-ai') return 'pi-ai'
   return 'unknown'
 }
@@ -414,7 +417,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
                 type="text"
                 value={stringAt(draft, 'baseURL') ?? ''}
                 placeholder={family === 'deepseek'
-                  ? DEEPSEEK_PUBLIC_BASE_URL
+                  ? DEEPSEEK_PUBLIC_BASE_URLS[namespace.ns as keyof typeof DEEPSEEK_PUBLIC_BASE_URLS]
                   : stringAt(fallback, 'baseURL') ?? t('baseUrlDefault')}
                 aria-label={t('baseUrl')}
                 disabled={disabled}
@@ -423,6 +426,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
                 }}
               />
             </div>
+            {namespace.ns === 'llm-deepseek-messages'
+              ? <p className={styles['intro']}>{t('messagesBaseUrlHint')}</p>
+              : null}
             {/* The protocol sits beside the endpoint it describes, as it does
                 on the create card. */}
             {ownsIdentity

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

@@ -35,6 +35,7 @@ export const en = {
   customized: 'Customized settings',
   baseUrl: 'Base URL',
   baseUrlDefault: 'Provider default',
+  messagesBaseUrlHint: 'Enter the Anthropic protocol root. Requests append /v1/messages automatically.',
   models: 'Models',
   modelsInherited: 'Using the adapter defaults',
   modelsCustomized: 'Customized model catalog',
@@ -141,6 +142,7 @@ export const zh: { [Key in keyof typeof en]: string } = {
   customized: '自定义设置',
   baseUrl: 'API 地址',
   baseUrlDefault: '提供方默认',
+  messagesBaseUrlHint: '填写 Anthropic 协议根地址,请求会自动追加 /v1/messages。',
   models: '模型目录',
   modelsInherited: '正在使用适配器默认模型',
   modelsCustomized: '已自定义模型目录',

+ 44 - 0
packages/client/ui-settings-models/tests/components.client.spec.tsx

@@ -610,6 +610,50 @@ describe('ModelsSection', () => {
     ])
   })
 
+  it('edits Messages credentials, endpoint, and model rows in their own namespace', async () => {
+    const namespace: SettingsNamespaceView = {
+      ...wireNamespaces()[0]!,
+      ns: 'llm-deepseek-messages',
+      value: { apiKeyEnv: 'DEEPSEEK_MESSAGES_API_KEY', models: DEFAULT_DEEPSEEK_MODELS },
+      user: {},
+    }
+    const { face, mutate, set } = scriptedFace({
+      mutate: vi.fn(() => Promise.resolve(remoteOk(namespace))),
+    })
+    const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
+    render(<ProviderEditor
+      provider="deepseek-messages"
+      displayName="DeepSeek Messages"
+      namespace={namespace}
+      schema={settingsSchema}
+      settingsPath={[]}
+      operations={operationsWith(face)}
+      t={t}
+      readOnly={false}
+      onClose={vi.fn()}
+    />)
+    fireEvent.click(screen.getByText(en.customized))
+    expect(screen.getByLabelText<HTMLInputElement>(en.baseUrl).placeholder)
+      .toBe('https://api.deepseek.com/anthropic')
+    expect(screen.getByText(en.messagesBaseUrlHint)).toBeTruthy()
+    fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-messages-test' } })
+    fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://messages.example/anthropic' } })
+    fireEvent.change(screen.getByLabelText(`${en.modelName} 1`), { target: { value: 'Messages Flash' } })
+    fireEvent.click(screen.getByText(en.apply))
+    await waitFor(() => { expect(set).toHaveBeenCalledWith('DEEPSEEK_MESSAGES_API_KEY', 'sk-messages-test') })
+    expect(mutate.mock.calls).toEqual([[
+      'llm-deepseek-messages',
+      [
+        { op: 'set', path: ['baseURL'], value: 'https://messages.example/anthropic' },
+        { op: 'set', path: ['models'], value: [
+          { ...DEFAULT_DEEPSEEK_MODELS[0], name: 'Messages Flash' },
+          DEFAULT_DEEPSEEK_MODELS[1],
+        ] },
+      ],
+      0,
+    ]])
+  })
+
   it('rejects duplicate DeepSeek model ids before writing', async () => {
     const { mutate } = await mountDeepSeekCard()
     fireEvent.click(screen.getByText(en.customized))

+ 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: 8923f550d5a7fd30de17c38680eb18182efcd90a
-README.zh.md: 898940c414ae317e7c8f472c6bc753019b589d59
+README.md: bad9d561a8d08e48a1ce75e9a670cbf640da4a6f
+README.zh.md: 9da666d981209a28cf9f3e01b2ad3a5cfa746ce4

+ 2 - 0
packages/llm/llm-deepseek-messages/README.md

@@ -27,6 +27,8 @@ 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`.
+
 ### Minimal configuration
 
 ```yaml

+ 2 - 0
packages/llm/llm-deepseek-messages/README.zh.md

@@ -27,6 +27,8 @@ 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`。
+
 ### 最小配置
 
 ```yaml

+ 3 - 0
pnpm-lock.yaml

@@ -1738,6 +1738,9 @@ importers:
       '@deepseek-ai/dsh-launch-environment':
         specifier: workspace:^
         version: link:../../util/launch-environment
+      '@deepseek-ai/dsh-llm-deepseek-messages':
+        specifier: workspace:^
+        version: link:../../llm/llm-deepseek-messages
       '@deepseek-ai/dsh-message-feedback':
         specifier: workspace:^
         version: link:../../feedback/message-feedback

+ 1 - 0
tsconfig.host.json

@@ -33,6 +33,7 @@
     "apps/web/tests/plugin-config.e2e.ts",
     "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/onboarding-deepseek-config.e2e.ts",
     "apps/web/tests/onboarding-usable-provider.e2e.ts",
     "apps/web/tests/remote-welcome.e2e.ts",