From 3859913c68e2e2c6e0218e7994f4a1f11dc48214 Mon Sep 17 00:00:00 2001 From: yudshj Date: Tue, 3 Nov 2026 12:43:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E7=BD=91=E9=A1=B5=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E4=B8=8E=E8=8E=B7=E5=8F=96=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E5=88=86=E5=BC=80=E9=80=89=E6=8B=A9=20Search=20=E4=B8=8E=20Fet?= =?UTF-8?q?ch=EF=BC=8CSearch=20=E9=BB=98=E8=AE=A4=E6=94=B9=E4=B8=BA=20Deep?= =?UTF-8?q?Seek=20Search=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-24-web-capability-seam.i18n.yaml | 36 +- .../2026-06-24-web-capability-seam.md | 65 +- .../2026-06-24-web-capability-seam.zh.md | 65 +- ...4-configuration-source-ownership.i18n.yaml | 4 +- ...26-08-04-configuration-source-ownership.md | 1 - ...08-04-configuration-source-ownership.zh.md | 1 - ...profile-owned-live-configuration.i18n.yaml | 4 +- ...-09-19-profile-owned-live-configuration.md | 2 +- ...-19-profile-owned-live-configuration.zh.md | 2 +- ...ch-offered-by-provider-readiness.i18n.yaml | 16 +- ...eb-search-offered-by-provider-readiness.md | 16 +- ...search-offered-by-provider-readiness.zh.md | 16 +- ...026-10-29-web-provider-selection.i18n.yaml | 37 + .../2026-10-29-web-provider-selection.md | 85 ++ .../2026-10-29-web-provider-selection.zh.md | 85 ++ ...-10-15-deepseek-search-fetch-api.i18n.yaml | 30 +- .../2026-10-15-deepseek-search-fetch-api.md | 34 +- ...2026-10-15-deepseek-search-fetch-api.zh.md | 34 +- apps/cli/reference/README.i18n.yaml | 6 +- apps/cli/reference/README.md | 4 +- apps/cli/reference/README.zh.md | 2 +- .../headless/tests/fixtures/cli.patch.yml | 10 +- .../headless/tests/keyless-smoke.e2e.ts | 2 +- .../plugin-config/official.expected.md | 4 +- .../plugin-config/web-search.expected.md | 40 +- .../plugin-manager/live-enabled.expected.md | 4 +- .../plugin-manager/manager.expected.md | 4 +- apps/web/tests/plugin-config.e2e.ts | 127 ++- apps/web/tests/plugin-manager.e2e.ts | 2 +- apps/web/tests/scaffold.ts | 50 +- apps/web/tests/web-search-round.e2e.ts | 13 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 6 +- docs/capability-seams.zh.md | 6 +- docs/config-catalog.md | 83 +- docs/config-catalog.zh.md | 83 +- docs/event-producer-consumer.md | 1 + docs/event-producer-consumer.zh.md | 1 + docs/module-graph.md | 29 +- docs/module-graph.zh.md | 29 +- docs/subsystems/web.i18n.yaml | 8 +- docs/subsystems/web.md | 67 +- docs/subsystems/web.zh.md | 67 +- docs/testing.i18n.yaml | 4 +- docs/testing.md | 2 +- docs/testing.zh.md | 2 +- .../guide.i18n.yaml | 16 + .../web-search-credential-modes/guide.md | 17 + .../web-search-credential-modes/guide.zh.md | 17 + .../guide.i18n.yaml | 16 + .../web-search-default-provider/guide.md | 19 + .../web-search-default-provider/guide.zh.md | 19 + packages/api/remotes/README.i18n.yaml | 8 +- packages/api/remotes/README.md | 4 +- packages/api/remotes/README.zh.md | 4 +- packages/api/remotes/package.json | 3 +- packages/api/remotes/src/client/index.ts | 5 +- packages/api/remotes/src/index.ts | 1 + packages/api/remotes/src/remote-events.ts | 2 + .../remotes/tests/remote-events.host.spec.ts | 10 + packages/api/remotes/tsconfig.client.json | 3 + packages/api/remotes/tsconfig.host.json | 3 + packages/bundle/base/cordis.patch.yml | 33 +- packages/bundle/base/tests/base.spec.ts | 8 +- packages/bundle/headless/README.i18n.yaml | 4 +- packages/bundle/headless/README.md | 2 + packages/bundle/headless/README.zh.md | 2 + packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 2 +- packages/client/README.zh.md | 2 +- .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 2 +- .../client/ui-plugin-manager/README.zh.md | 2 +- .../ui-settings-web-search/README.i18n.yaml | 24 +- .../client/ui-settings-web-search/README.md | 25 +- .../ui-settings-web-search/README.zh.md | 25 +- .../ui-settings-web-search/locale/de.json | 4 +- .../ui-settings-web-search/locale/es.json | 4 +- .../ui-settings-web-search/locale/fr.json | 4 +- .../ui-settings-web-search/locale/ja.json | 4 +- .../ui-settings-web-search/locale/pt.json | 4 +- .../ui-settings-web-search/locale/ru.json | 4 +- .../ui-settings-web-search/locale/zh.json | 4 +- .../ui-settings-web-search/package.json | 3 +- .../src/client/WebSearchCard.module.css | 95 +- .../src/client/WebSearchCard.tsx | 267 ++++-- .../src/client/index.ts | 38 +- .../src/client/locales.ts | 287 +++--- .../src/client/web-search-card-controller.ts | 550 +++++++---- .../tests/apply.client.spec.ts | 43 +- .../tests/card.client.spec.tsx | 437 ++++++--- .../tests/controller.client.spec.ts | 903 +++++++++++++----- .../tests/styles.client.spec.ts | 36 + .../ui-settings-web-search/tsconfig.json | 3 + .../liveshare/src/endpoint-classification.ts | 1 + .../extensions/tool-cordis/src/api-catalog.ts | 32 +- .../llm/llm-deepseek-account/README.i18n.yaml | 4 +- packages/llm/llm-deepseek-account/README.md | 2 +- .../llm/llm-deepseek-account/README.zh.md | 2 +- .../llm/llm-deepseek-account/src/index.ts | 12 +- .../llm/llm-deepseek-api-key/README.i18n.yaml | 4 +- packages/llm/llm-deepseek-api-key/README.md | 2 +- .../llm/llm-deepseek-api-key/README.zh.md | 2 +- .../llm/llm-deepseek-api-key/src/index.ts | 13 +- packages/llm/llm-deepseek/README.i18n.yaml | 8 +- packages/llm/llm-deepseek/README.md | 3 + packages/llm/llm-deepseek/README.zh.md | 3 + packages/llm/llm-deepseek/src/credential.ts | 58 ++ packages/llm/llm-deepseek/src/index.ts | 1 + .../llm/llm-deepseek/tests/credential.spec.ts | 71 ++ packages/llm/llm-deepseek/tsconfig.json | 2 + .../references/packages.md | 4 +- .../src/assembly/remote-default-responses.ts | 4 +- packages/web/README.i18n.yaml | 8 +- packages/web/README.md | 6 +- packages/web/README.zh.md | 6 +- packages/web/tool-web/README.md | 2 +- packages/web/tool-web/README.zh.md | 2 +- packages/web/tool-web/package.json | 5 +- .../web/tool-web/tests/integration.spec.ts | 32 +- .../web/web-deepseek-api/README.i18n.yaml | 24 +- packages/web/web-deepseek-api/README.md | 32 +- packages/web/web-deepseek-api/README.zh.md | 32 +- packages/web/web-deepseek-api/package.json | 15 +- packages/web/web-deepseek-api/src/fetch.ts | 31 +- packages/web/web-deepseek-api/src/index.ts | 328 ++++++- packages/web/web-deepseek-api/src/search.ts | 33 +- .../web/web-deepseek-api/src/transport.ts | 214 ++--- .../tests/composition.spec.ts | 142 +++ .../tests/credential-resolution.spec.ts | 471 +++++++++ .../tests/credentials.spec.ts | 15 +- .../web/web-deepseek-api/tests/egress.spec.ts | 3 +- .../web/web-deepseek-api/tests/fetch.spec.ts | 19 +- .../web/web-deepseek-api/tests/guidance.ts | 14 + .../web-deepseek-api/tests/readiness.spec.ts | 157 --- .../web-deepseek-api/tests/redirect.spec.ts | 5 +- packages/web/web-deepseek-api/tests/routes.ts | 48 + .../web/web-deepseek-api/tests/search.spec.ts | 58 +- .../web-deepseek-api/tests/transport.spec.ts | 119 ++- .../tests/web-deepseek-api.e2e.ts | 5 +- packages/web/web-deepseek-api/tsconfig.json | 13 + packages/web/web-fetch-http/src/provider.ts | 11 + .../web/web-search-deepseek/README.i18n.yaml | 16 +- packages/web/web-search-deepseek/README.md | 10 +- packages/web/web-search-deepseek/README.zh.md | 10 +- packages/web/web-search-deepseek/package.json | 2 + packages/web/web-search-deepseek/src/index.ts | 22 +- .../web/web-search-deepseek/src/provider.ts | 38 +- .../tests/deepseek.spec.ts | 124 ++- .../tests/settings.spec.ts | 11 + .../web/web-search-deepseek/tsconfig.json | 1 + packages/web/web-search-exa/README.i18n.yaml | 61 -- packages/web/web-search-exa/README.md | 142 --- packages/web/web-search-exa/README.zh.md | 142 --- packages/web/web-search-exa/locale/de.json | 6 - packages/web/web-search-exa/locale/en.json | 6 - packages/web/web-search-exa/locale/es.json | 6 - packages/web/web-search-exa/locale/fr.json | 6 - packages/web/web-search-exa/locale/ja.json | 6 - packages/web/web-search-exa/locale/pt.json | 6 - packages/web/web-search-exa/locale/ru.json | 6 - packages/web/web-search-exa/locale/zh.json | 6 - packages/web/web-search-exa/package.json | 45 - packages/web/web-search-exa/src/index.ts | 67 -- packages/web/web-search-exa/src/provider.ts | 165 ---- packages/web/web-search-exa/src/types.ts | 38 - .../web/web-search-exa/tests/egress.spec.ts | 40 - packages/web/web-search-exa/tests/exa.e2e.ts | 23 - packages/web/web-search-exa/tests/exa.spec.ts | 261 ----- packages/web/web-search-exa/tsconfig.json | 30 - .../web-search-perplexity/README.i18n.yaml | 82 -- packages/web/web-search-perplexity/README.md | 165 ---- .../web/web-search-perplexity/README.zh.md | 165 ---- .../web/web-search-perplexity/locale/de.json | 6 - .../web/web-search-perplexity/locale/en.json | 6 - .../web/web-search-perplexity/locale/es.json | 6 - .../web/web-search-perplexity/locale/fr.json | 6 - .../web/web-search-perplexity/locale/ja.json | 6 - .../web/web-search-perplexity/locale/pt.json | 6 - .../web/web-search-perplexity/locale/ru.json | 6 - .../web/web-search-perplexity/locale/zh.json | 6 - .../web/web-search-perplexity/package.json | 45 - .../web/web-search-perplexity/src/index.ts | 62 -- .../web/web-search-perplexity/src/provider.ts | 167 ---- .../web/web-search-perplexity/src/types.ts | 35 - .../tests/egress.spec.ts | 40 - .../tests/perplexity.e2e.ts | 23 - .../tests/perplexity.spec.ts | 243 ----- .../web/web-search-perplexity/tsconfig.json | 30 - packages/web/web/README.i18n.yaml | 30 +- packages/web/web/README.md | 45 +- packages/web/web/README.zh.md | 45 +- packages/web/web/package.json | 23 +- packages/web/web/src/index.ts | 72 +- packages/web/web/src/types.ts | 67 +- packages/web/web/tests/web.spec.ts | 66 ++ packages/web/web/tsconfig.json | 6 + pnpm-lock.yaml | 86 +- python/sdk-runtime/package.json | 2 - scripts/gen-cordis-catalog.ts | 3 + scripts/gen-doc-graphs.ts | 2 +- scripts/gen-tool-catalog.ts | 4 +- scripts/no-unknown-casts.baseline.json | 13 - scripts/product-package-policy.ts | 2 - scripts/snapshot-http-fixtures.spec.ts | 8 +- .../verify-package-readme-model-experience.ts | 1 - .../deepseek-fetch/cordis.snapshot.yml | 6 +- snapshots/session/deepseek-fetch/cordis.yml | 6 +- snapshots/session/deepseek-fetch/snapshot.yml | 2 + .../cordis.snapshot.yml | 8 +- .../web-search-endpoint-guidance/cordis.yml | 8 +- .../session.v4.jsonl | 11 +- .../web-search-endpoint-guidance/snapshot.yml | 2 + .../web-search-error-fixture.mjs | 8 +- .../cordis.snapshot.yml | 40 + .../cordis.yml | 18 + .../session.v4.jsonl | 23 + .../snapshot.yml | 10 + .../web-search-error-fixture.mjs | 64 ++ .../cordis.snapshot.yml | 6 +- .../web-search-unconfigured/cordis.yml | 6 +- tsconfig.base.json | 2 - tsconfig.host.json | 2 - 223 files changed, 5179 insertions(+), 4166 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-29-web-provider-selection.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-10-29-web-provider-selection.md create mode 100644 .agents/notes/implemented/architecture/2026-10-29-web-provider-selection.zh.md create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.md create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.zh.md create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.md create mode 100644 docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.zh.md create mode 100644 packages/client/ui-settings-web-search/tests/styles.client.spec.ts create mode 100644 packages/llm/llm-deepseek/src/credential.ts create mode 100644 packages/llm/llm-deepseek/tests/credential.spec.ts create mode 100644 packages/web/web-deepseek-api/tests/composition.spec.ts create mode 100644 packages/web/web-deepseek-api/tests/credential-resolution.spec.ts create mode 100644 packages/web/web-deepseek-api/tests/guidance.ts delete mode 100644 packages/web/web-deepseek-api/tests/readiness.spec.ts create mode 100644 packages/web/web-deepseek-api/tests/routes.ts delete mode 100644 packages/web/web-search-exa/README.i18n.yaml delete mode 100644 packages/web/web-search-exa/README.md delete mode 100644 packages/web/web-search-exa/README.zh.md delete mode 100644 packages/web/web-search-exa/locale/de.json delete mode 100644 packages/web/web-search-exa/locale/en.json delete mode 100644 packages/web/web-search-exa/locale/es.json delete mode 100644 packages/web/web-search-exa/locale/fr.json delete mode 100644 packages/web/web-search-exa/locale/ja.json delete mode 100644 packages/web/web-search-exa/locale/pt.json delete mode 100644 packages/web/web-search-exa/locale/ru.json delete mode 100644 packages/web/web-search-exa/locale/zh.json delete mode 100644 packages/web/web-search-exa/package.json delete mode 100644 packages/web/web-search-exa/src/index.ts delete mode 100644 packages/web/web-search-exa/src/provider.ts delete mode 100644 packages/web/web-search-exa/src/types.ts delete mode 100644 packages/web/web-search-exa/tests/egress.spec.ts delete mode 100644 packages/web/web-search-exa/tests/exa.e2e.ts delete mode 100644 packages/web/web-search-exa/tests/exa.spec.ts delete mode 100644 packages/web/web-search-exa/tsconfig.json delete mode 100644 packages/web/web-search-perplexity/README.i18n.yaml delete mode 100644 packages/web/web-search-perplexity/README.md delete mode 100644 packages/web/web-search-perplexity/README.zh.md delete mode 100644 packages/web/web-search-perplexity/locale/de.json delete mode 100644 packages/web/web-search-perplexity/locale/en.json delete mode 100644 packages/web/web-search-perplexity/locale/es.json delete mode 100644 packages/web/web-search-perplexity/locale/fr.json delete mode 100644 packages/web/web-search-perplexity/locale/ja.json delete mode 100644 packages/web/web-search-perplexity/locale/pt.json delete mode 100644 packages/web/web-search-perplexity/locale/ru.json delete mode 100644 packages/web/web-search-perplexity/locale/zh.json delete mode 100644 packages/web/web-search-perplexity/package.json delete mode 100644 packages/web/web-search-perplexity/src/index.ts delete mode 100644 packages/web/web-search-perplexity/src/provider.ts delete mode 100644 packages/web/web-search-perplexity/src/types.ts delete mode 100644 packages/web/web-search-perplexity/tests/egress.spec.ts delete mode 100644 packages/web/web-search-perplexity/tests/perplexity.e2e.ts delete mode 100644 packages/web/web-search-perplexity/tests/perplexity.spec.ts delete mode 100644 packages/web/web-search-perplexity/tsconfig.json create mode 100644 snapshots/session/web-search-legacy-endpoint-guidance/cordis.snapshot.yml create mode 100644 snapshots/session/web-search-legacy-endpoint-guidance/cordis.yml create mode 100644 snapshots/session/web-search-legacy-endpoint-guidance/session.v4.jsonl create mode 100644 snapshots/session/web-search-legacy-endpoint-guidance/snapshot.yml create mode 100644 snapshots/session/web-search-legacy-endpoint-guidance/web-search-error-fixture.mjs diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml index d4ad622140..0dc3371001 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml @@ -6,23 +6,23 @@ en: ca120635fc40431c zh: 6be7c5ae3c86cdcc /agent-note-web-capability-seam-stable-tools-over-multiple-providers/problem: - en: c8b932b02a4e1c80 - zh: 96c466be1a521f3f + en: df1e82a74c42fa1d + zh: 8d5452360a0dec8a /agent-note-web-capability-seam-stable-tools-over-multiple-providers/decision: - en: 2e08d801099cbe18 - zh: 8c5ab162ada9cc41 + en: 7a4921d82cd11749 + zh: 6ab506109afc1351 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/package-topology: - en: 5ea3ddfae82df181 - zh: 2d05d95e51081696 + en: 3394139c17d28ba8 + zh: 66857733ba51d98c /agent-note-web-capability-seam-stable-tools-over-multiple-providers/ctx-web-contract: en: b432f80a424c3e49 zh: faa26925fdd98387 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/provider-availability-and-selection: - en: 4e4ec068c2a22383 - zh: b7030d0f00bfdb74 + en: afc540d986e00d68 + zh: 84795770d00228c3 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/search-request-and-result-schema: - en: ec3703b7e7c6b23d - zh: 325755e6e90092da + en: ccf6404ae7f3172e + zh: 3253035ea49e2548 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/fetch-request-and-result-schema: en: 6dba3b9a51c28b6d zh: 58a93f6ba2d269ea @@ -33,8 +33,8 @@ en: 3440f2b34b93f4a1 zh: 57a8f784c7e26eff /agent-note-web-capability-seam-stable-tools-over-multiple-providers/testing: - en: 7f73699ac4459432 - zh: c16be49ac50c34c8 + en: 03da2f3422183384 + zh: 10fede51a415ce11 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered: en: 6119e2aff3b6be5a zh: 76136ef5249c23eb @@ -42,8 +42,8 @@ en: e0d84a9b1b9c2123 zh: 9e116a7b96751563 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/put-provider-dispatch-directly-in-dsh-tool-web: - en: dc01d014d03b49d2 - zh: b956b02f41c62682 + en: 68ef45e8e5a36de9 + zh: 0013786ed77e03a8 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/split-search-and-fetch-into-two-seams-dsh-search-dsh-fetch: en: 3fb72ba3e554f20a zh: aff5f0add5773388 @@ -63,11 +63,11 @@ en: 3ea94ffb142d144a zh: 89645f68412f6769 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/consequences: - en: 9774448fd6b21c41 - zh: 8411c590ad70d5c4 + en: 8e7523b2858efaf3 + zh: aff8fc74eb439ed6 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/deferred-work: - en: 48b7b8e7310cd56b - zh: d406cd6a8a649e58 + en: ddb599fd2ac1647c + zh: c0b8f446cf483b3c /agent-note-web-capability-seam-stable-tools-over-multiple-providers/open-questions: en: 63848c6ef9a6ab44 zh: 041b340802091e60 diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md index 74e88534ee..b1fe6636ab 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md @@ -6,7 +6,7 @@ English | [中文](2026-06-24-web-capability-seam.zh.md) ## Problem -The harness needs model-facing web tools without binding the model contract to one vendor's API shape. Search is the immediate pressure point: supporting both Exa search and Perplexity search from the start — two deliberately different provider shapes (Exa returns a flat `results[]` of `{title, url, highlights, publishedDate}`; Perplexity returns a generated answer plus citations) — is what proves the normalized web contract does not just mirror one vendor. Fetch is a separate operation: an anonymous public HTTP(S) fetch backend has transport, security, redirect, decoding, and size-limit concerns that are not the same as provider-backed search. +The harness needs model-facing web tools without binding the model contract to one vendor's API shape. Search is the immediate pressure point: search APIs return deliberately different formats (Exa returns a flat `results[]` of `{title, url, highlights, publishedDate}`, Perplexity returns a generated answer plus citations, and the DeepSeek Search API returns a flat `results[]` with snippets and summaries), and the normalized web contract must accept each of them without mirroring one vendor. The seam was designed against the Exa and Perplexity formats; neither provider ships, and a plugin can register one. Fetch is a separate operation: an anonymous public HTTP(S) fetch backend has transport, security, redirect, decoding, and size-limit concerns that are not the same as provider-backed search. The model-facing API must stay stable while backends change. A search provider swap should not change how the model asks for a query, and a fetch implementation swap should not change how the model asks for a URL. Conversely, a provider package should not expose its own model-facing tool schema just because it has extra provider-specific knobs. @@ -19,7 +19,7 @@ There is also a provider-selection question. Existing `tool-bash` and `tool-fs` Web access is a first-class capability seam following [the capability-seam Agent Note](2026-06-13-capability-seams.md): 1. `@deepseek-ai/dsh-web` (`packages/web/web`) owns `ctx.web`, provider registration, provider selection, shared request/result vocabulary, and web-specific errors. -2. Provider packages implement concrete backends and register capabilities with `ctx.web`, for example `@deepseek-ai/dsh-web-search-exa`, `@deepseek-ai/dsh-web-search-perplexity`, `@deepseek-ai/dsh-web-search-deepseek`, and `@deepseek-ai/dsh-web-fetch-http`. +2. Provider packages implement concrete backends and register capabilities with `ctx.web`, for example `@deepseek-ai/dsh-web-search-deepseek`, `@deepseek-ai/dsh-web-deepseek-api`, and `@deepseek-ai/dsh-web-fetch-http`. 3. `@deepseek-ai/dsh-tool-web` (`packages/web/tool-web`) owns the model-facing `web_search` and `web_fetch` tool schemas, prompt sections, argument validation, result formatting, and tool-owned presentation over `ctx.web`. Providers do not register tools. Providers register capabilities. `dsh-tool-web` is the only owner of model-facing names, descriptions, prompt guidance, JSON schemas, and presentation. @@ -32,9 +32,9 @@ Search and fetch are separate tools but one web-access seam. `ctx.web` owns prov - A tool is never unregistered merely because its selected provider is missing, misconfigured, missing credentials, ambiguous, or temporarily unavailable. - The provider is resolved at execution time, and a structured `WebError` is returned when the selected capability cannot run. -This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. `dsh-tool-web` narrows this per model request: it [withholds `web_search` while the selected provider reports that the request's search cannot authenticate](2026-09-27-web-search-offered-by-provider-readiness.md). If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`. +This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. `dsh-tool-web` narrows this per model request: it [withholds `web_search` or `web_fetch` while the selected provider reports that the request's operation cannot authenticate](2026-09-27-web-search-offered-by-provider-readiness.md). If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`. -The seam deliberately exposes no observation surface — no registry-change event and no aggregated capability-status query. Unavailability is a fact a caller observes by executing: `search()`/`fetch()` resolve the provider at call time and throw the structured `WebError` that names what failed. [The observation-surface Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) records that judgment: derived-on-call selection and enablement-based registration leave no consumer that needs a change signal or an availability probe distinct from executing and routing the error, and a future provider-status panel reintroduces the smallest signal or query it actually consumes. +The seam deliberately exposes no availability observation — no aggregated capability-status query and no per-provider availability report. Unavailability is a fact a caller observes by executing: `search()`/`fetch()` resolve the provider at call time and throw the structured `WebError` that names what failed. [The observation-surface Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) records that judgment: derived-on-call selection and enablement-based registration leave no consumer that needs a change signal or an availability probe distinct from executing and routing the error, and a future provider-status panel reintroduces the smallest signal or query it actually consumes. Provider pickers consume registration, not availability: `listProviders()` and the Remote method `web/listProviders` list every registered provider with its optional `title` and `description` (including unavailable providers), and the payload-free `web/providers-changed` event fires after each registry mutation. The [web provider selection decision](2026-10-29-web-provider-selection.md) records why registration is observable while availability is not. ## Package topology @@ -43,11 +43,9 @@ The three-package Service Definition / Service Provider / Consumer split follows The dependency direction mirrors bash and filesystem: ```text -@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-exa +@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-deepseek consumer interface implementation - <--depends on-- @deepseek-ai/dsh-web-search-perplexity - implementation - <--depends on-- @deepseek-ai/dsh-web-search-deepseek + <--depends on-- @deepseek-ai/dsh-web-deepseek-api implementation <--depends on-- @deepseek-ai/dsh-web-fetch-http implementation @@ -57,9 +55,7 @@ At runtime, provider packages register capabilities with `ctx.web`; `tool-web` r ```mermaid flowchart LR - exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"] - perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web - deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web + deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"] deepseekApi["@deepseek-ai/dsh-web-deepseek-api"] -->|registerSearchProvider / registerFetchProvider| web fetchLocal["@deepseek-ai/dsh-web-fetch-http"] -->|registerFetchProvider| web toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web @@ -67,9 +63,9 @@ flowchart LR toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"] ``` -`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, the provider availability contract, and error codes. It does not import tool, agent, session, LLM, or provider packages. +`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support, including the Typert protocol for its Remote method. It declares `ctx.web`, provider interfaces, request/result types, the provider availability contract, and error codes. It does not import tool, agent, session, LLM, or provider packages. -Provider packages depend only on `dsh-web` and Cordis. They own credentials, endpoints, wire mapping, parsing, and `WebError` translation, using platform `fetch`. Each provider injects the shared service and registers a backend; only `dsh-web` owns the `ctx.web` key. Provider-private protocol shapes do not create dependencies on `ctx.llm` or a Cordis HTTP service. +Provider packages depend on `dsh-web`, Cordis, and their credential/configuration helpers. They own credentials, endpoints, wire mapping, parsing, and `WebError` translation, using platform `fetch`. Each provider injects the shared service and registers a backend; only `dsh-web` owns the `ctx.web` key. Provider-private protocol shapes do not create dependencies on `ctx.llm` or a Cordis HTTP service. `@deepseek-ai/dsh-tool-web` depends on `@deepseek-ai/dsh-web`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-system-prompt`, and Cordis. It never imports concrete provider packages. @@ -78,16 +74,22 @@ Provider packages depend only on `dsh-web` and Cordis. They own credentials, end `ctx.web` is a provider registry plus a provider-selecting execution API. The registry half stays close to `LlmRuntime`: a `Map` per capability kind, `registerSearchProvider` / `registerFetchProvider` methods that return disposers, duplicate ids that throw `WebError`, and execution-time resolution that throws when the selected provider is absent or unusable. The authoritative signatures live in `packages/web/web/src/types.ts`; the seam's shape: ```ts -import type { WebFetchRequest, WebFetchResult, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web' +import type { + WebFetchRequest, WebFetchResult, WebProviderInfo, WebProviderList, WebSearchRequest, WebSearchResult, +} from '@deepseek-ai/dsh-web' interface WebSearchProvider { readonly id: string + readonly title?: string + readonly description?: WebProviderInfo['description'] available(): boolean search(request: WebSearchRequest, signal?: AbortSignal): Promise } interface WebFetchProvider { readonly id: string + readonly title?: string + readonly description?: WebProviderInfo['description'] available(): boolean fetch(request: WebFetchRequest, signal?: AbortSignal): Promise } @@ -95,6 +97,7 @@ interface WebFetchProvider { interface WebRuntime { registerSearchProvider(provider: WebSearchProvider): () => void registerFetchProvider(provider: WebFetchProvider): () => void + listProviders(): WebProviderList search(request: WebSearchRequest, signal?: AbortSignal): Promise fetch(request: WebFetchRequest, signal?: AbortSignal): Promise @@ -131,18 +134,15 @@ The "single provider auto-selects" rule is for tests, demos, and simple deployme - id: web name: '@deepseek-ai/dsh-web' config: - searchProvider: exa + searchProvider: deepseek-search-api fetchProvider: http -- id: web-search-exa - name: '@deepseek-ai/dsh-web-search-exa' - -- id: web-search-perplexity - name: '@deepseek-ai/dsh-web-search-perplexity' - - id: web-search-deepseek name: '@deepseek-ai/dsh-web-search-deepseek' +- id: web-deepseek-api + name: '@deepseek-ai/dsh-web-deepseek-api' + - id: web-fetch-http name: '@deepseek-ai/dsh-web-fetch-http' @@ -150,9 +150,9 @@ The "single provider auto-selects" rule is for tests, demos, and simple deployme name: '@deepseek-ai/dsh-tool-web' ``` -Operational overrides feed the same explicit selection path: `DSH_WEB_SEARCH_PROVIDER=perplexity` is equivalent to config `searchProvider: perplexity`, not a hidden priority chain inside `dsh-tool-web`. +Operational overrides feed the same explicit selection path: `DSH_WEB_SEARCH_PROVIDER=deepseek-search-api` is equivalent to config `searchProvider: deepseek-search-api`, not a hidden priority chain inside `dsh-tool-web`. -Each search or fetch reads the configured id when it runs, so a saved settings change selects another provider for the next operation without remounting `dsh-web`. The Web search settings switch relies on this to move both capabilities to the [DeepSeek Search and Fetch APIs](../feature/2026-10-15-deepseek-search-fetch-api.md) at once. +Each search or fetch reads the configured id when it runs, so a saved settings change selects another provider for the next operation without remounting `dsh-web`. The Search and Fetch menus on the Web search and fetch settings page rely on this to switch each capability's provider independently, including the [DeepSeek Search and Fetch APIs](../feature/2026-10-15-deepseek-search-fetch-api.md); the [web provider selection decision](2026-10-29-web-provider-selection.md) owns the shipped defaults. `ctx.web.search()` and `ctx.web.fetch()` resolve the provider at execution time using the selection rules above. If the selected capability is unavailable, they throw `WebError` with a structured code such as `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, or `WEB_PROVIDER_AMBIGUOUS`. If no provider is explicitly configured and no usable provider exists, the execution error is the generic `WEB_PROVIDER_UNAVAILABLE` case; there is deliberately no diagnostic summary of every unavailable provider. @@ -168,8 +168,8 @@ The `web_search` model-facing tool is small. The only model-facing argument is: - `dsh-tool-web` owns the value and puts it on `WebSearchRequest.maxResults`. - `ctx.web` passes the request through to the selected provider unchanged. -- A provider applies `maxResults` at the request layer when its API supports it (Exa's `numResults`), as a cost/latency optimization. -- `ctx.web` enforces the bound on the result: if a provider returns more than `maxResults` sources — because its API has no result-count control (Perplexity) or ignored the hint — the seam truncates `sources[]` to `maxResults` and sets `WebSearchResult.truncated` to `true` before returning. This makes the bound a single cross-provider guarantee the model-facing layer can rely on, rather than something each provider must remember to honor. +- A provider applies `maxResults` at the request layer when its API supports it (the DeepSeek Search API's `count`), as a cost/latency optimization. +- `ctx.web` enforces the bound on the result: if a provider returns more than `maxResults` sources — because its API has no result-count control or ignored the hint — the seam truncates `sources[]` to `maxResults` and sets `WebSearchResult.truncated` to `true` before returning. This makes the bound a single cross-provider guarantee the model-facing layer can rely on, rather than something each provider must remember to honor. The seam request carries no provider-specific controls — no Perplexity model selection, search recency, domain filters, Exa `livecrawl`, Exa `type`, regional hints, generated-answer budgets, or search depth. Such a field is added only when it has provider-neutral semantics that both the tool schema and selected providers can honor honestly. @@ -190,13 +190,14 @@ interface WebSearchSource { readonly url: string readonly title?: string readonly snippet?: string + readonly summary?: string readonly publishedAt?: string } ``` -`content` is optional provider-generated answer text, search context, or summary. `sources[]` is the portable citation shape. A source always has a URL; title, snippet, and `publishedAt` are optional because not every provider returns them. `title` is not required: Perplexity-style citations may provide only URLs, and forcing adapters to invent titles would make the seam lie. `dsh-tool-web` renders a `title ?? hostname(url)`-style fallback label for display. `publishedAt` is an optional publication/crawl timestamp as an ISO-8601 string — Exa returns it as `publishedDate` on each result and Perplexity returns a `date` on search results, so it is real provider data, not derived; the seam carries it as a string and leaves date parsing to the consumer. +`content` is optional provider-generated answer text, search context, or summary. `sources[]` is the portable citation shape. A source always has a URL; title, snippet, summary, and `publishedAt` are optional because not every provider returns them. `snippet` is a short excerpt for display, and `summary` is a longer excerpt for model reading whose length the consumer bounds. `title` is not required: Perplexity-style citations may provide only URLs, and forcing adapters to invent titles would make the seam lie. `dsh-tool-web` renders a `title ?? hostname(url)`-style fallback label for display. `publishedAt` is an optional publication/crawl timestamp as an ISO-8601 string — the DeepSeek Search API returns it as a nullable `date` on each result, Exa as `publishedDate`, and Perplexity as `date`, so it is real provider data, not derived; the seam carries it as a string and leaves date parsing to the consumer. -Exa search maps each entry of the provider's flat `results[]` into a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first `highlights[]` entry (an entry with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. Exa returns no provider-generated answer, so `content` is omitted. Perplexity search maps `choices[0].message.content` to `content` and prefers the structured top-level `search_results[]` for `sources[]` — `url` ← `url`, `title` ← `title`, `snippet` ← `snippet` (often empty), `publishedAt` ← `date` — falling back to the URL-only `citations[]` array only when `search_results` is absent (those sources carry just a `url`). If a provider returns fewer structured fields than the seam supports, the adapter omits those optional fields. +The DeepSeek Search API provider maps each entry of the response's `results[]` into a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← `snippet`, `summary` ← `summary`, `publishedAt` ← `date`, omitting empty values. It returns no provider-generated answer, so `content` is omitted. If a provider returns fewer structured fields than the seam supports, the adapter omits those optional fields. Full page retrieval remains the job of `web_fetch(url)`. Search snippets are discovery context, not fetched page bodies. @@ -283,7 +284,7 @@ Tool execution lets these errors flow through `ToolRuntime.execute()`, which alr ## Testing -Each layer is pinned at its own boundary: the registry/selection/truncation/abort contract and the `WebError` codes in `dsh-web`; per-provider request/response mapping over recorded fixtures (Perplexity fixtures include URL-only citations so the optional source fields stay honest) plus a self-skipping with-key smoke per real provider; real local-HTTP behavior in `web-fetch-http`; and enablement-driven registration, structured execution errors, and result formatting through the real tool registry in `dsh-tool-web`. A real-Loader smoke guards the two export shapes ([postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)): `dsh-web` is a default-exported service, while the providers and `tool-web` are namespace plugins where a stray `export default` would drop `inject`. +Each layer is pinned at its own boundary: the registry/selection/truncation/abort contract and the `WebError` codes in `dsh-web`; per-provider request/response mapping over recorded fixtures plus a self-skipping with-key smoke per real provider; real local-HTTP behavior in `web-fetch-http`; and enablement-driven registration, structured execution errors, and result formatting through the real tool registry in `dsh-tool-web`. A real-Loader smoke guards the two export shapes ([postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)): `dsh-web` is a default-exported service, while the providers and `tool-web` are namespace plugins where a stray `export default` would drop `inject`. ## Alternatives considered @@ -293,7 +294,7 @@ This matches the most flexible provider-plugin systems: every provider can expos ### Put provider dispatch directly in `dsh-tool-web` -This resembles OpenCode's local web search: one stable `websearch` tool dispatches to Exa or Parallel internally. It is acceptable for a small product path but wrong as a harness foundation. The tool package would own provider selection, credentials, request mapping, transport, response parsing, and presentation, making it hard to add Exa and Perplexity without baking their differences into the tool schema. +This resembles OpenCode's local web search: one stable `websearch` tool dispatches to Exa or Parallel internally. It is acceptable for a small product path but wrong as a harness foundation. The tool package would own provider selection, credentials, request mapping, transport, response parsing, and presentation, making it hard to add providers with different formats without baking their differences into the tool schema. ### Split search and fetch into two seams (`dsh-search`, `dsh-fetch`) @@ -321,9 +322,9 @@ Rejected for the shipped presets. Per-call confirmation would interrupt ordinary ## Consequences -**The search schema is deliberately thin.** Exa and Perplexity both expose useful provider-specific controls; a control is added only once it can be defined provider-neutrally and enforced honestly by both tool registration and provider execution. +**The search schema is deliberately thin.** Search APIs expose useful provider-specific controls, such as Exa's `livecrawl`, Perplexity's search recency, and the DeepSeek Search API's `localization`; a control is added only once it can be defined provider-neutrally and enforced honestly by both tool registration and provider execution. -**Perplexity citations can be sparse.** A citation may be only a URL. Making `title` and `snippet` optional keeps the seam truthful but means `tool-web` renders fallback labels. +**Citations can be sparse.** A Perplexity-style citation may be only a URL. Making `title` and `snippet` optional keeps the seam truthful but means `tool-web` renders fallback labels. **Stable tool registration defers misconfiguration to execution.** Keeping the tool visible is correct when the product enabled web access, but product apps that expect web search should surface the structured `WEB_PROVIDER_CONFIGURED_MISSING` / `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` / `WEB_PROVIDER_AMBIGUOUS` failures loudly so users do not discover setup problems only after the model calls the tool. @@ -337,7 +338,7 @@ Rejected for the shipped presets. Per-call confirmation would interrupt ordinary - A `pdf` `WebFetchBody` kind: the `http` provider decodes text-extractable PDFs (best-effort, capped, `truncated`) into a `{ kind: 'pdf'; content; pageCount? }` arm, and `tool-web` renders it. This is fetch, not `web_extract` — PDF retrieval is a concrete HTTP 200 plus deterministic local decoding, not provider-side extraction of a non-HTTP resource. Adding it is a coordinated change across `dsh-web` (declare the arm), the provider (decode + narrow "binary rejection" to "reject binary except text-extractable PDF"; scanned/image PDFs needing OCR stay out of scope), and `tool-web` (render). The closed `WebFetchBody` union makes the consumer side fail to compile until the new arm is handled. - Provider-backed extraction as a separate `web_extract` capability, rather than widening `web_fetch` silently. -- Provider-neutral search controls beyond `query` and `maxResults`, once Exa and Perplexity can both honor them honestly. +- Provider-neutral search controls beyond `query` and `maxResults`, once the registered providers can honor them honestly. ## Open questions diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md index ac5f935c92..7977f68d34 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -harness 需要面向模型的 web 工具,但不能将模型约定绑定到某一家厂商的 API 形状上。搜索是当前的压力点:从一开始就同时支持 Exa 搜索和 Perplexity 搜索——两种刻意不同的提供方形状(Exa 返回扁平的 `results[]`,每项包含 `{title, url, highlights, publishedDate}`;Perplexity 返回一段生成式回答加引用列表)——正是用来证明归一化的 web 约定并非只是镜像某一家厂商。Fetch 是另一项独立操作:匿名公开 HTTP(S) fetch 后端涉及传输、安全、重定向、解码和大小限制等关注点,与提供方支撑的搜索并不相同。 +harness 需要面向模型的 web 工具,但不能将模型约定绑定到某一家厂商的 API 形状上。搜索是当前的压力点:各搜索 API 返回刻意不同的格式(Exa 返回扁平的 `results[]`,每项包含 `{title, url, highlights, publishedDate}`;Perplexity 返回一段生成式回答加引用列表;DeepSeek Search API 返回带 snippet 与摘要的扁平 `results[]`),归一化的 web 约定必须能接纳每一种格式,而不是镜像某一家厂商。seam 是以 Exa 和 Perplexity 的格式为参照设计的;这两个提供方都不随附,插件可以注册它们。Fetch 是另一项独立操作:匿名公开 HTTP(S) fetch 后端涉及传输、安全、重定向、解码和大小限制等关注点,与提供方支撑的搜索并不相同。 面向模型的 API 必须保持稳定,而后端可以更换。更换搜索提供方不应改变模型发起查询的方式;更换 fetch 实现不应改变模型请求 URL 的方式。反过来,提供方包也不应仅仅因为自己有额外的提供方特有旋钮就暴露自己的面向模型工具 schema。 @@ -19,7 +19,7 @@ harness 需要面向模型的 web 工具,但不能将模型约定绑定到某 Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.zh.md): 1. `@deepseek-ai/dsh-web`(`packages/web/web`)拥有 `ctx.web`、提供方注册、提供方选择、共享的请求/结果词汇,以及 web 特有的错误。 -2. 提供方包实现具体后端并向 `ctx.web` 注册能力,例如 `@deepseek-ai/dsh-web-search-exa`、`@deepseek-ai/dsh-web-search-perplexity`、`@deepseek-ai/dsh-web-search-deepseek` 和 `@deepseek-ai/dsh-web-fetch-http`。 +2. 提供方包实现具体后端并向 `ctx.web` 注册能力,例如 `@deepseek-ai/dsh-web-search-deepseek`、`@deepseek-ai/dsh-web-deepseek-api` 和 `@deepseek-ai/dsh-web-fetch-http`。 3. `@deepseek-ai/dsh-tool-web`(`packages/web/tool-web`)拥有面向模型的 `web_search` 和 `web_fetch` 工具 schema、提示词段落、参数校验、结果格式化,以及通过 `ctx.web` 实现的工具展示。 提供方不注册工具。提供方注册能力。`dsh-tool-web` 是面向模型的名称、描述、提示词引导、JSON Schema、展示的唯一所有者。 @@ -32,9 +32,9 @@ Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13 - 工具绝不会仅仅因为其选定的提供方缺失、配置错误、缺少凭证、存在歧义或暂时不可用就被注销。 - 提供方在执行时解析,当选定的能力无法运行时返回结构化的 `WebError`。 -这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的约定。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。发行版的 `dsh-tool-web` 按模型请求收窄了这一点:选中的提供方报告该请求的搜索无法鉴权时,它会[撤下 `web_search`](2026-09-27-web-search-offered-by-provider-readiness.zh.md)。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。 +这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的约定。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。`dsh-tool-web` 按模型请求收窄了这一点:选中的提供方报告该请求的操作无法鉴权时,它会[撤下 `web_search` 或 `web_fetch`](2026-09-27-web-search-offered-by-provider-readiness.zh.md)。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。 -该 seam 刻意不暴露任何观察面——没有注册表变更事件,也没有聚合的能力状态查询。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。 +该 seam 刻意不暴露可用性观察——没有聚合的能力状态查询,也没有逐个提供方的可用性报告。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。提供方选择器消费的是注册情况而非可用性:`listProviders()` 与 Remote 方法 `web/listProviders` 列出每个已注册提供方及其可选的 `title` 与 `description`(包括不可用的提供方),且每次注册表变更后都会触发无载荷的 `web/providers-changed` 事件。[Web 提供方选择决策](2026-10-29-web-provider-selection.zh.md)记录了为何注册情况可观察而可用性不可观察。 ## 包拓扑 @@ -43,11 +43,9 @@ Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13 依赖方向与 bash 和 filesystem 一致: ```text -@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-exa +@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-deepseek consumer interface implementation - <--depends on-- @deepseek-ai/dsh-web-search-perplexity - implementation - <--depends on-- @deepseek-ai/dsh-web-search-deepseek + <--depends on-- @deepseek-ai/dsh-web-deepseek-api implementation <--depends on-- @deepseek-ai/dsh-web-fetch-http implementation @@ -57,9 +55,7 @@ Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13 ```mermaid flowchart LR - exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"] - perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web - deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web + deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"] deepseekApi["@deepseek-ai/dsh-web-deepseek-api"] -->|registerSearchProvider / registerFetchProvider| web fetchLocal["@deepseek-ai/dsh-web-fetch-http"] -->|registerFetchProvider| web toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web @@ -67,9 +63,9 @@ flowchart LR toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"] ``` -`@deepseek-ai/dsh-web` 仅依赖 Cordis 和底层 harness 支持。它声明 `ctx.web`、提供方接口、请求/结果类型、提供方可用性约定和错误码。它不导入工具、agent(智能体)、会话、LLM 或提供方包。 +`@deepseek-ai/dsh-web` 仅依赖 Cordis 和底层 harness 支持,其中包括其 Remote 方法所用的 Typert 协议。它声明 `ctx.web`、提供方接口、请求/结果类型、提供方可用性约定和错误码。它不导入工具、agent(智能体)、会话、LLM 或提供方包。 -提供方包仅依赖 `dsh-web` 和 Cordis。它们拥有凭证、端点、协议格式映射、解析和 `WebError` 转换,使用平台 `fetch`。每个提供方注入共享服务并注册后端;只有 `dsh-web` 拥有 `ctx.web` 键。提供方私有的协议形状不会产生对 `ctx.llm` 或 Cordis HTTP 服务的依赖。 +提供方包依赖 `dsh-web`、Cordis 及其凭据和配置函数。它们拥有凭证、端点、协议格式映射、解析和 `WebError` 转换,使用平台 `fetch`。每个提供方注入共享服务并注册后端;只有 `dsh-web` 拥有 `ctx.web` 键。提供方私有的协议形状不会产生对 `ctx.llm` 或 Cordis HTTP 服务的依赖。 `@deepseek-ai/dsh-tool-web` 依赖 `@deepseek-ai/dsh-web`、`@deepseek-ai/dsh-tools`、`@deepseek-ai/dsh-system-prompt` 和 Cordis。它从不导入具体的提供方包。 @@ -78,16 +74,22 @@ flowchart LR `ctx.web` 是一个提供方注册表加上一个带提供方选择的执行 API。注册表部分与 `LlmRuntime` 保持接近:每种能力类别一个 `Map`,`registerSearchProvider`/`registerFetchProvider` 方法返回 disposer,重复 id 抛出 `WebError`,执行时解析在选定提供方缺失或不可用时抛出异常。权威签名见 `packages/web/web/src/types.ts`;seam 的形状: ```ts -import type { WebFetchRequest, WebFetchResult, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web' +import type { + WebFetchRequest, WebFetchResult, WebProviderInfo, WebProviderList, WebSearchRequest, WebSearchResult, +} from '@deepseek-ai/dsh-web' interface WebSearchProvider { readonly id: string + readonly title?: string + readonly description?: WebProviderInfo['description'] available(): boolean search(request: WebSearchRequest, signal?: AbortSignal): Promise } interface WebFetchProvider { readonly id: string + readonly title?: string + readonly description?: WebProviderInfo['description'] available(): boolean fetch(request: WebFetchRequest, signal?: AbortSignal): Promise } @@ -95,6 +97,7 @@ interface WebFetchProvider { interface WebRuntime { registerSearchProvider(provider: WebSearchProvider): () => void registerFetchProvider(provider: WebFetchProvider): () => void + listProviders(): WebProviderList search(request: WebSearchRequest, signal?: AbortSignal): Promise fetch(request: WebFetchRequest, signal?: AbortSignal): Promise @@ -131,18 +134,15 @@ interface WebRuntime { - id: web name: '@deepseek-ai/dsh-web' config: - searchProvider: exa + searchProvider: deepseek-search-api fetchProvider: http -- id: web-search-exa - name: '@deepseek-ai/dsh-web-search-exa' - -- id: web-search-perplexity - name: '@deepseek-ai/dsh-web-search-perplexity' - - id: web-search-deepseek name: '@deepseek-ai/dsh-web-search-deepseek' +- id: web-deepseek-api + name: '@deepseek-ai/dsh-web-deepseek-api' + - id: web-fetch-http name: '@deepseek-ai/dsh-web-fetch-http' @@ -150,9 +150,9 @@ interface WebRuntime { name: '@deepseek-ai/dsh-tool-web' ``` -运维覆盖走同一条显式选择路径:`DSH_WEB_SEARCH_PROVIDER=perplexity` 等同于配置 `searchProvider: perplexity`,而非 `dsh-tool-web` 内部的隐式优先级链。 +运维覆盖走同一条显式选择路径:`DSH_WEB_SEARCH_PROVIDER=deepseek-search-api` 等同于配置 `searchProvider: deepseek-search-api`,而非 `dsh-tool-web` 内部的隐式优先级链。 -每次搜索或抓取在执行时读取配置的 id,因此保存的设置变更无需重新挂载 `dsh-web` 即对下一次操作选择另一个提供方。网页搜索设置页的开关借此把两种能力同时切换到 [DeepSeek Search 与 Fetch API](../feature/2026-10-15-deepseek-search-fetch-api.zh.md)。 +每次搜索或抓取在执行时读取配置的 id,因此保存的设置变更无需重新挂载 `dsh-web` 即可让下一次操作选用新提供方。「网页搜索与获取」设置页的 Search 和 Fetch 下拉框借此分别切换各能力的提供方(包括 [DeepSeek Search 与 Fetch API](../feature/2026-10-15-deepseek-search-fetch-api.zh.md));[Web 提供方选择决策](2026-10-29-web-provider-selection.zh.md)负责内置默认值。 `ctx.web.search()` 和 `ctx.web.fetch()` 在执行时按上述选择规则解析提供方。如果选定的能力不可用,它们抛出带有结构化代码的 `WebError`,如 `WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE` 或 `WEB_PROVIDER_AMBIGUOUS`。如果未显式配置提供方且不存在可用提供方,执行错误是通用的 `WEB_PROVIDER_UNAVAILABLE` 情况;刻意不提供对每个不可用提供方的诊断汇总。 @@ -168,8 +168,8 @@ interface WebRuntime { - `dsh-tool-web` 拥有该值并将其放在 `WebSearchRequest.maxResults` 上。 - `ctx.web` 将请求原样传递给选定的提供方。 -- 当提供方的 API 支持结果数量控制时(Exa 的 `numResults`),提供方在请求层应用 `maxResults`,作为成本/延迟优化。 -- `ctx.web` 在结果上强制执行上限:如果提供方返回的 source 数量超过 `maxResults`——因为其 API 没有结果数量控制(Perplexity)或忽略了提示——seam 将 `sources[]` 截断到 `maxResults` 并在返回前将 `WebSearchResult.truncated` 设为 `true`。这使上限成为面向模型层可以依赖的单一跨提供方保证,而非每个提供方都必须记得遵守的东西。 +- 当提供方的 API 支持结果数量控制时(DeepSeek Search API 的 `count`),提供方在请求层应用 `maxResults`,作为成本/延迟优化。 +- `ctx.web` 在结果上强制执行上限:如果提供方返回的 source 数量超过 `maxResults`——因为其 API 没有结果数量控制或忽略了提示——seam 将 `sources[]` 截断到 `maxResults` 并在返回前将 `WebSearchResult.truncated` 设为 `true`。这使上限成为面向模型层可以依赖的单一跨提供方保证,而非每个提供方都必须记得遵守的东西。 seam 请求不携带提供方特有的控制——没有 Perplexity 模型选择、搜索时效性、域名过滤器、Exa `livecrawl`、Exa `type`、区域提示、生成式回答预算或搜索深度。只有当某个字段具有提供方无关的语义,且工具 schema 和选定的提供方都能诚实地遵守时,才会添加。 @@ -190,13 +190,14 @@ interface WebSearchSource { readonly url: string readonly title?: string readonly snippet?: string + readonly summary?: string readonly publishedAt?: string } ``` -`content` 是可选的提供方生成的回答文本、搜索上下文或摘要。`sources[]` 是可移植的引用结构。source 必有 URL;title、snippet 和 `publishedAt` 可选,因为并非每个提供方都返回它们。`title` 不是必填:Perplexity 风格的引用可能只提供 URL,强制适配器编造标题会让 seam 说谎。`dsh-tool-web` 渲染 `title ?? hostname(url)` 风格的回退标签用于展示。`publishedAt` 是可选的发布/抓取时间戳,为 ISO-8601 字符串——Exa 在每条结果上以 `publishedDate` 返回它,Perplexity 在搜索结果上返回 `date`,因此它是真实的提供方数据而非派生值;seam 以字符串形式传递,日期解析留给消费方。 +`content` 是可选的提供方生成的回答文本、搜索上下文或摘要。`sources[]` 是可移植的引用结构。source 必有 URL;title、snippet、summary 和 `publishedAt` 可选,因为并非每个提供方都返回它们。`snippet` 是用于展示的短摘录,`summary` 是供模型阅读的较长摘录,其长度由消费方限制。`title` 不是必填:Perplexity 风格的引用可能只提供 URL,强制适配器编造标题会让 seam 说谎。`dsh-tool-web` 渲染 `title ?? hostname(url)` 风格的回退标签用于展示。`publishedAt` 是可选的发布/抓取时间戳,为 ISO-8601 字符串——DeepSeek Search API 在每条结果上以可为 null 的 `date` 返回它,Exa 以 `publishedDate` 返回,Perplexity 以 `date` 返回,因此它是真实的提供方数据而非派生值;seam 以字符串形式传递,日期解析留给消费方。 -Exa 搜索将提供方扁平 `results[]` 的每一项映射为 `WebSearchSource`:`url` ← `url`、`title` ← `title`、`snippet` ← 第一个 `highlights[]` 条目(没有 highlight 的条目没有可移植的 snippet,被丢弃)、`publishedAt` ← `publishedDate`。Exa 不返回提供方生成的回答,因此 `content` 省略。Perplexity 搜索将 `choices[0].message.content` 映射为 `content`,并优先使用结构化的顶层 `search_results[]` 作为 `sources[]`——`url` ← `url`、`title` ← `title`、`snippet` ← `snippet`(常为空)、`publishedAt` ← `date`——仅在 `search_results` 缺失时回退到纯 URL 的 `citations[]` 数组(这些 source 只有 `url`)。如果提供方返回的结构化字段少于 seam 支持的,适配器省略那些可选字段。 +DeepSeek Search API 提供方将响应 `results[]` 的每一项映射为 `WebSearchSource`:`url` ← `url`、`title` ← `title`、`snippet` ← `snippet`、`summary` ← `summary`、`publishedAt` ← `date`,空值省略。它不返回提供方生成的回答,因此 `content` 省略。如果提供方返回的结构化字段少于 seam 支持的,适配器省略那些可选字段。 完整页面获取仍是 `web_fetch(url)` 的职责。搜索 snippet 是发现上下文,不是获取到的页面正文。 @@ -283,7 +284,7 @@ fetch 提供方的资源控制: ## 测试 -每一层在自己的边界处固定:`dsh-web` 中的注册/选择/截断/abort 约定与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射(Perplexity fixture 包含纯 URL 引用,以保持可选 source 字段的诚实性),加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-http` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事故复盘(postmortem) 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.zh.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。 +每一层在自己的边界处固定:`dsh-web` 中的注册/选择/截断/abort 约定与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射,加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-http` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事故复盘(postmortem) 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.zh.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。 ## 曾考虑的替代方案 @@ -293,7 +294,7 @@ fetch 提供方的资源控制: ### 将提供方调度直接放在 `dsh-tool-web` 中 -这类似 OpenCode 的本地 web 搜索:一个稳定的 `websearch` 工具在内部调度到 Exa 或 Parallel。对于小型产品路径可以接受,但作为 harness 基础是错误的。工具包将拥有提供方选择、凭证、请求映射、传输、响应解析和展示,使得在不将 Exa 和 Perplexity 的差异烘焙进工具 schema 的情况下难以添加它们。 +这类似 OpenCode 的本地 web 搜索:一个稳定的 `websearch` 工具在内部调度到 Exa 或 Parallel。对于小型产品路径可以接受,但作为 harness 基础是错误的。工具包将拥有提供方选择、凭证、请求映射、传输、响应解析和展示,使得在不将各提供方格式差异烘焙进工具 schema 的情况下难以添加格式不同的提供方。 ### 将搜索和 fetch 拆为两个 seam(`dsh-search`、`dsh-fetch`) @@ -322,9 +323,9 @@ fetch 提供方的资源控制: ## 后果 -**搜索 schema 刻意精简。** Exa 和 Perplexity 都暴露了有用的提供方特有控制;只有当某个控制能以提供方无关的方式定义、且工具注册和提供方执行都能诚实遵守时,才会添加。 +**搜索 schema 刻意精简。** 各搜索 API 暴露了有用的提供方特有控制,例如 Exa 的 `livecrawl`、Perplexity 的搜索时效性和 DeepSeek Search API 的 `localization`;只有当某个控制能以提供方无关的方式定义、且工具注册和提供方执行都能诚实遵守时,才会添加。 -**Perplexity 引用可能稀疏。** 一条引用可能只有 URL。将 `title` 和 `snippet` 设为可选使 seam 保持诚实,但意味着 `tool-web` 需要渲染回退标签。 +**引用可能稀疏。** 一条 Perplexity 风格的引用可能只有 URL。将 `title` 和 `snippet` 设为可选使 seam 保持诚实,但意味着 `tool-web` 需要渲染回退标签。 **稳定的工具注册将配置错误推迟到执行时。** 当产品启用了 web 访问时,保持工具可见是正确的;但期望 web 搜索可用的产品应用应当明确暴露结构化的 `WEB_PROVIDER_CONFIGURED_MISSING`/`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`/`WEB_PROVIDER_AMBIGUOUS` 失败,避免用户直到模型调用工具后才发现配置问题。 @@ -340,7 +341,7 @@ fetch 提供方的资源控制: - `pdf` `WebFetchBody` 类别:`http` 提供方将可文本提取的 PDF 解码(尽力而为、有上限、`truncated`)为 `{ kind: 'pdf'; content; pageCount? }` 分支,`tool-web` 渲染它。这是 fetch 而非 `web_extract`——PDF 获取是具体的 HTTP 200 加确定性的本地解码,不是提供方侧对非 HTTP 资源的提取。添加它是跨 `dsh-web`(声明分支)、提供方(解码 + 将「二进制拒绝」收窄为「拒绝二进制,但可文本提取的 PDF 除外」;需要 OCR 的扫描/图片 PDF 不在范围内)和 `tool-web`(渲染)的协调变更。封闭的 `WebFetchBody` 联合类型使消费方在新分支被处理之前编译失败。 - 提供方支撑的提取作为独立的 `web_extract` 能力,而非静默扩展 `web_fetch`。 -- `query` 和 `maxResults` 之外的提供方无关搜索控制,待 Exa 和 Perplexity 都能诚实遵守时再添加。 +- `query` 和 `maxResults` 之外的提供方无关搜索控制,待已注册的提供方都能诚实遵守时再添加。 ## 开放问题 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index bdd096c69d..ef8926e7d2 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml @@ -12,8 +12,8 @@ en: aea80ece13f2905b zh: a880bc1a7839cf12 /agent-note-one-ordering-for-configuration-sources-and-what-a-discovered-file-may-not-decide/consequences: - en: 640b7ee4e8c57ed0 - zh: 1c17b3fba16861a8 + en: 41ebe22077560090 + zh: da3475e4c196c3f9 /agent-note-one-ordering-for-configuration-sources-and-what-a-discovered-file-may-not-decide/alternatives-considered: en: ac43499cfe7b8495 zh: e5ab599e7d3ed753 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md index ee4ea2a5b9..aeb9a25081 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -58,7 +58,6 @@ The line is that these take effect with no user action, before any turn, outside - A `.env` holding `DSH_*`, `PATH`, `BROWSER`, or — in the invoking directory — a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. - Composition is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; the product CLI offers no flag above it, so a deployment that must win against stored settings owns its own bin or loader tree. - Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all; the environment package records the remaining subprocess reach as a limitation. -- Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request credential resolution is separate work. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md index 024c93d3d1..36c3dda624 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -59,7 +59,6 @@ inherited process environment (read-only, wins) - 含 `DSH_*`、`PATH`、`BROWSER` 或(在调用目录中)proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 - composition 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;产品 CLI 没有高于它的标志,因此需要压过已存 settings 的部署方要自带 bin 或 loader 配置树。 - 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件;环境包将其余变量仍可抵达子进程这一点记录为一项限制。 -- Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求解析凭据是另一件事。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.i18n.yaml index 0fb431067d..10be2a7053 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.i18n.yaml @@ -9,8 +9,8 @@ en: 2abacff687661d28 zh: 2d7ba3ae27dba888 /agent-note-keep-live-configuration-in-the-owning-profile/decision: - en: 66c3884425cfb90a - zh: 0fe6a1f1a576470b + en: f9b1dd3d158457a9 + zh: 06ca76fcbbc76cda /agent-note-keep-live-configuration-in-the-owning-profile/alternatives-considered: en: 4089464ad1fb60bd zh: 3168e9e4a205145d diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.md b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.md index 65cc8f60b1..aabe6f5c43 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.md +++ b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.md @@ -14,7 +14,7 @@ A plugin declares every configurable value in its Cordis Config. Fields that can Form writes target the active profile. Home patches and command-line overlays remain higher-priority deployment inputs; an edit they would shadow fails before persistence. Two plugin instances are addressed by their distinct profile entry ids. Nested Includes retain independent ownership and are not writable through the profile form. -Cordis config patches replace complete entry configs. An edit preserves ordinary fields and unedited secrets, including raw configuration expressions. A field reset restores the currently inherited value; resetting the complete entry removes its config overrides so later bundle changes are inherited. Other edits store the entry's complete config in the profile row: the ordinary fields as composed at write time plus every volatile field. Later bundle changes to those fields do not reach that profile until the entry's config override is removed. In the shipped bundles this pins `permission.presets` after a default-preset change, `agent-presets` after a preset choice, `agent-loop.agents` after a parallelism edit, and `web-search-deepseek.apiKeyEnv` after a Web Search edit, and the client marks every volatile field of such a row as overridden, not only the edited one. Narrowing a write to the edited fields needs merge semantics for patch `config`, which Include does not provide. +Cordis config patches replace complete entry configs. An edit preserves ordinary fields and unedited secrets, including raw configuration expressions. A field reset restores the currently inherited value; resetting the complete entry removes its config overrides so later bundle changes are inherited. Other edits store the entry's complete config in the profile row: the ordinary fields as composed at write time plus every volatile field. Later bundle changes to those fields do not reach that profile until the entry's config override is removed. In the shipped bundles this pins `permission.presets` after a default-preset change, `agent-presets` after a preset choice, `agent-loop.agents` after a parallelism edit, and `web-search-deepseek.apiKeyEnv` after a legacy Messages search edit on the Web search and fetch page, and the client marks every volatile field of such a row as overridden, not only the edited one. Narrowing a write to the edited fields needs merge semantics for patch `config`, which Include does not provide. The removed `$DSH_HOME/settings.yaml` is imported once into the active profile when the Loader has settled every entry after Settings starts: section ids are entry ids, with `ui-developer-tools` → `ui-settings`, `ui-onboarding` → `ui-settings-general`, and `shell` → the platform's shell executor entry. The file is renamed to `settings.yaml.imported` before the first write, so the import never repeats; a rejected section is logged and stays in the renamed file. diff --git a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.zh.md b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.zh.md index 9ce0952aab..f808941c35 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-19-profile-owned-live-configuration.zh.md @@ -14,7 +14,7 @@ Status: implemented 表单写入当前 profile。home patch 和命令行 overlay 仍是优先级更高的部署输入;如果它们会遮蔽某次修改,编辑器会在持久化前拒绝写入。两个插件实例通过各自不同的 profile entry id 寻址。嵌套 Include 保留独立所有权,不能通过该 profile 表单写入。 -Cordis config patch 替换完整 entry config。编辑会保留普通字段、未编辑的秘密值和原始配置表达式。字段重置恢复当前继承值;整个 entry 重置后会移除其 config override,从而继承后续 bundle 变化。其他编辑会把该 entry 的完整 config 存入 profile 行:写入时组合出的普通字段加上全部 volatile 字段。此后 bundle 对这些字段的修改在该 entry 的 config override 被移除之前不会到达该 profile。在已发布的 bundle 中,修改默认权限预设会钉住 `permission.presets`,选择预设会钉住 `agent-presets`,编辑并行度会钉住 `agent-loop.agents`,编辑网页搜索会钉住 `web-search-deepseek.apiKeyEnv`;客户端会把这类行的每个 volatile 字段都标为已覆盖,而不只是被编辑的那个。要把写入收窄到被编辑字段需要 patch `config` 的合并语义,Include 不提供。 +Cordis config patch 替换完整 entry config。编辑会保留普通字段、未编辑的秘密值和原始配置表达式。字段重置恢复当前继承值;整个 entry 重置后会移除其 config override,从而继承后续 bundle 变化。其他编辑会把该 entry 的完整 config 存入 profile 行:写入时组合出的普通字段加上全部 volatile 字段。此后 bundle 对这些字段的修改在该 entry 的 config override 被移除之前不会到达该 profile。在已发布的 bundle 中,修改默认权限预设会钉住 `permission.presets`,选择预设会钉住 `agent-presets`,编辑并行度会钉住 `agent-loop.agents`,在「网页搜索与获取」页编辑旧版 Messages 搜索会钉住 `web-search-deepseek.apiKeyEnv`;客户端会把这类行的每个 volatile 字段都标为已覆盖,而不只是被编辑的那个。要把写入收窄到被编辑字段需要 patch `config` 的合并语义,Include 不提供。 被移除的 `$DSH_HOME/settings.yaml` 会在 Settings 启动后、Loader 完成所有条目加载时一次性导入当前 profile:section id 即 entry id,其中 `ui-developer-tools` → `ui-settings`、`ui-onboarding` → `ui-settings-general`、`shell` → 当前平台的 shell 执行器条目。文件在第一次写入前改名为 `settings.yaml.imported`,因此导入不会重复;被拒绝的 section 会记录日志并保留在改名后的文件中。 diff --git a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.i18n.yaml index 4a2fe2ba9d..badb03541e 100644 --- a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.i18n.yaml @@ -6,14 +6,14 @@ en: 9163af91ea0e030d zh: 92e44043b999d32f /agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/problem: - en: 6fac3c73a4415c83 - zh: f65b61cc1705348b + en: 32ba2b2b42e8deb5 + zh: 6a6532561f81b8e2 /agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/decision: - en: 458416ed7e47f644 - zh: 8b4dd820228e9dfd + en: 08a54b37e157da29 + zh: 65f633055d9df242 /agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/alternatives-considered: - en: b731254983be656d - zh: 5dee2b09f7faa537 + en: 6da221f6b821ea44 + zh: b15774011abed7ed /agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/consequences: - en: 798faeaa0b406eac - zh: 97bbfdaebc84b1d4 + en: c90a122c1f33bb83 + zh: 0175ca0f599d086b diff --git a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md index c3e74990d7..2399e3f1fc 100644 --- a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md +++ b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md @@ -6,17 +6,17 @@ English | [中文](2026-09-27-web-search-offered-by-provider-readiness.zh.md) ## Problem -`dsh-tool-web` registers `web_search` whenever search is enabled, and the [web capability seam decision](2026-06-24-web-capability-seam.md) keeps it visible while the selected provider cannot run. The shipped search provider, `dsh-web-search-deepseek`, authenticates a search with the DeepSeek account token only for a model request on the `deepseek-account` route; every other route needs a configured API key. A Session on an API-key route without a search key was therefore offered a tool whose every call failed with `WEB_PROVIDER_CREDENTIAL_MISSING`. Sessions in one Host can use different routes, a Session can switch routes between requests, and the Web app registers `web_search` once per agent preset, so no registration decision matches every request. The Search and Fetch APIs of `dsh-web-deepseek-api`, which the Web search settings switch selects together, need that credential for both operations, so the same Session was also offered a `web_fetch` whose every call failed. +`dsh-tool-web` registers `web_search` whenever search is enabled, and the [web capability seam decision](2026-06-24-web-capability-seam.md) keeps it visible while the selected provider cannot run. The DeepSeek web providers authenticate an operation with the DeepSeek account token only for a model request on the `deepseek-account` route; every other route needs a configured API key. Without a readiness check, a Session on an API-key route without a key is offered a tool whose every call fails with `WEB_PROVIDER_CREDENTIAL_MISSING`. Sessions in one Host can use different routes, a Session can switch routes between requests, and the Web app registers `web_search` once per agent preset, so no registration decision matches every request. The Fetch API of `dsh-web-deepseek-api`, which the user selects separately from the search provider ([provider selection](2026-10-29-web-provider-selection.md)), needs the same credential, so such a Session with it selected would also be offered a `web_fetch` whose every call fails. ## Decision Each role of the web capability seam owns one part of a per-step check: - **Service Definition.** `WebSearchProvider` and `WebFetchProvider` have an optional `ready(llmProvider)` that answers whether an operation made for a model request on that chat route has a credential to send. It checks configuration only and makes no network calls. `ctx.web.searchReady(llmProvider?)` and `ctx.web.fetchReady(llmProvider?)` resolve the provider with the same rules as `search()` and `fetch()` and return its answer. A provider without `ready()` counts as ready, and a selection failure answers true because the operation reports that failure as a structured `WebError`. -- **Service Provider.** `dsh-web-search-deepseek` answers true when the route is `deepseek-account` and `ctx.deepseekAccount.resolveToken()` returns a token for the configured search endpoint, when the section carries a literal `apiKey`, or when `ctx.credentials.describe()` reports the `apiKeyEnv` reference configured (without that service, when the launching environment carries it). It checks credentials in the order `search()` resolves them, and a failed account-token or credential-store read answers false, because `search()` fails on that read instead of trying the next credential. The Search and Fetch API providers of `dsh-web-deepseek-api` share one credential and answer the same way for their API base; their transport repeats these checks, so neither package depends on the other. The providers name no tool or prompt section. +- **Service Provider.** Each DeepSeek provider checks its credentials in the order its operation resolves them. `dsh-web-search-deepseek` checks, on the `deepseek-account` route, whether `ctx.deepseekAccount.resolveToken()` returns a token for its Messages endpoint, and then its literal `apiKey` or its `apiKeyEnv` reference. The Search and Fetch API providers of `dsh-web-deepseek-api` resolve the same plan as the operation ([credential resolution](2026-10-29-web-provider-selection.md)): a capability with its own YAML fields checks only the key under its `*ApiKeyEnv`; otherwise, on the account route with the account model route mounted, a token for that route's `/v1/tools` base counts, and then the key of the `deepseek-official` route, or `DEEPSEEK_API_KEY` without that route. A key counts when `ctx.credentials.describe()` reports its reference configured (without that service, when the launching environment carries it); the [shared credential helpers](../../../../packages/llm/llm-deepseek/src/credential.ts) perform these presence checks. A failed account-token or credential-store read answers false, because the operation fails on that read instead of trying the next credential. The providers name no tool or prompt section. - **Consumer.** `dsh-tool-web` registers `web_search` and `web_fetch` with [step activation conditions](../../../../packages/core/tools/README.md#conditional-tools) that ask `ctx.web.searchReady()` and `ctx.web.fetchReady()` about the route of the step's model selection. The agent loop resolves conditions before it assembles the step, so the declared tools, the `tool:web_search` and `tool:web_fetch` prompt sections, PTC program bindings, and dispatch all read the same admitted set. -The route decides only whether the account token is checked before the API key, which happens on the `deepseek-account` route; an API key counts on every route, so a conversation on another provider's model is offered the DeepSeek web tools whenever a DeepSeek key is configured. The providers do not compare the chat route's endpoint with their own: each operation sends the configured key, `DEEPSEEK_API_KEY` by default, to the configured endpoint, even when the official route chats through a gateway. +The route decides only whether the account token is checked before the API key, which happens on the `deepseek-account` route; an API key counts on every route, so a conversation on another provider's model is offered the DeepSeek web tools whenever a DeepSeek key is configured. A Search or Fetch API capability with its own YAML fields ignores the route and is ready exactly when its own key is configured. Only presence decides: no API key value is read and no endpoint is contacted, so a wrong key or an endpoint that does not serve the native `web_search_20250305` server tool keeps the tool admitted. A step that does not admit a web tool also refuses calls to it as `UNKNOWN_TOOL`, so a model that repeats an earlier call reaches no provider; an operation made outside a model step resolves its credential when it runs and fails with `WEB_PROVIDER_CREDENTIAL_MISSING` when there is none. @@ -24,7 +24,7 @@ The request header records the assembled tools, so the agent loop announces each ## Alternatives considered -**Let the DeepSeek provider remove `web_search` in its own assemble listener.** The seam stays unchanged, but the provider then names another package's tool, prompt section, and PTC transport, and it removes the tool even when the deployment selects another search provider, such as Exa, whose searches would succeed. +**Let the DeepSeek provider remove `web_search` in its own assemble listener.** The seam stays unchanged, but the provider then names another package's tool, prompt section, and PTC transport, and it removes the tool even when the deployment selects another search provider, such as one a plugin registers, whose searches would succeed. **Extend `available()`.** It is synchronous and takes no request, while this check reads an asynchronous credential store and depends on the request's route. A route-dependent `available()` would also make provider selection depend on the conversation. @@ -36,9 +36,9 @@ The request header records the assembled tools, so the agent loop announces each **Offer search only on the DeepSeek account route.** Account tokens are issued only for the official endpoint, so a gateway key could never reach DeepSeek. But every shipped profile defaults to the `deepseek-official` route, so search would be off by default, including the official API-key case. -**Withhold the tools from other providers' conversations unless search has a key of its own.** A search from another model's conversation bills the DeepSeek key, but DeepSeek search is one of the services that key pays for, and a user who configured it expects search to work whatever model chats. The Web search settings page saves its key under `apiKeyEnv`, `DEEPSEEK_API_KEY` by default, which the chat route also reads, so a key entered there could never serve another model; a `routes` setting that opens them would be one more thing a user has to find. +**Withhold the tools from other providers' conversations unless search has a key of its own.** A search from another model's conversation bills the DeepSeek key, but DeepSeek search is one of the services that key pays for, and a user who configured it expects search to work whatever model chats. The Search and Fetch APIs send the chat route's own key, and the settings page saves the legacy Messages search key under `apiKeyEnv`, `DEEPSEEK_API_KEY` by default, which the chat route also reads, so withholding them would leave a configured key unable to serve another model; a `routes` setting that opens them would be one more thing a user has to find. -**Compare the official chat route's endpoint with the search endpoint.** Withholding the tools or the key when the two endpoints differ in origin keeps a gateway's key away from DeepSeek, but the providers would have to find the chat route's configuration themselves. Taking the first active `dsh-llm-deepseek-api-key` Loader row answers for an unrelated instance when a composition isolates several, which disables a correct route or lets a gateway key through; only the route's owner knows which endpoint serves a request, and an endpoint field in `dsh-llm` would reach the browser through the Remote `listProviders()`. The check would also withhold search from users who chat through a proxy or development endpoint with a key the official search endpoint accepts. Without it, a gateway user's search fails at the configured endpoint. +**Compare the official chat route's endpoint with the web endpoint and withhold the tools when they differ.** It would keep a gateway's key away from DeepSeek, but it would also withhold web tools from users who chat through a proxy or development endpoint with a key the web endpoint accepts. The Search and Fetch APIs instead derive their endpoint from the origin of the route whose key they send, so the key and endpoint come from one route; legacy Messages search keeps its own endpoint, where a gateway user's search fails. **Keep the tool offered and fail at execution.** The model spends a call on a search that cannot authenticate, and the failure repeats on every attempt. @@ -46,6 +46,6 @@ The request header records the assembled tools, so the agent loop announces each ## Consequences -`dsh-web` gains public members: the optional `ready()` of both provider interfaces, `WebRuntime.searchReady()`, and `WebRuntime.fetchReady()`. Each readiness method is the smallest query its one consumer needs, which is how the [observation-surface decision](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) lets a status query return; neither reports availability or selection failures. The Exa, Perplexity, and HTTP fetch providers implement no `ready()`, so their behavior is unchanged, and `web_fetch` stays admitted whenever the switch leaves HTTP fetch selected. A step that does not admit a web tool leaves it out of PTC program bindings too. An endpoint that cannot serve the native server tool keeps the tool admitted and fails each call. Removing a tool on an unchanged route changes the declared tools and misses the prompt cache once; a model switch already invalidates it. Searches and fetches from other providers' conversations bill the configured DeepSeek key, and a deployment whose official route chats through a gateway sends the gateway's key to the configured endpoint, the official one by default, where each operation fails unless that endpoint accepts the key. The DeepSeek web packages read no Loader rows and depend on no LLM package. +`dsh-web` gains public members: the optional `ready()` of both provider interfaces, `WebRuntime.searchReady()`, and `WebRuntime.fetchReady()`. Each readiness method is the smallest query its one consumer needs, which is how the [observation-surface decision](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) lets a status query return; neither reports availability or selection failures. The HTTP fetch provider implements no `ready()`, so `web_fetch` stays admitted whenever HTTP fetch is selected. A step that does not admit a web tool leaves it out of PTC program bindings too. An endpoint that cannot serve the native server tool keeps the tool admitted and fails each call. Removing a tool on an unchanged route changes the declared tools and misses the prompt cache once; a model switch already invalidates it. Searches and fetches from other providers' conversations bill the configured DeepSeek key. A deployment whose official route chats through a gateway sends the gateway's key to that gateway's origin with `/v1/tools` for the Search and Fetch APIs, and to the configured Messages endpoint, the official one by default, for legacy Messages search, where each search fails unless that endpoint accepts the key. The Search and Fetch APIs read the DeepSeek model routes' Loader entries through the model provider directory. -Unit tests pin each role: `packages/web/web/tests/web.spec.ts` covers which provider `searchReady()` and `fetchReady()` ask and their answer on selection failure, `packages/web/web-search-deepseek/tests/deepseek.spec.ts` and `packages/web/web-deepseek-api/tests/readiness.spec.ts` cover each credential case of `ready()` on the account route and on other routes, and `packages/web/tool-web/tests/tool-web.spec.ts` resolves step admission of both tools through the real prompt, tool, and executor services, including the `UNKNOWN_TOOL` refusal of a call in a step that withheld the tool. In real compositions, the shipped headless keyless smoke test and the `web-search-unconfigured` recorded-session snapshot, whose search key references are unset, pin the withheld search tool. Snapshot replays supply a placeholder DeepSeek key, so their tool lists do not depend on the runner's environment; with it, the `pi-ai-conversation-updates` conversation on a pi-ai route keeps `web_search`. +Unit tests pin each role: [web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) covers which provider `searchReady()` and `fetchReady()` ask and their answer on selection failure, [deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) and [credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) cover each credential case of `ready()` on the account route and on other routes, including readiness that matches each operation, and [tool-web.spec.ts](../../../../packages/web/tool-web/tests/tool-web.spec.ts) resolves step admission of both tools through the real prompt, tool, and executor services, including the `UNKNOWN_TOOL` refusal of a call in a step that withheld the tool. In real compositions, the shipped headless keyless smoke test and the `web-search-unconfigured` recorded-session snapshot, whose search key references are unset, pin the withheld search tool. Snapshot replays supply a placeholder DeepSeek key, so their tool lists do not depend on the runner's environment; with it, the `pi-ai-conversation-updates` conversation on a pi-ai route keeps `web_search`. diff --git a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.zh.md b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.zh.md index 9c4b0f6948..61f8acb6f8 100644 --- a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.zh.md @@ -6,17 +6,17 @@ Status: implemented ## Problem -`dsh-tool-web` 在启用搜索时总会注册 `web_search`,而 [web 能力 seam 决策](2026-06-24-web-capability-seam.zh.md)让它在选中的提供方无法运行时仍然可见。发行版的搜索提供方 `dsh-web-search-deepseek` 只对 `deepseek-account` 路由上的模型请求使用 DeepSeek 账号 token 鉴权;其他路由都需要已配置的 API 密钥。因此,API 密钥路由上没有搜索密钥的会话会获得一个每次调用都以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败的工具。同一 Host 中的会话可以使用不同路由,一个会话可以在请求之间切换路由,而 Web 应用按 agent preset 只注册一次 `web_search`,因此任何注册层面的决定都无法与每个请求相符。Web 搜索设置开关会同时选中 `dsh-web-deepseek-api` 的 Search 与 Fetch API,两个操作都需要这份凭据,因此同一会话还会获得一个每次调用都失败的 `web_fetch`。 +`dsh-tool-web` 在启用搜索时总会注册 `web_search`,而 [web 能力 seam 决策](2026-06-24-web-capability-seam.zh.md)让它在选中的提供方无法运行时仍然可见。DeepSeek Web 提供方只对 `deepseek-account` 路由上的模型请求使用 DeepSeek 账号 token 为操作鉴权;其他路由都需要已配置的 API 密钥。没有就绪检查时,API 密钥路由上没有密钥的会话会获得一个每次调用都以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败的工具。同一 Host 中的会话可以使用不同路由,一个会话可以在请求之间切换路由,而 Web 应用按 agent preset 只注册一次 `web_search`,因此任何注册层面的决定都无法与每个请求相符。`dsh-web-deepseek-api` 的 Fetch API 由用户与搜索提供方分别选择([提供方选择](2026-10-29-web-provider-selection.zh.md)),它需要同一份凭据,因此这样的会话选中它时,还会获得一个每次调用都失败的 `web_fetch`。 ## Decision web 能力 seam 的每个角色各负责逐步骤检查的一部分: - **Service Definition。** `WebSearchProvider` 与 `WebFetchProvider` 有一个可选的 `ready(llmProvider)`,回答为该对话路由上的模型请求发起的操作是否有可发送的凭据。它只检查配置,不发起网络调用。`ctx.web.searchReady(llmProvider?)` 与 `ctx.web.fetchReady(llmProvider?)` 按与 `search()`、`fetch()` 相同的规则解析提供方并返回其回答。没有 `ready()` 的提供方视为就绪;选择失败时回答 true,因为对应操作会把该失败报告为结构化 `WebError`。 -- **Service Provider。** `dsh-web-search-deepseek` 在以下任一情况下回答 true:路由为 `deepseek-account` 且 `ctx.deepseekAccount.resolveToken()` 为配置的搜索端点返回 token;配置段带有字面 `apiKey`;或 `ctx.credentials.describe()` 报告 `apiKeyEnv` 引用已配置(没有该服务时,启动环境中存在该变量)。它按 `search()` 解析凭据的顺序检查凭据,读取账号 token 或凭据存储失败时回答 false,因为 `search()` 会在该次读取上失败,而不会尝试下一种凭据。`dsh-web-deepseek-api` 的 Search 与 Fetch API 提供方共用一份凭据,并对其 API 基础地址以同样方式回答;它们的传输层重复了这些检查,因此两个包互不依赖。提供方不点名任何工具或提示词段落。 +- **Service Provider。** 每个 DeepSeek 提供方都按其操作解析凭据的顺序检查凭据。`dsh-web-search-deepseek` 在 `deepseek-account` 路由上检查 `ctx.deepseekAccount.resolveToken()` 是否为其 Messages 端点返回 token,然后检查其字面 `apiKey` 或 `apiKeyEnv` 引用。`dsh-web-deepseek-api` 的 Search 与 Fetch API 提供方解析与操作相同的方案([凭据解析](2026-10-29-web-provider-selection.zh.md)):在 YAML 中设置了单独字段的能力只检查其 `*ApiKeyEnv` 下的密钥;否则,在账号路由上且账号模型路由已挂载时,该路由 `/v1/tools` 基础地址的 token 算数,然后是 `deepseek-official` 路由的密钥,没有该路由时为 `DEEPSEEK_API_KEY`。`ctx.credentials.describe()` 报告其引用已配置时(没有该服务时,启动环境中存在该变量),密钥即算存在;这些存在性检查由[共享凭据函数](../../../../packages/llm/llm-deepseek/src/credential.ts)执行。读取账号 token 或凭据存储失败时回答 false,因为操作会在该次读取上失败,而不会尝试下一种凭据。提供方不点名任何工具或提示词段落。 - **Consumer。** `dsh-tool-web` 注册 `web_search` 与 `web_fetch` 时各带一个[步骤激活条件](../../../../packages/core/tools/README.zh.md#conditional-tools),以该步骤模型选择的路由询问 `ctx.web.searchReady()` 与 `ctx.web.fetchReady()`。agent loop 在组装该步骤之前解析各条件,因此声明的工具、`tool:web_search` 与 `tool:web_fetch` 提示词段落、PTC 程序绑定与派发都读取同一个准入集合。 -路由只决定是否在 API 密钥之前检查账号 token,这只发生在 `deepseek-account` 路由上;API 密钥在任何路由上都算数,因此只要配置了 DeepSeek 密钥,使用其他提供方模型的对话也会获得 DeepSeek Web 工具。提供方不比较聊天路由的地址与自己的端点:即使官方路由经网关聊天,每次操作也把配置的密钥(默认 `DEEPSEEK_API_KEY`)发往配置的端点。 +路由只决定是否先检查账号 token 再检查 API 密钥,这发生在 `deepseek-account` 路由上;API 密钥在每条路由上都算数,因此只要配置了 DeepSeek 密钥,使用其他提供方模型的对话也会获得 DeepSeek Web 工具。在 YAML 中设置了单独字段的 Search 或 Fetch API 能力不看路由,恰在其自身密钥已配置时就绪。 只看凭据是否存在:不读取 API 密钥的值,也不访问任何端点,因此错误的密钥,或不提供原生 `web_search_20250305` 服务器工具的端点,仍会让该工具保持准入。未准入某个 Web 工具的步骤也会以 `UNKNOWN_TOOL` 拒绝对它的调用,因此模型重复先前的调用也到达不了任何提供方;在模型步骤之外发起的操作在运行时解析凭据,没有凭据时以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败。 @@ -24,7 +24,7 @@ web 能力 seam 的每个角色各负责逐步骤检查的一部分: ## Alternatives considered -**让 DeepSeek 提供方在自己的 assemble 监听器中移除 `web_search`。** seam 保持不变,但提供方因此要点名另一个包的工具、提示词段落与 PTC 传输工具;而且即使部署选择了另一个搜索成功的提供方(例如 Exa),它也会移除该工具。 +**让 DeepSeek 提供方在自己的 assemble 监听器中移除 `web_search`。** seam 保持不变,但提供方因此要点名另一个包的工具、提示词段落与 PTC 传输工具;而且即使部署选择了另一个搜索成功的提供方(例如插件注册的提供方),它也会移除该工具。 **扩展 `available()`。** 它是同步的且不接收请求,而这项检查要读取异步凭据存储,并取决于请求的路由。依赖路由的 `available()` 还会让提供方选择取决于对话。 @@ -36,9 +36,9 @@ web 能力 seam 的每个角色各负责逐步骤检查的一部分: **只在 DeepSeek 账号路由上提供搜索。** 账号 token 只对官方端点签发,因此网关的密钥不可能到达 DeepSeek。但所有发行 profile 的默认路由都是 `deepseek-official`,搜索会因此默认关闭,连官方 API 密钥的情况也一并关闭。 -**除非搜索有自己的密钥,否则不向其他提供方的对话提供这些工具。** 其他模型对话中的搜索会由 DeepSeek 密钥计费,但 DeepSeek 搜索本就是该密钥付费使用的服务之一,配置了它的用户也期望无论用哪个模型对话,搜索都能用。Web 搜索设置页把密钥存在 `apiKeyEnv` 指向的引用下,默认是聊天路由也在读取的 `DEEPSEEK_API_KEY`,因此在那里输入的密钥永远无法服务其他模型;再加一个开放这些路由的 `routes` 配置项,只会让用户多找一样东西。 +**除非搜索有自己的密钥,否则不向其他提供方的对话提供这些工具。** 其他模型对话中的搜索会由 DeepSeek 密钥计费,但 DeepSeek 搜索本就是该密钥付费使用的服务之一,配置了它的用户也期望无论用哪个模型对话,搜索都能用。Search 与 Fetch API 发送聊天路由自己的密钥,设置页把旧版 Messages 搜索的密钥存在 `apiKeyEnv` 指向的引用下,默认是聊天路由也在读取的 `DEEPSEEK_API_KEY`,因此撤下工具会让已配置的密钥无法服务其他模型;再加一个开放这些路由的 `routes` 配置项,只会让用户多找一样东西。 -**比较官方聊天路由的端点与搜索端点。** 两个端点不同源时撤下工具或不发送密钥,能让网关的密钥远离 DeepSeek,但提供方得自己找出聊天路由的配置。取第一条激活的 `dsh-llm-deepseek-api-key` Loader 行时,如果组合隔离了多个实例,回答的可能是无关的实例,从而关掉正确的路由,或放过网关的密钥;只有路由的所有者知道哪个端点服务某个请求,而在 `dsh-llm` 加地址字段会让它经 Remote 方法 `listProviders()` 进入浏览器。这项检查还会让经代理或开发端点对话、而密钥能被官方搜索端点接受的用户失去搜索。不做这项检查时,网关用户的搜索会在配置的端点上失败。 +**比较官方聊天路由的端点与 Web 端点,二者不同时撤下工具。** 这能让网关的密钥远离 DeepSeek,但也会让经代理或开发端点对话、而密钥能被 Web 端点接受的用户失去网页工具。Search 与 Fetch API 改为从其所发送密钥的路由的 origin 推导端点,因此密钥与端点来自同一条路由;旧版 Messages 搜索保留自己的端点,网关用户的搜索会在那里失败。 **保持工具可见,在执行时失败。** 模型会把一次调用花在无法鉴权的搜索上,并且每次尝试都会重复失败。 @@ -46,6 +46,6 @@ web 能力 seam 的每个角色各负责逐步骤检查的一部分: ## Consequences -`dsh-web` 增加以下公共成员:两个提供方接口上可选的 `ready()`、`WebRuntime.searchReady()` 与 `WebRuntime.fetchReady()`。每个就绪方法都是其唯一消费方所需的最小查询,这正是[观测接口决策](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md)允许状态查询回归的方式;它们既不报告可用性,也不报告选择失败。Exa、Perplexity 与 HTTP 抓取提供方没有实现 `ready()`,其行为不变;只要开关让 HTTP 抓取保持选中,`web_fetch` 就始终准入。未准入某个 Web 工具的步骤,其 PTC 程序绑定中也没有它。无法提供原生服务器工具的端点仍会让工具保持准入,并使每次调用失败。在路由不变时移除工具会改变声明的工具,并使提示词缓存失效一次;模型切换本身已经使缓存失效。其他提供方对话中的搜索与抓取由配置的 DeepSeek 密钥计费;官方路由经网关对话的部署会把网关的密钥发往配置的端点(默认是官方端点),除非该端点接受这个密钥,否则每次操作都会失败。DeepSeek Web 包不读取 Loader 的行,也不依赖任何 LLM 包。 +`dsh-web` 增加以下公共成员:两个提供方接口上可选的 `ready()`、`WebRuntime.searchReady()` 与 `WebRuntime.fetchReady()`。每个就绪方法都是其唯一消费方所需的最小查询,这正是[观测接口决策](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md)允许状态查询回归的方式;它们既不报告可用性,也不报告选择失败。HTTP 抓取提供方没有实现 `ready()`,因此只要选中 HTTP 抓取,`web_fetch` 就始终准入。未准入某个 Web 工具的步骤,其 PTC 程序绑定中也没有它。无法提供原生服务器工具的端点仍会让工具保持准入,并使每次调用失败。在路由不变时移除工具会改变声明的工具,并使提示词缓存失效一次;模型切换本身已经使缓存失效。其他提供方对话中的搜索与抓取由配置的 DeepSeek 密钥计费。官方路由经网关对话的部署,对 Search 与 Fetch API 会把网关的密钥发往该网关 origin 加 `/v1/tools`,对旧版 Messages 搜索则发往配置的 Messages 端点(默认是官方端点),除非该端点接受这个密钥,否则每次搜索都会失败。Search 与 Fetch API 通过模型提供方目录读取 DeepSeek 模型路由的 Loader 条目。 -单元测试分别固定每个角色:`packages/web/web/tests/web.spec.ts` 覆盖 `searchReady()` 与 `fetchReady()` 询问哪个提供方以及选择失败时的回答,`packages/web/web-search-deepseek/tests/deepseek.spec.ts` 与 `packages/web/web-deepseek-api/tests/readiness.spec.ts` 覆盖 `ready()` 在账号路由与其他路由上的每种凭据情况,`packages/web/tool-web/tests/tool-web.spec.ts` 通过真实的提示词、工具与执行器服务解析两个工具的步骤准入,包括在撤下该工具的步骤中调用它时以 `UNKNOWN_TOOL` 拒绝。在真实组合中,发行版 headless 无密钥冒烟测试与 `web-search-unconfigured` 录制会话快照的搜索密钥引用都未设置,二者固定了搜索工具的撤下行为。快照回放提供占位 DeepSeek 密钥,因此其工具列表不依赖运行环境;借助该密钥,`pi-ai-conversation-updates` 中 pi-ai 路由上的对话保留 `web_search`。 +单元测试分别固定每个角色:[web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) 覆盖 `searchReady()` 与 `fetchReady()` 询问哪个提供方以及选择失败时的回答,[deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) 与 [credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) 覆盖 `ready()` 在账号路由与其他路由上的每种凭据情况,包括就绪判断与每次操作一致,[tool-web.spec.ts](../../../../packages/web/tool-web/tests/tool-web.spec.ts) 通过真实的提示词、工具与执行器服务解析两个工具的步骤准入,包括在撤下该工具的步骤中调用它时以 `UNKNOWN_TOOL` 拒绝。在真实组合中,发行版 headless 无密钥冒烟测试与 `web-search-unconfigured` 录制会话快照的搜索密钥引用都未设置,二者固定了搜索工具的撤下行为。快照回放提供占位 DeepSeek 密钥,因此其工具列表不依赖运行环境;借助该密钥,`pi-ai-conversation-updates` 中 pi-ai 路由上的对话保留 `web_search`。 diff --git a/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.i18n.yaml new file mode 100644 index 0000000000..f54423c675 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.i18n.yaml @@ -0,0 +1,37 @@ +# Bilingual-pair consistency record for 2026-10-29-web-provider-selection.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-29-web-provider-selection.md +/agent-note-web-provider-selection-independent-menus-over-registered-providers: + en: 2a2fa1def8a81625 + zh: 317d8b115f34e677 +/agent-note-web-provider-selection-independent-menus-over-registered-providers/problem: + en: 3f5dacf86015cebb + zh: cf0d5bceb9941554 +/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision: + en: 0682ebef17f6a5e7 + zh: 6ea399459262eb0c +/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/registered-providers-are-observable-availability-is-not: + en: 38ce796dea91fa35 + zh: d312a9cd29fd5cc9 +/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/independent-selection: + en: 9d68ffca5c8be681 + zh: 36bb661f98de0ebb +/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/credential-resolution: + en: 32f59c35d71a4832 + zh: e9288313bb18b78d +/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/defaults-and-stored-selections: + en: 5fcd3319fbceed1d + zh: 20771646b9c36fa6 +/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/legacy-messages-search: + en: 2c7c84f8fa1e8745 + zh: 5d0244e4f17ba12a +/agent-note-web-provider-selection-independent-menus-over-registered-providers/alternatives-considered: + en: 0c227e270894cd71 + zh: 114484a3e69f5c0a +/agent-note-web-provider-selection-independent-menus-over-registered-providers/consequences: + en: 50f046bf81a7783d + zh: 5c0e27bdcaf9f12e +/agent-note-web-provider-selection-independent-menus-over-registered-providers/verification: + en: 26ade0695094e9bc + zh: 7b265c43b1886da5 diff --git a/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.md b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.md new file mode 100644 index 0000000000..1d37c118ec --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.md @@ -0,0 +1,85 @@ +# Agent Note: Web provider selection - independent menus over registered providers + +Status: implemented + +English | [中文](2026-10-29-web-provider-selection.zh.md) + +## Problem + +The web service selects search and fetch providers through two independent fields, `web.searchProvider` and `web.fetchProvider`. A settings control that writes both at once ties fetch billing and target access policy to the search choice, and a control that offers only the DeepSeek choices it knows cannot select a provider that another plugin registers. A picker over registered providers needs the Host to report them, and the [web capability seam](2026-06-24-web-capability-seam.md) has no availability observation for it to reuse. Messages search starts an auxiliary model request for each query, whereas the [Search API](../feature/2026-10-15-deepseek-search-fetch-api.md) returns structured sources directly. The Search and Fetch APIs also need a DeepSeek credential, and a credential setting of their own makes every user reconcile it with the model provider they chat with. + +## Decision + +Users choose search and fetch providers independently from the Host's registered provider lists. The shipped composition selects the Search API for search and local HTTP fetch for fetch, and the DeepSeek Search and Fetch APIs follow the conversation's DeepSeek credential. + +### Registered providers are observable; availability is not + +`WebSearchProvider` and `WebFetchProvider` carry an optional `title`, the provider's display name, and an optional localized `description` of what the provider suits and what it costs or cannot do. `ctx.web.listProviders()` returns `{ search, fetch }`, each a registration-ordered list of `{ id, title?, description? }` entries, and the `web` Remote namespace exposes it as `web/listProviders`. Every registration and disposal emits the payload-free `web/providers-changed` event after the registry changes; the web service contains listener failures, so an observer cannot veto a registry change. [dsh-api-remotes](../../../../packages/api/remotes/README.md) mounts the namespace and forwards the event, and Clients re-read the list after each notification and reconnect. + +The lists include providers whose `available()` answers false. A picker shows what can be pinned, including a provider whose credential the user has not configured yet, whereas availability depends on per-request facts such as the Session's model route and credential state. Availability therefore stays an execution result: `search()` and `fetch()` resolve the configured provider at call time and throw the structured `WebError`, as the seam note records. The tool layer still never enumerates providers. + +### Independent selection + +The Web search and fetch page writes `web.searchProvider` from its Search menu and `web.fetchProvider` from its Fetch menu. Each menu lists every registered provider for its capability by `title` (or `id` when `title` is absent), so plugin providers appear without page changes, and a selected id that the Host does not register stays visible as such. Official providers register no `title`; the page names them from its own locale dictionary and lists them first in a fixed order: the Search API (`deepseek-search-api`) followed by Messages search (`deepseek-official`) for search, and local HTTP fetch (`http`) followed by the Fetch API (`deepseek-fetch-api`) for fetch. Other providers follow in Host registration order. Selecting the deployment's composed provider clears the stored override instead of storing a duplicate value. The [settings page README](../../../../packages/client/ui-settings-web-search/README.md) owns the page's controls. + +Every registered provider is listed; a provider has no flag that keeps it out of the menus. This note supersedes the [Search and Fetch API decision](../feature/2026-10-15-deepseek-search-fetch-api.md)'s rejection of listing every registered provider in a settings dropdown. Independent menus must offer providers that other plugins register, and the display metadata that listing needs is only the optional `title` and `description`. The description lives on the registration rather than in the page's dictionaries so that official and plugin providers explain themselves through one field, and a new provider needs no page change to describe its trade-offs. The rejected generic editor still stands: the page edits only the DeepSeek providers' settings, and other providers keep their own settings pages. + +The Search and Fetch APIs read one `web-deepseek-api` namespace, and each resolves its own credential and endpoint, as the next section records. Messages search reads `web-search-deepseek` for its independent endpoint and key reference. + +### Credential resolution + +Search and Fetch follow the DeepSeek credential of the conversation, and the settings page shows no credential control for them. The conversation's route is the `provider` of the initiating Session's latest request context, which account sign-out also reads; `ready()` uses the route of the model request being prepared, so readiness and execution resolve the same plan. + +On the `deepseek-account` route, with an active, valid `@deepseek-ai/dsh-llm-deepseek-account` Loader entry at the entry root that the model provider directory lists, the operation sends only the account token that `ctx.deepseekAccount` releases for the dispatch endpoint under that route's origin with `/v1/tools`; an HTTP 401 invalidates that token. Every other operation, including one in an account conversation without a sign-in or a token, sends the key under the `apiKeyEnv` of the active `@deepseek-ai/dsh-llm-deepseek-api-key` entry for `deepseek-official`, resolved with that package's own resolver (including `DEEPSEEK_BASE_URL`), to the origin of that route's base URL with `/v1/tools`; without that entry, `DEEPSEEK_API_KEY` goes to `https://api.deepseek.com/v1/tools`. Sending the API key in a signed-out account conversation keeps web tools available there. A user with only `DEEPSEEK_API_KEY`, including every headless, SDK, and ACP run, therefore searches and fetches by default, and a gateway or internal deployment set through the model provider's base URL serves web access at the same origin. A missing key makes the capability not ready, and an operation fails with `WEB_PROVIDER_CREDENTIAL_MISSING` and guidance naming the Models page, the reference, and the account sign-in. A mounted route entry whose settings its resolver rejects is not treated as absent: an operation that needs that route is not ready and fails with `WEB_PROVIDER_MISCONFIGURED` without reading a credential or sending the request, so a broken gateway setting never redirects requests to the official endpoint. + +A capability whose YAML sets a non-empty `searchBaseURL` or `searchApiKeyEnv` (`fetchBaseURL` or `fetchApiKeyEnv` for Fetch) instead always sends the key under its `*ApiKeyEnv` (`DEEPSEEK_API_KEY` when unset) to its `*BaseURL` (the official base when unset) and never the account token, so an intranet or third-party endpoint receives only the key chosen for it. Each capability has its own fields because a deployment can serve search and fetch from different gateways. These fields are YAML-only, and an empty value counts as unset. Stored values of fields outside the schema, including `baseURL`, `apiKeyEnv`, and `apiKey`, load without schema errors and have no effect. + +The [credential helpers](../../../../packages/llm/llm-deepseek/src/credential.ts) of `@deepseek-ai/dsh-llm-deepseek` read and validate API keys, check key presence, and release account tokens for the DeepSeek model routes, Messages search, and the Search and Fetch APIs, so every DeepSeek request reads the credential store the same way; the account model route, Messages search, and the Search and Fetch APIs also invalidate the token that an HTTP 401 rejects. Shipped profiles need no per-profile credential override, because the API-key path works wherever `DEEPSEEK_API_KEY` or the DeepSeek API-key route has a key. + +### Defaults and stored selections + +The [base composition](../../../../packages/bundle/base/cordis.patch.yml) sets `searchProvider: deepseek-search-api` and `fetchProvider: http`. Search defaults to the Search API because it returns sources without Harness starting an auxiliary model request. Fetch defaults to local HTTP fetch because it has no per-request API charge, applies Harness's own URL validation and resource limits, and reaches intranet and local addresses. The Fetch API remains one menu choice away. + +A stored selection, whether in the settings document or in a deployment's own composition, wins over the shipped default, and an upgrade does not rewrite it. A selection is never replaced at execution: when the selected provider is missing, unavailable, or fails, the operation fails with its structured error and does not fall back to another search or fetch provider. A fallback would send the query and credential to an endpoint the user did not choose and change billing without notice. + +### Legacy Messages search + +The DeepSeek Messages search provider remains available as **DeepSeek (legacy)**. A stored `web.searchProvider: deepseek-official` (or `DSH_WEB_SEARCH_PROVIDER=deepseek-official`) selects it, so users whose gateways serve Messages search but not `/v1/tools` can retain their working provider. + +The Messages provider owns and writes `web/deepseek-search-llm-request`. The declaration and payload retain compatibility with released Sessions; the Search and Fetch API package neither declares nor writes this auxiliary event. + +## Alternatives considered + +**Keep one switch for both APIs.** It lets Fetch API adoption need no configuration edit, but it ties fetch billing and target access policy to the search choice, and it cannot express a provider that another plugin registers. + +**Filter the lists by availability, or add availability to each entry.** A filtered list hides a provider while its credential is missing, right when the user needs to choose and configure it. Availability also depends on the request's model route and credential state, so a listed boolean could disagree with execution. The archived [observation-surface decision](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) allows only the smallest signal a consumer reads; the menus read registration only. + +**Keep a fixed provider catalog in the Client.** The Client would show ids the Host does not register as normal choices and would never show a provider that a plugin registers at runtime. + +**Carry the provider list in the change event.** A payload-free event leaves one read path for initial load, notifications, and reconnects, so the Client never has to order an event payload against a concurrent read. + +**Keep display names in the Client or in a separate Host registry.** The registering plugin knows its provider's name; a separate map would let a provider and its label register or dispose independently. + +**Fall back to another provider when the selected one fails.** A fallback hides gateway and credential misconfiguration, sends the query to a second service, and changes what the request costs without the user choosing it. + +**Remove Messages search and let a stored `deepseek-official` select the Search API.** Gateways can support Messages search without Search API routes. Retaining the legacy provider preserves that choice instead of changing its protocol and billing. + +**Default fetch to the Fetch API as well.** It adds a per-request charge to every fetch and delegates page retrieval and target access policy to the service, which fetches from its own side rather than from the user's machine. + +**Choose a credential source per capability on the settings page**, from a menu listing the configured DeepSeek model providers plus a custom key and base URL, without fallback between sources. The visible setting would state the whole rule, but the configuration surface is too large for users to understand: each capability adds a credential menu that the user must keep in step with the model provider they chat with, and a signed-in user chatting on the account route gets no web tools until they change it. Following the conversation's credential needs no setting, and the YAML-only own fields cover endpoints that differ from the model provider. + +**Share one custom key and base URL between Search and Fetch.** It cannot express a deployment that serves the two APIs from different gateways. + +**Fail an account conversation without a sign-in instead of sending the API key.** It would keep the account route from ever billing the API key, but signed-out account conversations would lose web tools even when a DeepSeek API key is configured. + +**Send the account token whenever the user is signed in, whatever the conversation's route.** It would bill web access to the account during conversations on the API-key route or on another provider, which the user did not choose for them. + +## Consequences + +Users without a stored search selection search through the Search API: their queries go to the Search endpoint the conversation's DeepSeek credential selects, cost a Search API request each, and send up to 128 characters of the latest human prompt as `ori_question` unless `sendOriginalQuestion` is off. Deployments whose gateway has no `/v1/tools` routes get search failures until they select Messages search or set `searchBaseURL` to a base that serves them. A stored `deepseek-official` selection uses Messages search. Users without a stored fetch selection fetch through local HTTP fetch. A signed-in user without an API key has DeepSeek web search only in conversations on the account route; the [upgrade guide](../../../../docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.md) lists each affected setup. + +A plugin provider, including one written in creator mode, appears in the matching menu as soon as it registers, under its `title`. The menus can offer a provider that cannot run for the current request; the failure appears when a search or fetch runs. Each registry change costs one forwarded event and one list read in each Client that observes it. + +## Verification + +[web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) covers list contents and order, titles, events after registration and disposal, no event for a rejected duplicate, and contained listener failures. [deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) covers the independent Messages provider, and [credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) covers, for both capabilities, the account token on the account route and its invalidation after HTTP 401, the API-key fallback without a sign-in, the route key and endpoint in official, non-DeepSeek, and Session-less conversations, `DEEPSEEK_API_KEY` and the official base without that route, YAML-only own fields that never send the token, independent capabilities, readiness matching each operation, and ignored removed fields; [composition.spec.ts](../../../../packages/web/web-deepseek-api/tests/composition.spec.ts) boots the real model routes through the Loader. [remote-events.host.spec.ts](../../../../packages/api/remotes/tests/remote-events.host.spec.ts) covers event forwarding, and [base.spec.ts](../../../../packages/bundle/base/tests/base.spec.ts) pins the shipped defaults. The settings page's controller and card specs and the [plugin configuration browser scenario](../../../../apps/web/tests/plugin-config.e2e.ts) cover menu contents, independent Search and Fetch selection, the absence of credential controls for the DeepSeek APIs, and the settings each provider keeps. diff --git a/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.zh.md b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.zh.md new file mode 100644 index 0000000000..7b9b5a75da --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.zh.md @@ -0,0 +1,85 @@ +# Agent Note: Web 提供方选择——基于已注册提供方的独立下拉框 + +Status: implemented + +[English](2026-10-29-web-provider-selection.md) | 中文 + +## 问题 + +Web 服务通过 `web.searchProvider` 和 `web.fetchProvider` 两个独立字段分别选择搜索与抓取提供方。用一个设置控件同时写入两者,会把抓取的计费和目标访问策略绑在搜索选择上;只提供已知 DeepSeek 选项的控件,也无法选用其他插件注册的提供方。基于已注册提供方的选择器需要 Host 报告这些提供方,而 [Web 能力 seam](2026-06-24-web-capability-seam.zh.md) 没有可供复用的可用性观察接口。Messages 搜索每次查询都会发起一次辅助模型请求,而 [Search API](../feature/2026-10-15-deepseek-search-fetch-api.zh.md) 能直接返回结构化来源。Search 与 Fetch API 还需要 DeepSeek 凭据;为它们单独设置凭据,会让每个用户都要把它与自己对话所用的模型提供方对齐。 + +## 决策 + +用户从 Host 已注册的提供方列表中分别选择搜索和抓取提供方。内置组合将搜索设为 Search API,将抓取设为本地 HTTP 抓取;DeepSeek Search 与 Fetch API 跟随会话的 DeepSeek 凭据。 + +### 已注册的提供方可观察,可用性不可观察 + +`WebSearchProvider` 和 `WebFetchProvider` 带有可选的 `title`(提供方的显示名称)和可选的多语言 `description`(说明该提供方适合什么、有什么代价或做不到什么)。`ctx.web.listProviders()` 返回 `{ search, fetch }`,二者均为按注册顺序排列的 `{ id, title?, description? }` 列表,`web` Remote namespace 将其暴露为 `web/listProviders`。每次注册和注销都会在注册表变更后触发无载荷的 `web/providers-changed` 事件;Web 服务会隔离监听器异常,因此观察者无法阻断注册表变更。[dsh-api-remotes](../../../../packages/api/remotes/README.zh.md) 挂载该 namespace 并转发该事件,Client 在收到通知和重连后重新读取列表。 + +列表包含 `available()` 返回 false 的提供方。选择器展示的是可固定选用的项,包括用户尚未配置凭据的提供方;而可用性取决于会话的模型路由、凭据状态等单次请求条件。因此可用性仍是执行期结果:`search()` 和 `fetch()` 在调用时解析所配置的提供方并抛出结构化 `WebError`,与 seam 记录一致。工具层仍从不枚举提供方。 + +### 分别选择 + +「网页搜索与获取」页由 Search 下拉框写入 `web.searchProvider`,由 Fetch 下拉框写入 `web.fetchProvider`。每个下拉框以 `title`(缺省时用 `id`)列出该能力的所有已注册提供方,因此插件提供方无需修改页面即可出现,且 Host 未注册的已选 id 仍会照常显示。官方提供方不注册 `title`,由页面用自己的语言字典命名,并按固定顺序排在最前:搜索依次为 Search API(`deepseek-search-api`)与 Messages 搜索(`deepseek-official`),抓取依次为本地 HTTP 抓取(`http`)和 Fetch API(`deepseek-fetch-api`)。其他提供方按 Host 注册顺序排列。选择部署组合的默认提供方会清除已保存的覆盖值,而非冗余保存一份副本。[设置页 README](../../../../packages/client/ui-settings-web-search/README.zh.md) 负责页面控件说明。 + +所有已注册的提供方都会列出;提供方没有可以把自己排除在下拉框之外的标志。本 Note 取代了 [Search 与 Fetch API 决策](../feature/2026-10-15-deepseek-search-fetch-api.zh.md)对「在设置页用下拉框列出所有已注册的提供方」的否决。独立的下拉框必须提供其他插件注册的提供方,而列出提供方所需的展示元数据只是可选的 `title` 和 `description`。说明放在注册信息里而不是页面词典里,这样官方与插件提供方都通过同一个字段说明自己,新提供方无需修改页面即可说明其取舍。被否决的通用编辑表单仍然成立:该页面只编辑 DeepSeek 提供方的设置,其他提供方保留各自的设置页。 + +Search 与 Fetch API 读取同一个 `web-deepseek-api` namespace,并各自解析自己的凭据和端点,见下一节。Messages 搜索从 `web-search-deepseek` 读取独立的端点和密钥引用。 + +### 凭据解析 + +Search 与 Fetch 跟随会话的 DeepSeek 凭据,设置页不为它们显示凭据控件。会话的路由是发起会话最近一次请求上下文的 `provider`,账号退出登录读取的也是它;`ready()` 使用正在准备的模型请求的路由,因此就绪判断与执行解析出相同的方案。 + +在 `deepseek-account` 路由上,若模型提供方目录列出了入口根部活动且有效的 `@deepseek-ai/dsh-llm-deepseek-account` Loader 条目,操作只发送 `ctx.deepseekAccount` 为该路由 origin 加 `/v1/tools` 下的请求端点给出的账号令牌;HTTP 401 会作废该令牌。其他所有操作,包括账号会话中没有登录或没有令牌的操作,都把 `deepseek-official` 的活动 `@deepseek-ai/dsh-llm-deepseek-api-key` 条目的 `apiKeyEnv` 下的密钥(用该包自己的解析函数解析,包括 `DEEPSEEK_BASE_URL`)发往该路由基础地址的 origin 加 `/v1/tools`;没有该条目时,把 `DEEPSEEK_API_KEY` 发往 `https://api.deepseek.com/v1/tools`。未登录的账号会话发送 API 密钥,网页工具因此在其中仍然可用。因此只有 `DEEPSEEK_API_KEY` 的用户(包括所有 headless、SDK 与 ACP 运行)默认即可搜索和抓取,通过模型提供方基础地址设置的网关或内部部署也在同一 origin 上提供 Web 访问。缺少密钥时,该能力未就绪,操作以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败,指引信息点名模型页面、该引用和账号登录。已挂载、但其解析函数拒绝其设置的路由条目不视为不存在:需要该路由的操作未就绪,并以 `WEB_PROVIDER_MISCONFIGURED` 失败,不读取凭据、不发送请求,因此网关设置出错时请求不会被改发到官方端点。 + +在 YAML 中设置了非空 `searchBaseURL` 或 `searchApiKeyEnv`(Fetch 为 `fetchBaseURL` 或 `fetchApiKeyEnv`)的能力,则始终把其 `*ApiKeyEnv`(未设置时为 `DEEPSEEK_API_KEY`)下的密钥发往其 `*BaseURL`(未设置时为官方基础地址),从不发送账号令牌,因此内网或第三方端点只收到为它选择的密钥。每个能力有自己的字段,因为一个部署可以由不同网关提供搜索和抓取。这些字段只能在 YAML 中设置,空值视为未设置。schema 之外字段的已保存值,包括 `baseURL`、`apiKeyEnv` 和 `apiKey`,可以加载而不产生 schema 错误,且不生效。 + +`@deepseek-ai/dsh-llm-deepseek` 的[凭据函数](../../../../packages/llm/llm-deepseek/src/credential.ts)为 DeepSeek 模型路由、Messages 搜索以及 Search 与 Fetch API 读取并校验 API 密钥、检查密钥是否存在、取得账号令牌,因此每个 DeepSeek 请求都以同一方式读取凭据库;账号模型路由、Messages 搜索以及 Search 与 Fetch API 还会作废被 HTTP 401 拒绝的令牌。内置配置不需要按 profile 覆盖凭据,因为只要 `DEEPSEEK_API_KEY` 或 DeepSeek API 密钥路由有密钥,API 密钥路径就能工作。 + +### 默认值与已保存的选择 + +[base 组合](../../../../packages/bundle/base/cordis.patch.yml)设置 `searchProvider: deepseek-search-api` 和 `fetchProvider: http`。搜索默认使用 Search API,因为它返回来源时无需 Harness 发起辅助模型请求。抓取默认使用本地 HTTP 抓取,因为它没有按次的 API 费用,执行 Harness 自身的 URL 校验和资源限制,并且可以访问内网和本地地址。Fetch API 仍可在下拉框中直接选择。 + +已保存的选择——无论位于设置文档还是部署自身的组合中——优先于内置默认值,升级不会改写它。执行时从不替换选择:所选提供方缺失、不可用或失败时,操作以结构化错误失败,不会回退到其他搜索或抓取提供方。回退会把查询和凭据发往用户未选择的端点,并在不提示的情况下改变计费。 + +### 旧版 Messages 搜索 + +DeepSeek Messages 搜索提供方仍以 **DeepSeek(旧版)**提供。已保存的 `web.searchProvider: deepseek-official`(或 `DSH_WEB_SEARCH_PROVIDER=deepseek-official`)会选中它,因此网关支持 Messages 搜索但不提供 `/v1/tools` 的用户可以保留可用的提供方。 + +Messages 提供方负责声明并写入 `web/deepseek-search-llm-request`。声明和载荷保持与已发布会话兼容;Search 与 Fetch API 包既不声明也不写入这一辅助事件。 + +## 考虑过的替代方案 + +**保留一个同时切换两个 API 的开关。** 它让使用 Fetch API 无需修改配置,但把抓取的计费和目标访问策略绑在搜索选择上,也无法表达其他插件注册的提供方。 + +**按可用性过滤列表,或在每个条目中加入可用性。** 过滤后的列表恰好会在凭据缺失时隐藏提供方,而这正是用户需要选择并配置它的时候。可用性还取决于请求的模型路由和凭据状态,列表中的布尔值可能与实际执行结果不一致。已归档的[观察面决策](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md)只允许引入消费方实际读取的最小信号;下拉框只读取注册情况。 + +**在 Client 中维护固定的提供方目录。** Client 会把 Host 未注册的 id 当作正常选项展示,且永远无法展示插件在运行时注册的提供方。 + +**在变更事件中携带提供方列表。** 无载荷事件让初次加载、变更通知和断线重连共用同一条读取路径,Client 无需为事件载荷与并发读取结果排序。 + +**把显示名称放在 Client 或单独的 Host 注册表中。** 注册插件最清楚自身提供方的名称;拆成独立映射会让提供方与其标签各自独立注册或注销。 + +**所选提供方失败时回退到其他提供方。** 回退会掩盖网关和凭据的配置错误,将查询发往第二个服务,并在未经用户选择的情况下改变请求的费用。 + +**移除 Messages 搜索,让已保存的 `deepseek-official` 选中 Search API。** 网关可能支持 Messages 搜索,却没有 Search API 路由。保留旧版提供方可以保留这一选择,而不改变其协议和计费。 + +**抓取也默认使用 Fetch API。** 它会给每次抓取增加按次费用,并把页面抓取和目标访问策略交给服务端,由服务端而不是用户的机器发起抓取。 + +**在设置页为每个能力选择一种凭据来源**,下拉框列出已配置的 DeepSeek 模型提供方,外加自定义密钥和基础地址,来源之间不回退。这样可见的设置就能说明全部规则,但配置面过大,用户难以理解:每个能力都多出一个凭据下拉框,用户必须让它与自己对话所用的模型提供方保持一致;在账号路由上对话的已登录用户在修改设置前没有网页工具。跟随会话的凭据不需要任何设置,与模型提供方不同的端点由只能在 YAML 中设置的单独字段覆盖。 + +**Search 与 Fetch 共用一个自定义密钥和基础地址。** 它无法表达由不同网关分别提供两个 API 的部署。 + +**账号会话未登录时直接失败,而不是发送 API 密钥。** 这样账号路由永远不会消耗 API 密钥,但即使配置了 DeepSeek API 密钥,未登录的账号会话也会失去网页工具。 + +**只要用户已登录就发送账号令牌,不论会话的路由。** 这会让在 API 密钥路由或其他提供方上进行的会话把 Web 访问记到账号上,而用户并没有为这些会话这样选择。 + +## 影响 + +没有已保存搜索选择的用户通过 Search API 搜索:查询发往会话的 DeepSeek 凭据所选的 Search 端点,每次计为一次 Search API 请求,并且除非关闭 `sendOriginalQuestion`,还会把最近一条用户提示的至多 128 个字符作为 `ori_question` 发送。网关没有 `/v1/tools` 路由的部署会遇到搜索失败,直到选择 Messages 搜索,或把 `searchBaseURL` 设为提供这些路由的基础地址。已保存的 `deepseek-official` 选择使用 Messages 搜索。没有已保存抓取选择的用户通过本地 HTTP 抓取。已登录但没有 API 密钥的用户只在账号路由上的会话中可以使用 DeepSeek 网页搜索;[升级指南](../../../../docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.zh.md)列出了每种受影响的配置。 + +插件提供方(包括在创作模式下编写的提供方)一经注册,就会以其 `title` 出现在对应的下拉框中。下拉框可能提供在当前请求中无法运行的提供方;失败会在执行搜索或抓取时出现。每次注册表变更会在每个观察它的 Client 中产生一次转发事件和一次列表读取。 + +## 验证 + +[web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) 覆盖列表内容与顺序、标题、注册与撤销后的事件、重复注册被拒绝时不触发事件,以及监听器失败的隔离。[deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) 覆盖独立的 Messages 提供方,[credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) 针对两个能力覆盖账号路由上的账号令牌及其在 HTTP 401 后被作废、未登录时回退到 API 密钥、官方、非 DeepSeek 和会话之外的操作使用路由密钥与端点、没有该路由时使用 `DEEPSEEK_API_KEY` 与官方基础地址、从不发送令牌的 YAML 单独字段、两个能力相互独立、就绪判断与每次操作一致,以及被忽略的已移除字段;[composition.spec.ts](../../../../packages/web/web-deepseek-api/tests/composition.spec.ts) 通过 Loader 启动真实的模型路由。[remote-events.host.spec.ts](../../../../packages/api/remotes/tests/remote-events.host.spec.ts) 覆盖事件转发,[base.spec.ts](../../../../packages/bundle/base/tests/base.spec.ts) 固定内置默认值。设置页的 controller 与 card 测试以及[插件配置浏览器场景](../../../../apps/web/tests/plugin-config.e2e.ts)覆盖下拉框内容、Search 与 Fetch 的分别选择、DeepSeek API 不显示凭据控件,以及各提供方保留的设置。 diff --git a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml index a353feefcf..98c3a9541a 100644 --- a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml @@ -9,11 +9,11 @@ en: d1aad580a4db9872 zh: 1eb3bac59353291d /agent-note-direct-deepseek-search-and-fetch-apis/decision: - en: e70efbb3aa9d53b6 - zh: 1d25eae2461346df + en: 43298ccae4f7a6d4 + zh: eb7ef46b6f290c15 /agent-note-direct-deepseek-search-and-fetch-apis/decision/protocol-selection-and-deployment: - en: ec653b59d62ed95f - zh: 0d675616059930ba + en: 90e5031d0d2fa9e4 + zh: a9c0229b20555d9e /agent-note-direct-deepseek-search-and-fetch-apis/decision/search-results-and-model-input: en: ad7b3e4fbe528cf2 zh: 8ab8779bfdaaa4fa @@ -21,17 +21,17 @@ en: 7566e400f9ab3a51 zh: 118b2e84d0030480 /agent-note-direct-deepseek-search-and-fetch-apis/decision/default-migration: - en: d5a8e36b545f1c04 - zh: d741fd0789401c62 -/agent-note-direct-deepseek-search-and-fetch-apis/decision/supplementary-messages-fix: - en: 738e32b488bbc1a1 - zh: f33afe570f62599a + en: cb2f0910955b6730 + zh: 7df057048acd7858 +/agent-note-direct-deepseek-search-and-fetch-apis/decision/legacy-messages-search: + en: 0dcc97d8105fab60 + zh: d80d40ae3a0c601d /agent-note-direct-deepseek-search-and-fetch-apis/alternatives-considered: - en: 641cbfefc76f2f95 - zh: e3e718473eace85f + en: 907e0b8424943d43 + zh: f45e583e7cb4eb63 /agent-note-direct-deepseek-search-and-fetch-apis/consequences: - en: 124a35cad810c2fc - zh: 504da2a59c3873be + en: 7ec21c5b1124e5f8 + zh: af769dbdb4face9f /agent-note-direct-deepseek-search-and-fetch-apis/verification-and-remaining-api-adoption: - en: 4c84e7bd035b52a7 - zh: 4e5687bda0a47706 + en: 595a35ce727a7c82 + zh: 10b2197663198015 diff --git a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md index f6acb981c6..ee8b912b2e 100644 --- a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md +++ b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md @@ -10,15 +10,15 @@ DeepSeek web retrieval through Messages requires an auxiliary model request for ## Decision -The [DeepSeek Search and Fetch guide](https://api-docs.deepseek.com/zh-cn/guides/search), checked on 2026-10-16, defines the upstream API. The [web-deepseek-api plugin](../../../../packages/web/web-deepseek-api/README.md) implements it behind the existing `web_search` and `web_fetch` tools, and [web-search-deepseek](../../../../packages/web/web-search-deepseek/README.md) keeps Messages search. The [web capability decision](../architecture/2026-06-24-web-capability-seam.md) continues to own provider selection, tool responsibilities, and the fetch result fields. +The [DeepSeek Search and Fetch guide](https://api-docs.deepseek.com/zh-cn/guides/search), checked on 2026-10-16, defines the upstream API. The [web-deepseek-api plugin](../../../../packages/web/web-deepseek-api/README.md) implements it behind the existing `web_search` and `web_fetch` tools. The [web capability decision](../architecture/2026-06-24-web-capability-seam.md) continues to own provider selection, tool responsibilities, and the fetch result fields, and the [web provider selection decision](../architecture/2026-10-29-web-provider-selection.md) owns how users choose each provider and which providers the shipped composition selects. ### Protocol selection and deployment -The APIs register as `deepseek-search-api` and `deepseek-fetch-api`; Messages search keeps `deepseek-official`. The setting **Use the new Web Search API** (**启用新版 Web 搜索 API**) on the Web search page writes `web.searchProvider` and `web.fetchProvider` together: on selects both APIs, and off restores Messages search with local HTTP fetch, the shipped selection. The switch defaults off and takes effect after saving, because the web service reads both selections for each operation. Messages remains the default until the Search API launch is announced, so existing users keep working search without configuration. Each operation resolves one settings snapshot, including its endpoint and credentials. Both APIs read one credential and one API base, because the guide serves them from one base and the switch selects them together. +The APIs register as `deepseek-search-api` and `deepseek-fetch-api`. Messages search remains separately registered as `deepseek-official`. The web service selects each provider through `web.searchProvider` and `web.fetchProvider`, which the Search and Fetch menus on the Web search and fetch page set independently; the shipped composition selects the Search API and local HTTP fetch, as the [selection decision](../architecture/2026-10-29-web-provider-selection.md) records. A saved selection takes effect on the next operation because the web service reads both selections per operation. Each operation resolves one settings snapshot, including its endpoint and credential. Both APIs read the `web-deepseek-api` namespace, and each capability resolves its own endpoint and credential. -The two protocols retain independent API bases. Messages keeps `https://api.deepseek.com/anthropic/v1` and appends `/messages`. Direct retrieval uses `web-deepseek-api.baseURL`, which defaults to the guide's public base `https://api.deepseek.com/v1/tools`, and appends `/search` or `/fetch`; the verified development base is `http://api-dev.deepseek.com:8031/api/v1/tools`. These prefixes are different and are never inferred from the hostname, so another deployment enters its complete base. The switch is already the explicit opt-in, so the documented public deployment serves as its default. A blank or unparseable base leaves both providers unavailable. +Direct retrieval appends `/search` or `/fetch` to an API base. A capability without its own YAML endpoint fields uses the origin of the conversation's DeepSeek model route base URL with `/v1/tools`, which for the public deployment is the guide's `https://api.deepseek.com/v1/tools`; the [selection decision](../architecture/2026-10-29-web-provider-selection.md) owns that derivation and the credential each operation sends. The verified development base is `http://api-dev.deepseek.com:8031/api/v1/tools`; its prefix differs and is never inferred from the hostname, so such a deployment sets the complete base in `searchBaseURL` or `fetchBaseURL`. An unparseable base leaves that capability's provider unavailable. -The guide specifies API-key Bearer authentication, and requests send only the documented authorization and JSON content type. A Session on the DeepSeek account route sends its account token instead, as Messages search does; the public Search and Fetch routes accepted it on 2026-10-16, so account-only users can enable direct retrieval. An Anthropic-compatible gateway checked the same day served Messages web search and had no `/v1/tools` routes; such deployments keep Messages search. +The guide specifies API-key Bearer authentication, and requests send only the documented authorization and JSON content type. A conversation on the `deepseek-account` route sends the account token instead; the public Search and Fetch routes accepted it on 2026-10-16, so account-only users have direct retrieval. An Anthropic-compatible gateway checked the same day served Messages web search and had no `/v1/tools` routes; such deployments can select Messages search or set the capability's own base URL to one that serves `/v1/tools`. ### Search results and model input @@ -36,45 +36,41 @@ The HTTP provider retains its own URL validation and resource limits. Selecting ### Default migration -Changing the default to the Search API is a separate change after the launch is announced. API keys and account tokens both reach the public routes; deployments behind a gateway without `/v1/tools` routes need an explicit `web.searchProvider: deepseek-official` before the default changes, and a stored Messages selection keeps Messages. +The shipped composition selects `deepseek-search-api`; the [selection decision](../architecture/2026-10-29-web-provider-selection.md) owns that default and its no-fallback rule. API keys and account tokens both reach the public routes. Deployments behind a gateway without `/v1/tools` routes set `searchBaseURL` to a base that serves them. A stored `web.searchProvider: deepseek-official` selects Messages search, and any other stored selection keeps its provider. -### Supplementary Messages fix +### Legacy Messages search -The retained Messages path explicitly sends `thinking: { type: 'disabled' }` and records that same body before dispatch. This avoids reasoning work in an auxiliary request whose generated answer is discarded; it does not implement or control the new retrieval API. `tool_choice` remains omitted. The [persistence acknowledgement](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.md) owns compatibility for the optional logged field. +The independent `dsh-web-search-deepseek` provider sends Messages search requests when `deepseek-official` is selected, as the [selection decision](../architecture/2026-10-29-web-provider-selection.md) records. Its `web/deepseek-search-llm-request` Session event type, including the optional `thinking: { type: 'disabled' }` body field that the [persistence acknowledgement](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.md) covers, stays declared and written in `dsh-web-search-deepseek`, so released Sessions that contain the event still open. Messages search sends that field because Harness discards the generated answer and the auxiliary request needs no reasoning. ## Alternatives considered -**Keep Messages as the only search implementation.** It preserves auxiliary generation and response extraction even when callers need only sources. Direct Search removes that Harness model request while retaining Messages as an explicit mode. - -**Make the Search API the default now.** The launch is not announced, and some gateways expose only Messages; their users would lose working search. +**Keep Messages as the only search implementation.** It preserves auxiliary generation and response extraction even when callers need only sources. Direct Search removes that Harness model request. **Reuse the Messages address or infer a route prefix.** The protocols and deployed prefixes differ. Separate bases preserve existing configurations and avoid sending a valid request to the wrong route. -**Require an explicit Search API base.** Enabling the switch is already an explicit choice, and an empty default would make every user copy the documented public address. Other deployments still override it. +**Require an explicit Search API base.** The Search API is the shipped search default, and an empty default would leave it unavailable until every user enters an address that the DeepSeek model route already determines. Other deployments still set their own base. -**Keep the APIs inside the Messages provider behind a provider-local switch.** A switch inside `web-search-deepseek` cannot change fetch routing, so turning it on left `web_fetch` on HTTP unless the user also edited `web.fetchProvider`; it also tied the APIs' lifecycle to Messages search, which the default migration may retire. Separate registrations let the web service's existing selection express the switch, and each package can change or retire alone. +**Keep the APIs inside the Messages provider behind a provider-local switch.** A switch inside the Messages provider cannot change fetch routing, so `web_fetch` would stay on HTTP unless the user also edited `web.fetchProvider`; it also ties the APIs' lifecycle to Messages search. Separate registrations let the web service's existing selection choose each provider, without coupling either API to the Messages provider. -**Share one transport between Messages search and the new APIs.** Both resolve credentials through the same seams, `ctx.credentials` and `ctx.deepseekAccount`, so a shared module would save only the dispatch and failure-mapping code. It would make the Messages package depend on the new API package and tie their releases, while the default migration may retire Messages search. Each package keeps its own transport until then. +**Share one transport between Messages search and the new APIs.** A shared web transport would couple different request formats and failure mappings. Credential values instead come from the shared [`llm-deepseek` credential helpers](../../../../packages/llm/llm-deepseek/src/credential.ts); Messages search and the Search/Fetch API transports remain independent. -**Split Search and Fetch into separate packages.** The guide serves both APIs from one base with one credential, and the switch selects them together; separate packages would duplicate the settings a user edits once. Each operation lives in its own module, so either API can change without the other. +**Split Search and Fetch into separate packages.** The guide serves both APIs from one base with one credential; separate packages would duplicate settings that address one deployment. Independent selection does not require separate packages, because each API registers its own provider id. Each operation lives in its own module, so either API can change without the other. -**List every registered provider in a settings dropdown.** A generic provider form derives editors from Config schemas and needs host-side display metadata on the provider interfaces. The page needs only the DeepSeek choice, and other providers keep their own settings pages. +**List every registered provider in a settings dropdown.** A generic provider form derives editors from Config schemas and needs host-side display metadata on the provider interfaces. This rejection stands for the generic form: the page edits only the DeepSeek providers' settings, and other providers keep their own settings pages. The [selection decision](../architecture/2026-10-29-web-provider-selection.md) supersedes it for the provider menus, which list every registered provider by its `title`. **Let the model supply `ori_question` as a tool argument.** The API defines the field as the user's original question, which the latest human prompt already records. A tool argument would change the model-facing schema shared by every search provider and invite paraphrase. -**Keep HTTP fetch when the switch turns on.** The switch adopts the new DeepSeek retrieval APIs, and leaving fetch on HTTP made Fetch API adoption depend on a hand-edited `web.fetchProvider`. HTTP fetch, with its own access policy, remains the selection whenever the switch is off, and configuration can still select either fetch provider alone. - **Add an extraction tool or widen the fetch result.** The API retrieves one concrete web page as Markdown, which fits the existing fetch operation. Its success already implies a retrieved page, and the Markdown body carries the page heading, so widening the result would change every fetch consumer for little model-visible gain. ## Consequences -Retrieval no longer requires auxiliary model tokens in direct mode, but its results still consume the main model's context. One Messages web search through a gateway on 2026-10-16 consumed 8,154 input and 828 output tokens for a single server-side search. The guide prices Search and Fetch independently at CNY 5 per 1,000 successful requests with results; result count does not change a request's price. Multiple queries remain multiple requests. The stated Claude Code and Codex promotion does not establish a Harness exemption. Search API mode also sends up to 128 characters of the user's prompt to the search service with each query unless `sendOriginalQuestion` is off. +Direct retrieval requires no auxiliary model tokens, but its results still consume the main model's context. One Messages web search through a gateway on 2026-10-16 consumed 8,154 input and 828 output tokens for a single server-side search. The guide prices Search and Fetch independently at CNY 5 per 1,000 successful requests with results; result count does not change a request's price. Multiple queries remain multiple requests. The stated Claude Code and Codex promotion does not establish a Harness exemption. Search API mode also sends up to 128 characters of the user's prompt to the search service with each query unless `sendOriginalQuestion` is off. Search and Fetch each have an account-wide 5 QPS limit, independent of model concurrency and shared across that account's API keys. Fetch also has a target-domain limit. The tool accepts up to `searchMaxQueries` queries per call, 10 by default, and fails the batch if one query fails. The Search provider sends at most `searchRequestsPerSecond` requests, 5 by default, in any one-second window of one plugin instance, so the second five queries of a full batch start one second after the first. The pacing is not shared across processes, and the provider has no automatic retries or fallback; other processes or clients can exhaust the same account allowance. ## Verification and remaining API adoption -Provider tests cover request mapping, empty results, malformed responses, independent and default bases, account-token authentication, cancellation, and blank configuration. Settings tests and the [plugin configuration browser scenario](../../../../apps/web/tests/plugin-config.e2e.ts) cover staged saves, the default base, and switching both selections. The [web search replay](../../../../apps/web/tests/web-search-round.e2e.ts) and [Fetch Session replay](../../../../snapshots/session/deepseek-fetch/snapshot.yml) pin model-visible results. On 2026-10-16 the [real provider tests](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts) passed Search and Fetch against the public base, and a headless round on the shipped profile returned eight sources and fetched Markdown without an auxiliary request event. The account-token, gateway, and status observations above come from manual probes the same day. +Provider tests cover request mapping, empty results, malformed responses, independent and derived bases, account-token authentication and its invalidation after HTTP 401, cancellation, and blank configuration. Settings tests and the [plugin configuration browser scenario](../../../../apps/web/tests/plugin-config.e2e.ts) cover staged saves, the result count, and independent Search and Fetch selection. The [web search replay](../../../../apps/web/tests/web-search-round.e2e.ts) and [Fetch Session replay](../../../../snapshots/session/deepseek-fetch/snapshot.yml) pin model-visible results. On 2026-10-16 the [real provider tests](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts) passed Search and Fetch against the public base, and a headless round on the shipped profile returned eight sources and fetched Markdown without an auxiliary request event. The account-token, gateway, and status observations above come from manual probes the same day. The following capabilities remain outside the implemented integration: diff --git a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md index 6d26e41aaf..c7493c8e23 100644 --- a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md +++ b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md @@ -10,15 +10,15 @@ Status: implemented ## 决策 -2026-10-16 核对的 [DeepSeek Search 与 Fetch 指南](https://api-docs.deepseek.com/zh-cn/guides/search)定义上游 API。[web-deepseek-api 插件](../../../../packages/web/web-deepseek-api/README.zh.md)通过现有 `web_search` 和 `web_fetch` 工具接入它,[web-search-deepseek](../../../../packages/web/web-search-deepseek/README.zh.md) 保留 Messages 搜索。[Web 能力决策](../architecture/2026-06-24-web-capability-seam.zh.md)继续负责提供方选择、工具职责和抓取结果字段。 +2026-10-16 核对的 [DeepSeek Search 与 Fetch 指南](https://api-docs.deepseek.com/zh-cn/guides/search)定义上游 API。[web-deepseek-api 插件](../../../../packages/web/web-deepseek-api/README.zh.md)通过现有 `web_search` 和 `web_fetch` 工具接入它。[Web 能力决策](../architecture/2026-06-24-web-capability-seam.zh.md)继续负责提供方选择、工具职责和抓取结果字段;[Web 提供方选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)负责用户如何选择各个提供方,以及内置组合选择哪些提供方。 ### 协议选择与部署 -两个 API 注册为 `deepseek-search-api` 和 `deepseek-fetch-api`;Messages 搜索保留 `deepseek-official`。网页搜索页上的设置 **Use the new Web Search API**(**启用新版 Web 搜索 API**)同时写入 `web.searchProvider` 和 `web.fetchProvider`:开启时选择两个 API,关闭时恢复内置选择,即 Messages 搜索与本地 HTTP 抓取。开关默认关闭,保存后生效,因为 Web 服务在每次操作时读取两项选择。在 Search API 正式发布公告之前,Messages 仍是默认值,已有用户无需配置即可继续使用搜索。每次操作解析一份设置快照,包含端点和凭据。两个 API 读取同一份凭据和同一个 API 基础地址,因为指南从同一基础地址提供它们,开关也同时选择它们。 +两个 API 注册为 `deepseek-search-api` 和 `deepseek-fetch-api`;Messages 搜索仍独立注册为 `deepseek-official`。Web 服务通过 `web.searchProvider` 和 `web.fetchProvider` 选择各个提供方,「网页搜索与获取」页上的 Search 和 Fetch 下拉框分别设置这两项;内置组合选择 Search API 与本地 HTTP 抓取,见[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)。已保存的选择从下一次操作起生效,因为 Web 服务在每次操作时读取两项选择。每次操作解析一份包含端点和凭据的设置快照。两个 API 读取 `web-deepseek-api` namespace,每个能力各自解析端点与凭据。 -两种协议分别保存 API 基础地址。Messages 保留 `https://api.deepseek.com/anthropic/v1`,追加 `/messages`。直接检索使用 `web-deepseek-api.baseURL`,默认值为指南中的公网地址 `https://api.deepseek.com/v1/tools`,追加 `/search` 或 `/fetch`;已验证的开发地址为 `http://api-dev.deepseek.com:8031/api/v1/tools`。两者前缀不同,程序不会根据主机名推断,其他部署需填写完整基础地址。开关本身已是显式启用,因此以文档中的公网部署作为默认值。基础地址为空白或无法解析时,两个提供方都不可用。 +直接检索在 API 基础地址后追加 `/search` 或 `/fetch`。没有在 YAML 中单独设置端点字段的能力,使用会话的 DeepSeek 模型路由基础地址的 origin 加 `/v1/tools`,公网部署即指南中的 `https://api.deepseek.com/v1/tools`;该推导及每次操作发送的凭据由[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)负责。已验证的开发地址为 `http://api-dev.deepseek.com:8031/api/v1/tools`;其前缀不同,程序不会根据主机名推断,此类部署需在 `searchBaseURL` 或 `fetchBaseURL` 中填写完整基础地址。基础地址无法解析时,该能力的提供方不可用。 -指南规定使用 API 密钥进行 Bearer 鉴权,请求只发送文档规定的鉴权与 JSON 内容类型请求头。DeepSeek 账号路由上的会话改为发送其账号 token,与 Messages 搜索一致;2026-10-16 公网 Search 与 Fetch 路由已接受该 token,因此只登录账号的用户也可以启用直接检索。同日检查的一个 Anthropic 兼容网关提供 Messages 网页搜索,但没有 `/v1/tools` 路由;此类部署继续使用 Messages 搜索。 +指南规定使用 API 密钥进行 Bearer 鉴权,请求只发送文档规定的鉴权与 JSON 内容类型请求头。`deepseek-account` 路由上的会话改为发送账号 token;2026-10-16 公网 Search 与 Fetch 路由已接受该 token,因此只登录账号的用户也能使用直接检索。同日检查的一个 Anthropic 兼容网关提供 Messages 网页搜索,但没有 `/v1/tools` 路由;此类部署可以选择 Messages 搜索,或把该能力的单独基础地址设为提供 `/v1/tools` 的地址。 ### 搜索结果与模型输入 @@ -36,45 +36,41 @@ HTTP 提供方继续负责自身的 URL 校验和资源限制。选择 DeepSeek ### 默认值迁移 -将默认值改为 Search API 是发布公告之后的另一项变更。API 密钥和账号 token 都能访问公网路由;位于没有 `/v1/tools` 路由的网关之后的部署,需要在默认值变更前显式设置 `web.searchProvider: deepseek-official`,已保存的 Messages 选择仍使用 Messages。 +内置组合选择 `deepseek-search-api`;[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)负责这项默认值及其不回退规则。API 密钥和账号 token 都能访问公网路由。位于没有 `/v1/tools` 路由的网关之后的部署,需把 `searchBaseURL` 设为提供这些路由的基础地址。已保存的 `web.searchProvider: deepseek-official` 会选中 Messages 搜索,其他已保存的选择保持其提供方。 -### 附带的 Messages 修复 +### 旧版 Messages 搜索 -保留的 Messages 路径显式发送 `thinking: { type: 'disabled' }`,并在发送前记录同一请求体。这避免了辅助请求在生成回答被丢弃的情况下执行思考;它不实现或控制新的检索 API。`tool_choice` 仍不传递。[持久化确认记录](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.zh.md)负责日志中可选字段的兼容性。 +选中 `deepseek-official` 时,独立的 `dsh-web-search-deepseek` 提供方发送 Messages 搜索请求,见[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)。它的 `web/deepseek-search-llm-request` 会话事件类型(包括[持久化确认记录](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.zh.md)覆盖的可选请求体字段 `thinking: { type: 'disabled' }`)仍由 `dsh-web-search-deepseek` 声明并写入,因此包含该事件的已发布会话仍能打开。Messages 搜索发送该字段,是因为 Harness 丢弃生成的回答,辅助请求无需推理。 ## 考虑过的替代方案 -**仅保留 Messages 搜索实现。** 即使调用方只需要来源,这也保留了辅助生成和响应提取。直接 Search 去掉这次 Harness 模型请求,同时保留 Messages 供显式选择。 - -**立即将 Search API 设为默认值。** 尚未发布公告,部分网关也只提供 Messages;这些用户会失去可用的搜索。 +**仅保留 Messages 搜索实现。** 即使调用方只需要来源,这也保留了辅助生成和响应提取。直接 Search 去掉这次 Harness 模型请求。 **复用 Messages 地址或推断路由前缀。** 协议与部署前缀不同。分别保存基础地址可保留现有配置,避免把有效请求发往错误路由。 -**要求显式填写 Search API 基础地址。** 开启开关已是显式选择,空默认值会迫使每位用户复制文档中的公网地址。其他部署仍可覆盖它。 +**要求显式填写 Search API 基础地址。** Search API 是内置的搜索默认值,空默认值会让它在每位用户填写一个 DeepSeek 模型路由已经确定的地址之前都不可用。其他部署仍可设置自己的基础地址。 -**把两个 API 放在 Messages 提供方内部,由提供方自己的开关切换。** `web-search-deepseek` 内的开关无法改变抓取路由,因此开启后除非用户另外修改 `web.fetchProvider`,`web_fetch` 仍走 HTTP;它还把两个 API 的生命周期绑在 Messages 搜索上,而默认值迁移后 Messages 搜索可能被移除。独立注册让 Web 服务现有的选择来表达这个开关,每个包都可以单独修改或移除。 +**把两个 API 放在 Messages 提供方内部,由提供方自己的开关切换。** Messages 提供方内的开关无法改变抓取路由,因此除非用户另外修改 `web.fetchProvider`,`web_fetch` 仍会走 HTTP;它还会把两个 API 的生命周期绑在 Messages 搜索上。独立注册让 Web 服务通过现有的提供方选择机制选择各个提供方,不会把任一 API 与 Messages 提供方绑定。 -**让 Messages 搜索与新 API 共用一套传输层。** 两者都通过同样的 seam,即 `ctx.credentials` 和 `ctx.deepseekAccount` 解析凭据,共享模块只能省下请求派发与失败映射代码。它会让 Messages 包依赖新 API 包、两者必须一起发版,而默认值迁移后 Messages 搜索可能被移除。在此之前,两个包各自保留传输层。 +**让 Messages 搜索与新 API 共用一套传输层。** 共享 Web 传输层会耦合不同的请求格式和失败映射。凭据值改为通过共享的 [`llm-deepseek` 凭据函数](../../../../packages/llm/llm-deepseek/src/credential.ts)获取;Messages 搜索与 Search/Fetch API 传输层保持独立。 -**把 Search 和 Fetch 拆成两个包。** 指南从同一基础地址、以同一份凭据提供两个 API,开关也同时选择它们;拆成两个包会让用户只需编辑一次的设置重复出现。每种操作位于独立模块,任一 API 都可以单独修改。 +**把 Search 和 Fetch 拆成两个包。** 指南从同一基础地址、以同一份凭据提供两个 API;拆成两个包会让指向同一部署的设置重复出现。分别选择不需要拆包,因为每个 API 注册自己的提供方 id。每种操作位于独立模块,任一 API 都可以单独修改。 -**在设置页用下拉框列出所有已注册的提供方。** 通用提供方表单从 Config schema 推导编辑控件,还需要在提供方接口上增加宿主侧展示元数据。该页面只需要 DeepSeek 的选择,其他提供方保留各自的设置页。 +**在设置页用下拉框列出所有已注册的提供方。** 通用提供方表单从 Config schema 推导编辑控件,还需要在提供方接口上增加宿主侧展示元数据。这一否决对通用表单仍然成立:该页面只编辑 DeepSeek 提供方的设置,其他提供方保留各自的设置页。对于提供方下拉框,[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)取代了这一否决,下拉框按 `title` 列出所有已注册的提供方。 **让模型以工具参数提供 `ori_question`。** API 将该字段定义为用户的原始问题,而最近一条用户提示已记录了这段文本。工具参数会改变所有搜索提供方共用的面向模型的 schema,并引入改写。 -**开启开关时保留 HTTP 抓取。** 该开关用于启用 DeepSeek 新的检索 API,保留 HTTP 抓取会让使用 Fetch API 依赖手动修改 `web.fetchProvider`。开关关闭时,抓取仍选择带有自身访问策略的 HTTP 提供方,配置中也仍可单独选择任一抓取提供方。 - **新增提取工具或扩展抓取结果。** API 以 Markdown 获取一个具体网页,符合现有抓取操作。其成功本身即表示已获取页面,Markdown 正文也带有页面标题,因此扩展结果会改动每个抓取消费方,而模型可见的收益很小。 ## 影响 -直接模式中的检索不再需要辅助模型 token,但结果仍消耗主模型上下文。2026-10-16 通过网关进行的一次 Messages 网页搜索,为一次服务端搜索消耗了 8,154 个输入 token 和 828 个输出 token。指南对 Search 和 Fetch 分别按每 1,000 次成功且有结果的请求收取 5 元;结果条数不影响单次价格。多个查询仍对应多次请求。文档中的 Claude Code 与 Codex 优惠不能作为 Harness 免收费用的依据。除非关闭 `sendOriginalQuestion`,Search API 模式还会随每个查询把用户提示的至多 128 个字符发送给搜索服务。 +直接检索不需要辅助模型 token,但结果仍消耗主模型上下文。2026-10-16 通过网关进行的一次 Messages 网页搜索,为一次服务端搜索消耗了 8,154 个输入 token 和 828 个输出 token。指南对 Search 和 Fetch 分别按每 1,000 次成功且有结果的请求收取 5 元;结果条数不影响单次价格。多个查询仍对应多次请求。文档中的 Claude Code 与 Codex 优惠不能作为 Harness 免收费用的依据。除非关闭 `sendOriginalQuestion`,Search API 模式还会随每个查询把用户提示的至多 128 个字符发送给搜索服务。 Search 和 Fetch 各有账号级 5 QPS 限制,独立于模型并发,同一账号的 API 密钥共享额度。Fetch 还有目标域名限制。工具每次调用最多接受 `searchMaxQueries` 个查询,默认 10 个;任一查询失败会使整批失败。Search 提供方在单个插件实例的任意一秒窗口内最多发送 `searchRequestsPerSecond` 个请求,默认 5 个,因此满批次的后五个查询比前五个晚一秒开始。该限速不跨进程共享,提供方也没有自动重试或回退;其他进程或客户端也会消耗同一账号的额度。 ## 验证与尚未接入的 API 能力 -提供方测试覆盖请求映射、空结果、格式错误的响应、独立地址与默认地址、账号 token 鉴权、取消与空白配置。设置测试及[插件配置浏览器场景](../../../../apps/web/tests/plugin-config.e2e.ts)覆盖暂存后保存、默认地址和同时切换两项选择。[网页搜索回放](../../../../apps/web/tests/web-search-round.e2e.ts)与 [Fetch Session 回放](../../../../snapshots/session/deepseek-fetch/snapshot.yml)固定模型可见结果。2026-10-16,[真实提供方测试](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts)在公网地址通过了 Search 与 Fetch 验证;在内置配置上运行的一轮 headless 对话返回了八个来源并抓取到 Markdown,且没有辅助请求事件。上文的账号 token、网关和状态码观察来自同日的手动探测。 +提供方测试覆盖请求映射、空结果、格式错误的响应、独立地址与推导地址、账号 token 鉴权及其在 HTTP 401 后被作废、取消与空白配置。设置测试及[插件配置浏览器场景](../../../../apps/web/tests/plugin-config.e2e.ts)覆盖暂存后保存、结果数,以及分别选择 Search 与 Fetch。[网页搜索回放](../../../../apps/web/tests/web-search-round.e2e.ts)与 [Fetch Session 回放](../../../../snapshots/session/deepseek-fetch/snapshot.yml)固定模型可见结果。2026-10-16,[真实提供方测试](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts)在公网地址通过了 Search 与 Fetch 验证;在内置配置上运行的一轮 headless 对话返回了八个来源并抓取到 Markdown,且没有辅助请求事件。上文的账号 token、网关和状态码观察来自同日的手动探测。 以下能力尚未包含在已实现的接入中: diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index ffd79b8daa..ca5a0ccaf0 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -3,7 +3,7 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md /dsh-cli-behavior-reference: - en: 5a4f43756dae900e + en: abffba1bcc44698c zh: d58c479481cfa5fe /dsh-cli-behavior-reference/profile-boot: en: b474a6fc3c98837d @@ -24,8 +24,8 @@ en: 149c5571acd51baf zh: 300e3c2425273a92 /dsh-cli-behavior-reference/shared-deployment-behavior: - en: f40edcf903a51f3f - zh: 8a9a915451047c8b + en: f02219118dcec531 + zh: fe2977628d7340da /dsh-cli-behavior-reference/source-execution: en: 05c2b23790f7b296 zh: 51f809020fb230a1 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index a375450048..a5632f83af 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -6,6 +6,8 @@ This reference defines the profile, plugin-management, and config-dump command m The [generated command help](../../../docs/cli-help.md) contains complete launcher, plugin-management, and shipped-profile help. Run `pnpm run gen-cli-help` to refresh it; `pnpm run verify-cli-help` checks it against the current CLI. + + ## Profile boot `dsh ` abbreviates `dsh --profile ` and boots the profile at `$DSH_HOME/profiles/`. The shorthand name must immediately follow `dsh`; `plugin` remains the plugin-management command, so boot a profile with that name using `dsh --profile plugin`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch ` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. The final YAML composition controls whether `dsh-hmr` watches configuration; without HMR, changes require restart. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. @@ -134,7 +136,7 @@ Web, headless, and SDK inherit native tool execution from the base bundle; use a ## Shared deployment behavior -The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search` and `web_fetch`, the HTTP fetch provider, default-on DeepSeek session-log upload, and feedback-gated OTel upload for all users. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation and can reach loopback and private-network hosts. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets. +The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search` and `web_fetch`, the HTTP fetch provider, default-on DeepSeek session-log upload, and feedback-gated OTel upload for all users. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. The DeepSeek Search and Fetch APIs follow the session's DeepSeek credentials; custom endpoints and keys can only be set in YAML through `web-deepseek-api.searchBaseURL` / `searchApiKeyEnv` and `fetchBaseURL` / `fetchApiKeyEnv` ([authentication](../../../packages/web/web-deepseek-api/README.md#authentication)). Legacy Messages search reads `DEEPSEEK_SEARCH_BASE_URL`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation and can reach loopback and private-network hosts. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets. Feedback is recorded in the Session log without starting model work. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) sends complete unaccepted log suffixes with subsequent DeepSeek requests by default, including requests sent through configured gateways; turn off **Settings → General → Upload Session Log when using the official model API** in Web, or set its `enabled` configuration to `false` to opt out. [OTel session upload](../../../packages/session/session-telemetry-otel/README.md) applies to all users and providers, including `deepseek-official`, without requiring a request header. The base defaults to `FEEDBACK_ONLY`: new own text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including stored context; later records wait for the next explicit feedback. Inherited parent feedback does not authorize a fork. Requests, restoration, mount, and HMR do not trigger capture. SDK batching may finish an authorized upload without further interaction or model work. `DSH_TELEMETRY_MODE=DISABLED` disables OTel delivery; `FULL` is rejected, and any non-empty `DSH_TELEMETRY_DISABLED` disables its row. `DSH_TELEMETRY_OTLP_URL` selects the collector. Handoff is best-effort, not collector acceptance; no durable outbox or retry guarantee is provided. These OTel settings do not enable or disable the DeepSeek contribution. Neither path changes model input, but exports can include message text, tool arguments and results, and workspace paths. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 642fa18bc5..3ac1da997b 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -136,7 +136,7 @@ Web、headless 与 SDK 继承 base bundle 的原生工具执行;使用 [PTC ov ## 共享部署行为 -基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、HTTP fetch 提供方,默认开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认,并且可以访问 loopback 与内网主机。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。 +基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、HTTP fetch 提供方,默认开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。DeepSeek Search 与 Fetch API 跟随会话的 DeepSeek 凭据;自定义 endpoint 和密钥只能在 YAML 中通过 `web-deepseek-api.searchBaseURL` / `searchApiKeyEnv` 和 `fetchBaseURL` / `fetchApiKeyEnv` 设置([鉴权](../../../packages/web/web-deepseek-api/README.zh.md#authentication))。旧版 Messages 搜索读取 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认,并且可以访问 loopback 与内网主机。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。 反馈记录在会话日志中,不会启动模型工作。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随后续 DeepSeek 请求发送尚未确认接收的完整日志后缀,包括经已配置网关发送的请求;可在 Web 的**设置 → 通用 → 在使用官方模型 API 时上传 Session Log**中关闭上传,或将其 `enabled` 配置设为 `false`。[OTel 会话上传](../../../packages/session/session-telemetry-otel/README.zh.md)适用于所有用户和提供方,包括 `deepseek-official`,无需请求头。基础配置默认使用 `FEEDBACK_ONLY`:新的自身文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范日志前缀,包含存储的上下文;后续记录等待下一次显式反馈。继承的父级反馈不构成 fork 的授权。请求、恢复、挂载和 HMR 不触发捕获。SDK 批处理可完成已授权上传,无需进一步交互或模型工作。`DSH_TELEMETRY_MODE=DISABLED` 禁止 OTel 投递;`FULL` 被拒绝,任何非空的 `DSH_TELEMETRY_DISABLED` 都会禁用其配置行。`DSH_TELEMETRY_OTLP_URL` 选择采集端。交接尽力而为,不代表采集端接受;不提供持久化 outbox 或重试保证。这些 OTel 设置不会开启或关闭 DeepSeek 贡献。两条路径都不改变模型输入,但导出可能包含消息文本、工具参数和结果,以及工作区路径。 diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml b/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml index aa22a2d2f0..5b1f799910 100644 --- a/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml +++ b/apps/cli/tests/profiles/headless/tests/fixtures/cli.patch.yml @@ -42,8 +42,14 @@ mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write' workspaceRoot: !!js process.cwd() -# A search key reference no runner sets: the turn runs as a deployment without -# web search credentials, whatever the launching environment exports. +# Own key references no runner sets on the DeepSeek Search and Fetch APIs: the +# turn runs as a deployment without web search credentials, whatever +# DEEPSEEK_API_KEY the launching environment exports. +- id: web-deepseek-api + config: + searchApiKeyEnv: DSH_KEYLESS_SMOKE_SEARCH_KEY + fetchApiKeyEnv: DSH_KEYLESS_SMOKE_FETCH_KEY + - id: web-search-deepseek config: apiKeyEnv: DSH_KEYLESS_SMOKE_SEARCH_KEY diff --git a/apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts index 0272ff8752..3710ec082a 100644 --- a/apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts @@ -61,7 +61,7 @@ describe('headless-agent keyless smoke', () => { expect(String(result?.['output'])).toContain('CLI_TOOL_ROUND_TRIP') expect(persistedHeader).toMatchObject({ type: 'session' }) expect(persistedToolNames).toEqual(expect.arrayContaining(['read', 'write', 'edit', 'web_fetch'])) - // Search has no key in this composition, so the model request does not offer it. + // Neither DeepSeek search provider has a key in this composition, so the model request does not offer search. expect(persistedToolNames).not.toContain('web_search') expect(persistedToolNames).not.toContain('str_replace_editor') }, PRODUCTION_PROFILE_TEST_TIMEOUT_MS) diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index f80f40866c..0d4f6919d6 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -11,8 +11,8 @@ - button "查看 子智能体": 子智能体 - text: 设置子智能体的递归层级、数量和模型。 - listitem: - - button "查看 网页搜索": 网页搜索 - - text: 设置 DeepSeek 的搜索提供方。 + - button "查看 网页搜索与获取": 网页搜索与获取 + - text: 选择网页搜索与网页获取使用的提供方。 - listitem: - button "查看 Agent 循环": Agent 循环 - text: 控制 Agent 派发工具调用的方式。 diff --git a/apps/web/tests/expected/plugin-config/web-search.expected.md b/apps/web/tests/expected/plugin-config/web-search.expected.md index a1c912fcd4..7f725a72e5 100644 --- a/apps/web/tests/expected/plugin-config/web-search.expected.md +++ b/apps/web/tests/expected/plugin-config/web-search.expected.md @@ -1,24 +1,20 @@ - button "返回插件": 插件 -- heading "网页搜索" [level=3] -- paragraph: 设置 DeepSeek 的搜索提供方。 -- text: 启用新版 Web 搜索 API -- switch "启用新版 Web 搜索 API" [checked] -- paragraph: - - text: 默认关闭,搜索通过 DeepSeek Messages 完成。新版 Web 搜索 API 功能与计费详见 - - link "网页搜索与抓取指南": - - /url: https://api-docs.deepseek.com/zh-cn/guides/search -- text: API Key 未配置密钥;仅使用 DeepSeek 账号模型的对话可以通过默认接口地址搜索。 -- textbox "API Key" -- paragraph: 不写入设置文件。留空表示保持当前密钥。 -- text: Search / Fetch API 基础地址 已覆盖 -- button "恢复默认" -- textbox "Search / Fetch API 基础地址": - - /placeholder: "" - - text: https://search-api.test/api/v1/tools -- paragraph: 填写含 API 前缀的地址,例如 https://api.deepseek.com/v1/tools;程序追加 /search 或 /fetch,留空恢复默认。 -- text: 每次搜索结果数 -- textbox "每次搜索结果数": - - /placeholder: "" - - text: "10" -- paragraph: 每个查询返回的结果条数,须为 1–50 的整数,默认 10;不控制搜索调用次数。web_search 保留的来源数不超过其自身的结果上限。 +- heading "网页搜索与获取" [level=3] +- paragraph: 选择网页搜索与网页获取使用的提供方。 +- region "网页搜索提供方": + - heading "网页搜索提供方" [level=4] + - button "网页搜索提供方 DeepSeek Search API": DeepSeek Search API + - paragraph: 直接请求搜索接口返回标题、URL 与摘要,无需额外调用模型。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费 + - paragraph: + - link "网页搜索与获取指南": + - /url: https://api-docs.deepseek.com/zh-cn/guides/search + - text: 每次搜索结果数 + - textbox "每次搜索结果数": + - /placeholder: "" + - text: "10" + - paragraph: 每个查询返回的结果条数,须为 1–50 的整数,默认 10;不控制搜索调用次数。web_search 保留的来源数不超过其自身的结果上限。 +- region "网页获取提供方": + - heading "网页获取提供方" [level=4] + - button "网页获取提供方 DeepSeek Fetch API": DeepSeek Fetch API + - paragraph: 由 DeepSeek 服务端抓取公网页面并返回 Markdown。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费,且无法访问内网和本机 - button "保存" [disabled] diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index fe2de50fcc..b425c381c9 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -11,8 +11,8 @@ - button "查看 子智能体": 子智能体 - text: 设置子智能体的递归层级、数量和模型。 - listitem: - - button "查看 网页搜索": 网页搜索 - - text: 设置 DeepSeek 的搜索提供方。 + - button "查看 网页搜索与获取": 网页搜索与获取 + - text: 选择网页搜索与网页获取使用的提供方。 - listitem: - button "查看 Agent 循环": Agent 循环 - text: 控制 Agent 派发工具调用的方式。 diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index f564b673e8..aa7d31a43b 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -11,8 +11,8 @@ - button "查看 子智能体": 子智能体 - text: 设置子智能体的递归层级、数量和模型。 - listitem: - - button "查看 网页搜索": 网页搜索 - - text: 设置 DeepSeek 的搜索提供方。 + - button "查看 网页搜索与获取": 网页搜索与获取 + - text: 选择网页搜索与网页获取使用的提供方。 - listitem: - button "查看 Agent 循环": Agent 循环 - text: 控制 Agent 派发工具调用的方式。 diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 7152ad1ef8..2b317f4ec3 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -35,10 +35,11 @@ describe('web e2e: plugin configuration pages', () => { beforeAll(async () => { // The live-client fixture is a bundle with a browser half; switched on // below, that half registers its row's configuration into the page. + // Legacy Messages search names an unset credential so the goldens show the + // unconfigured key whatever keys the host environment carries. scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), deepSeekSearch: { apiKeyEnv: UNUSED_KEY_ENV }, - deepSeekWebApi: { apiKeyEnv: UNUSED_KEY_ENV }, profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-live-client') }] }, }) browser = await chromium.launch() @@ -92,12 +93,12 @@ describe('web e2e: plugin configuration pages', () => { // Every page the shipped web composition exposes: the shell executor, the // agent loop, subagent selection, and the DeepSeek search provider. - await panel.getByRole('button', { name: '查看 网页搜索', exact: true }).waitFor({ timeout: 20_000 }) + await panel.getByRole('button', { name: '查看 网页搜索与获取', exact: true }).waitFor({ timeout: 20_000 }) const basic = panel.locator('[data-plugin-group="basic"]') expect(await basic.locator('[data-plugin-item]').count()).toBe(4) expect(await basic.locator(':scope > ul > li').evaluateAll(cards => cards.map(card => card.getAttribute('data-plugin-item')))) .toEqual(['subagent', 'web-search', 'agent-loop', 'shell']) - for (const title of ['子智能体', '网页搜索', 'Agent 循环', '终端']) { + for (const title of ['子智能体', '网页搜索与获取', 'Agent 循环', '终端']) { expect(await basic.getByRole('button', { name: `查看 ${title}`, exact: true }).count()).toBe(1) } // A card carries the one-liner; the fields wait for the page. @@ -142,63 +143,107 @@ describe('web e2e: plugin configuration pages', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('switches search and fetch to the Search and Fetch APIs together and preserves each API’s endpoint', async () => { + it('selects search and fetch providers independently and keeps each provider’s settings', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-search-api-settings')) const panel = await openPlugins() - await openPage(panel, '网页搜索') - const toggle = panel.getByRole('switch', { name: '启用新版 Web 搜索 API', exact: true }) - const endpoint = panel.locator('#plugin-config-web-search-endpoint') - const maxUses = panel.getByLabel('单次请求最多搜索次数', { exact: true }) - const save = panel.getByRole('button', { name: '保存', exact: true }) - expect(await toggle.getAttribute('aria-checked')).toBe('false') - expect(await panel.getByRole('link', { name: '网页搜索与抓取指南', exact: true }).getAttribute('href')) - .toBe('https://api-docs.deepseek.com/zh-cn/guides/search') - expect(await panel.getByLabel('Messages API 基础地址', { exact: true }).count()).toBe(1) - expect(await maxUses.inputValue()).toBe('5') - expect(await endpoint.inputValue()).toBe('') - expect(await save.isDisabled()).toBe(true) - await endpoint.fill('https://messages.test/anthropic/v1') - await save.click() - await expect.poll(() => save.isDisabled()).toBe(true) - await toggle.focus() - await page.keyboard.press('Space') - expect(await panel.getByLabel('Search / Fetch API 基础地址', { exact: true }).count()).toBe(1) - expect(await endpoint.inputValue()).toBe('https://api.deepseek.com/v1/tools') - expect(await maxUses.count()).toBe(0) + await openPage(panel, '网页搜索与获取') + // A menu's accessible name is its label followed by the selected entry, so the menus are found by id. + const searchMenu = panel.locator('#plugin-config-web-search-provider') + const fetchMenu = panel.locator('#plugin-config-web-fetch-provider') + const searchSection = panel.getByRole('region', { name: '网页搜索提供方', exact: true }) + const fetchSection = panel.getByRole('region', { name: '网页获取提供方', exact: true }) const count = panel.getByLabel('每次搜索结果数', { exact: true }) + const save = panel.getByRole('button', { name: '保存', exact: true }) + /** Pick one entry from a menu. */ + async function choose(menu: Locator, label: string): Promise { + await menu.click() + await page.getByRole('menuitem', { name: label, exact: true }).click() + } + /** Open a menu and read its entries in order, then close it. */ + async function entries(menu: Locator): Promise { + await menu.click() + const items = await page.getByRole('menuitem').allTextContents() + await page.keyboard.press('Escape') + await expect.poll(() => page.getByRole('menuitem').count()).toBe(0) + return items + } + // The shipped composition selects the DeepSeek Search API and local fetch, and the page writes nothing on mount. + expect(await searchMenu.textContent()).toBe('DeepSeek Search API') + expect(await fetchMenu.textContent()).toBe('本机直接获取') + expect(await panel.getByRole('button', { name: '网页搜索提供方 DeepSeek Search API', exact: true }).count()).toBe(1) + expect(await panel.getByRole('button', { name: '网页获取提供方 本机直接获取', exact: true }).count()).toBe(1) + expect(await entries(searchMenu)).toEqual(['DeepSeek Search API', 'DeepSeek(旧版)']) + expect(await entries(fetchMenu)).toEqual(['本机直接获取', 'DeepSeek Fetch API']) + expect(await settingsDocument()).not.toContain('searchProvider:') + // The page fixes the menu order itself; the Loader imports plugins concurrently, so the + // Host's registration order is not the YAML row order and only its membership is checked here. + const hostWeb = scaffold.ctx.get('web') as { listProviders(): Record<'search' | 'fetch', { id: string }[]> } | undefined + expect(hostWeb?.listProviders().search.map(provider => provider.id).sort()).toEqual(['deepseek-official', 'deepseek-search-api']) + expect(await panel.getByRole('link', { name: '网页搜索与获取指南', exact: true }).getAttribute('href')) + .toBe('https://api-docs.deepseek.com/zh-cn/guides/search') + // Each section shows the description its selected provider registered with the Host, in the active language. + expect(await searchSection.getByText('直接请求搜索接口返回标题、URL 与摘要,无需额外调用模型。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费', { exact: true }).count()).toBe(1) + expect(await fetchSection.getByText('本机直接请求,免 API Key 与计费,可访问内网和本机。仅解码 HTML、JSON 等文本,不执行 JavaScript、不带登录态,不支持 PDF;也可以试试 DeepSeek Fetch API', { exact: true }).count()).toBe(1) + // The Fetch API suggestion is text, not an action. + expect(await fetchSection.getByRole('button').count()).toBe(1) + expect(await save.isDisabled()).toBe(true) + + // The Search panel holds only the result count. + expect(await searchSection.getByRole('textbox').count()).toBe(1) expect(await count.inputValue()).toBe('10') await count.fill('51') await panel.getByText('请填 1–50 的整数;留空表示使用默认值。', { exact: true }).waitFor() expect(await save.isDisabled()).toBe(true) - expect(await toggle.isDisabled()).toBe(true) + for (const menu of [searchMenu, fetchMenu]) expect(await menu.isDisabled()).toBe(true) await count.fill('10') - expect(await settingsDocument()).not.toContain('searchProvider: deepseek-search-api') - await endpoint.fill('https://search-api.test/api/v1/tools') + expect(await save.isDisabled()).toBe(true) + + // The Fetch API takes no settings here, so its section shows no panel. + await choose(fetchMenu, 'DeepSeek Fetch API') + expect(await fetchSection.getByText('由 DeepSeek 服务端抓取公网页面并返回 Markdown。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费,且无法访问内网和本机', { exact: true }).count()).toBe(1) + expect(await fetchSection.getByRole('textbox').count()).toBe(0) + expect(await fetchSection.getByRole('button').count()).toBe(1) await save.click() await expect.poll(() => save.isDisabled()).toBe(true) - await expect.poll(settingsDocument).toContain('searchProvider: deepseek-search-api') await expect.poll(settingsDocument).toContain('fetchProvider: deepseek-fetch-api') - await expect.poll(settingsDocument).toContain('baseURL: https://search-api.test/api/v1/tools') + await page.reload({ waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await openPlugins() - await openPage(panel, '网页搜索') - expect(await toggle.getAttribute('aria-checked')).toBe('true') - expect(await endpoint.inputValue()).toBe('https://search-api.test/api/v1/tools') + await openPage(panel, '网页搜索与获取') + expect(await searchMenu.textContent()).toBe('DeepSeek Search API') + expect(await fetchMenu.textContent()).toBe('DeepSeek Fetch API') const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd) await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'web-search.expected.md'), snapshot, MODE) - await panel.getByRole('button', { name: '恢复默认', exact: true }).click() - expect(await endpoint.inputValue()).toBe('https://api.deepseek.com/v1/tools') + + await choose(fetchMenu, '本机直接获取') await save.click() await expect.poll(() => save.isDisabled()).toBe(true) - await expect.poll(settingsDocument).not.toContain('https://search-api.test/api/v1/tools') - await toggle.click() - expect(await endpoint.inputValue()).toBe('https://messages.test/anthropic/v1') - await save.click() - await expect.poll(() => save.isDisabled()).toBe(true) - await expect.poll(settingsDocument).not.toContain('searchProvider: deepseek-search-api') await expect.poll(settingsDocument).not.toContain('fetchProvider: deepseek-fetch-api') - expect(await settingsDocument()).toContain('baseURL: https://messages.test/anthropic/v1') + + await choose(searchMenu, 'DeepSeek(旧版)') + const messagesEndpoint = panel.locator('#plugin-config-web-search-endpoint') + expect(await panel.getByLabel('单次请求最多搜索次数', { exact: true }).inputValue()).toBe('5') + await messagesEndpoint.fill('https://messages.test/anthropic/v1') + await save.click() + await expect.poll(() => save.isDisabled()).toBe(true) + await expect.poll(settingsDocument).toContain('searchProvider: deepseek-official') + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await openPlugins() + await openPage(panel, '网页搜索与获取') + expect(await searchMenu.textContent()).toBe('DeepSeek(旧版)') + expect(await messagesEndpoint.inputValue()).toBe('https://messages.test/anthropic/v1') + expect(await entries(searchMenu)).toEqual(['DeepSeek Search API', 'DeepSeek(旧版)']) + expect(await save.isDisabled()).toBe(true) + expect(await settingsDocument()).toContain('searchProvider: deepseek-official') + await choose(searchMenu, 'DeepSeek Search API') + await count.fill('20') + await save.click() + await expect.poll(() => save.isDisabled()).toBe(true) + await expect.poll(settingsDocument).not.toContain('searchProvider:') + expect(await settingsDocument()).toContain('count: 20') + expect(await settingsDocument()).not.toMatch(/ApiKeyEnv:|searchBaseURL:|fetchBaseURL:/) expect(tripwire.pageErrors).toEqual([]) }) diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 51903b5633..66bd3cfb1d 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -1166,7 +1166,7 @@ describe('web e2e: plugin manager', () => { // Schedule ships in the delivered composition, so no bundle card carries it in either language. expect(await panel.getByRole('button', { name: 'View Automation tasks', exact: true }).count()).toBe(0) // The official configuration pages follow the language too, from their own dictionary. - for (const title of ['Shell', 'Agent loop', 'Subagent', 'Web search']) { + for (const title of ['Shell', 'Agent loop', 'Subagent', 'Web search and fetch']) { await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor() } } finally { diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index a7e5afd77f..2b173325e4 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -396,29 +396,25 @@ export interface LaunchOptions { welcomeNoticePending?: boolean /** Leave first-use Workspace initialization eligible; ordinary scenarios start after the default was removed. */ firstUse?: boolean - /** - * Patch the shipped DeepSeek search row to a deterministic endpoint and - * credential reference. Browser search scenarios keep the real provider and - * credentials seam while avoiding external search traffic and ambient keys. - */ + /** Pin the legacy Messages search endpoint and credential reference. */ deepSeekSearch?: { - /** Anthropic-compatible base URL; the provider appends `/messages`. */ + /** Anthropic-compatible base URL; `/messages` is appended. */ baseURL?: string - /** Credential reference resolved by the shipped search provider. */ + /** Credential reference read for each search. */ apiKeyEnv: string } /** - * Patch the shipped DeepSeek Search and Fetch API row to a deterministic - * endpoint and credential reference, optionally selecting both providers as - * the settings switch does. + * Give both sections of the shipped DeepSeek Search and Fetch API row their + * own credential reference and, when given, endpoint, so neither follows the + * conversation's DeepSeek credential. Browser search scenarios keep the real + * providers and credentials seam while avoiding external search traffic and + * ambient keys. */ deepSeekWebApi?: { - /** Search and Fetch API base URL; omitted uses the provider default. The providers append `/search` or `/fetch`. */ + /** Base URL of both sections; omitted uses the official base. The providers append `/search` or `/fetch`. */ baseURL?: string - /** Credential reference resolved by both providers. */ + /** Credential reference of both sections. */ apiKeyEnv: string - /** Select `deepseek-search-api` and `deepseek-fetch-api` in place of the shipped selection. */ - select?: boolean } /** Preset selection default and additional declarative definitions for this scenario. */ agentPresets?: { @@ -690,27 +686,25 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise ({ id: `preset-${config.id}`, name: '@deepseek-ai/dsh-agent-preset', config })) }, ], - ...options.deepSeekSearch === undefined - ? [] - : [{ - id: 'web-search-deepseek', - config: { - apiKeyEnv: options.deepSeekSearch.apiKeyEnv, - ...options.deepSeekSearch.baseURL === undefined ? {} : { baseURL: options.deepSeekSearch.baseURL }, - }, - }], ...options.deepSeekWebApi === undefined ? [] : [ { id: 'web-deepseek-api', config: { - apiKeyEnv: options.deepSeekWebApi.apiKeyEnv, - ...options.deepSeekWebApi.baseURL === undefined ? {} : { baseURL: options.deepSeekWebApi.baseURL }, + searchApiKeyEnv: options.deepSeekWebApi.apiKeyEnv, + fetchApiKeyEnv: options.deepSeekWebApi.apiKeyEnv, + ...options.deepSeekWebApi.baseURL === undefined ? {} : { + searchBaseURL: options.deepSeekWebApi.baseURL, fetchBaseURL: options.deepSeekWebApi.baseURL, + }, }, }, - ...options.deepSeekWebApi.select === true - ? [{ id: 'web', config: { searchProvider: 'deepseek-search-api', fetchProvider: 'deepseek-fetch-api' } }] - : [], ], + ...options.deepSeekSearch === undefined ? [] : [{ + id: 'web-search-deepseek', + config: { + apiKeyEnv: options.deepSeekSearch.apiKeyEnv, + ...options.deepSeekSearch.baseURL === undefined ? {} : { baseURL: options.deepSeekSearch.baseURL }, + }, + }], { id: 'llm-deepseek', disabled: mode !== 'record' && !maskDeepSeekCredential }, ] diff --git a/apps/web/tests/web-search-round.e2e.ts b/apps/web/tests/web-search-round.e2e.ts index f831fb6324..a4cbfc5207 100644 --- a/apps/web/tests/web-search-round.e2e.ts +++ b/apps/web/tests/web-search-round.e2e.ts @@ -1,7 +1,8 @@ -// Web e2e scenario for the shipped default search composition. A real browser -// drives `web_search`; the model stream is replayed while the real DeepSeek -// provider calls a deterministic local Search API endpoint through -// the real credentials service. +// Web e2e scenario for the shipped default search provider, the DeepSeek +// Search API, with no provider selection patched. A real browser drives +// `web_search`; the model stream is replayed while the real DeepSeek provider +// calls a deterministic local Search API endpoint through the real +// credentials service. import { readFile } from 'node:fs/promises' import { createServer, type Server } from 'node:http' import type { AddressInfo } from 'node:net' @@ -140,7 +141,7 @@ async function startSearchServer(captured: CapturedSearchRequest[]): Promise<{ s return { server, baseURL: `http://127.0.0.1:${address.port}` } } -describe('web e2e: opt-in Search API web search', () => { +describe('web e2e: default Search API web search', () => { let scaffold: WebScaffold let browser: Browser let page: Page @@ -155,10 +156,10 @@ describe('web e2e: opt-in Search API web search', () => { scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), compareReplaySession: true, + // Own YAML endpoint settings pin Search to the local endpoint and a dedicated key. deepSeekWebApi: { baseURL: `${search.baseURL}/api/v1/tools`, apiKeyEnv: SEARCH_CREDENTIAL_REF, - select: true, }, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }), }) diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 1083094774..ace0b73fe0 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: 3c972fccec17a890 - zh: 94c82847a3c8b83c + en: 278dd0c19178635e + zh: 5c4abda8eb8273a7 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index e333a79f71..31ee229d1e 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -280,8 +280,6 @@ flowchart LR pkg_tool_jobs["tool-jobs"] pkg_web["web"] svc_web["ctx.web
Web access provider registry"] - pkg_web_search_exa["web-search-exa"] - pkg_web_search_perplexity["web-search-perplexity"] pkg_web_search_deepseek["web-search-deepseek"] pkg_web_deepseek_api["web-deepseek-api"] pkg_web_fetch_http["web-fetch-http"] @@ -474,8 +472,6 @@ flowchart LR pkg_web_deepseek_api --> svc_web pkg_web_fetch_http --> svc_web pkg_web_search_deepseek --> svc_web - pkg_web_search_exa --> svc_web - pkg_web_search_perplexity --> svc_web pkg_webhook --> svc_webhookRuntime pkg_workflow --> svc_workflowEngine pkg_workflow_ptc --> svc_workflowEngine @@ -746,7 +742,7 @@ flowchart LR | `ctx.claudeCodeMods` | `core` | [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | - | - | - | Loads mods that defineMod plugins add, raises their hook chains from harness extension points, and draws the band above the prompt over its Remote. | | `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | Producers (background bash/pwsh and PTY sends) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | -| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-deepseek-api`](../packages/web/web-deepseek-api), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | +| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-deepseek-api`](../packages/web/web-deepseek-api), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | | `ctx.webServer` | `core` | [`host-webserver`](../packages/host/webserver) | - | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-hmr`](../packages/client/hmr) | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index cf3f2e67fc..c8688c5536 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -282,8 +282,6 @@ flowchart LR pkg_tool_jobs["tool-jobs"] pkg_web["web"] svc_web["ctx.web
Web access provider registry"] - pkg_web_search_exa["web-search-exa"] - pkg_web_search_perplexity["web-search-perplexity"] pkg_web_search_deepseek["web-search-deepseek"] pkg_web_deepseek_api["web-deepseek-api"] pkg_web_fetch_http["web-fetch-http"] @@ -476,8 +474,6 @@ flowchart LR pkg_web_deepseek_api --> svc_web pkg_web_fetch_http --> svc_web pkg_web_search_deepseek --> svc_web - pkg_web_search_exa --> svc_web - pkg_web_search_perplexity --> svc_web pkg_webhook --> svc_webhookRuntime pkg_workflow --> svc_workflowEngine pkg_workflow_ptc --> svc_workflowEngine @@ -748,7 +744,7 @@ flowchart LR | `ctx.claudeCodeMods` | `core` | [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | - | - | - | 加载 defineMod 插件添加的模组,从 harness 扩展点触发其钩子链,并通过其 Remote 绘制提示框上方的横幅。 | | `ctx.inspector` | `core` | `inspector` | - | - | - | 负责 Worker 托管的 CDP target,以及独立于传输的 Host 和 Client observation 与 Cordis tree query API。 | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | 生产方(后台 bash/pwsh 与 PTY 发送)登记正在运行的工作;声明 record 的 job 还为非消费观察者流式提供原始输出;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | -| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-deepseek-api`](../packages/web/web-deepseek-api), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | +| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-deepseek-api`](../packages/web/web-deepseek-api), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | | `ctx.webServer` | `core` | [`host-webserver`](../packages/host/webserver) | - | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-hmr`](../packages/client/hmr) | - | 普通的 node:http 载体:具名路由注册表、索引转换 tap,以及静态 dist 回退;Web 传输插件注册自己的路由。 | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f61b80c48b..4f6f98475d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -4571,7 +4571,7 @@ export type ApprovalPolicy = 'ask' | 'never' ## `@deepseek-ai/dsh-web` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/web/web/src/index.ts:57`](../packages/web/web/src/index.ts) +- `source`: [`packages/web/web/src/index.ts:62`](../packages/web/web/src/index.ts) ```ts config-catalog /** @@ -4630,17 +4630,16 @@ export interface Config { - `inject`: `web` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/web/web-deepseek-api/src/index.ts:38`](../packages/web/web-deepseek-api/src/index.ts) +- `source`: [`packages/web/web-deepseek-api/src/index.ts:58`](../packages/web/web-deepseek-api/src/index.ts) ```ts config-catalog -/** Settings read by the next search or fetch without re-registering either provider. */ +/** + * Settings read by the next search or fetch without re-registering either provider. A non-empty `*BaseURL` or + * `*ApiKeyEnv` gives that capability its own endpoint and key; with neither, it follows the conversation's DeepSeek + * credential. Stored fields of earlier settings (`baseURL`, `apiKeyEnv`, `apiKey`) are not schema fields: they load + * without error and have no effect. + */ export interface Config { - /** Literal DeepSeek API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ - apiKey: Volatile - /** Credential reference resolved for each operation; defaults to `DEEPSEEK_API_KEY`. */ - apiKeyEnv: Volatile - /** API base including its path prefix; `/search` and `/fetch` are appended. */ - baseURL: Volatile /** Search API results per search, from 1 through 50; a smaller request bound wins. Defaults to 10. */ count: Volatile /** @@ -4651,6 +4650,20 @@ export interface Config { searchRequestsPerSecond: Volatile /** Send the triggering user question, bounded to 128 characters, as `ori_question`. Defaults to true. */ sendOriginalQuestion: Volatile + /** + * Own Search API base including its path prefix; `/search` is appended. With only {@link searchApiKeyEnv} set, the + * official base applies. + */ + searchBaseURL: Volatile + /** Own Search API credential reference. With only {@link searchBaseURL} set, `DEEPSEEK_API_KEY` applies. */ + searchApiKeyEnv: Volatile + /** + * Own Fetch API base including its path prefix; `/fetch` is appended. With only {@link fetchApiKeyEnv} set, the + * official base applies. + */ + fetchBaseURL: Volatile + /** Own Fetch API credential reference. With only {@link fetchBaseURL} set, `DEEPSEEK_API_KEY` applies. */ + fetchApiKeyEnv: Volatile } ``` @@ -4687,7 +4700,7 @@ export interface Config { - `inject`: `web` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/web/web-search-deepseek/src/index.ts:51`](../packages/web/web-search-deepseek/src/index.ts) +- `source`: [`packages/web/web-search-deepseek/src/index.ts:54`](../packages/web/web-search-deepseek/src/index.ts) ```ts config-catalog /** Plugin config (all optional — `apply` fills env-var and constant defaults). */ @@ -4710,56 +4723,6 @@ export interface Config { ``` - - - -## `@deepseek-ai/dsh-web-search-exa` - -- `inject`: `web` -- `source`: [`packages/web/web-search-exa/src/index.ts:35`](../packages/web/web-search-exa/src/index.ts) - -```ts config-catalog -/** Plugin config (all optional — `apply` fills env-var and constant defaults). */ -export interface Config { - /** Exa API key. Falls back to `$EXA_API_KEY`. Empty → provider unavailable. */ - apiKey?: string - /** Endpoint base; `/search` is appended. Defaults to the public API. */ - baseURL?: string - /** Retrieval mode sent as Exa's `type`. Defaults to `auto`. */ - searchType?: 'auto' | 'keyword' | 'neural' - /** Default result count when a request carries no `maxResults`. Omitted = none. */ - numResults?: number - /** Highlight sentences requested per result. Defaults to 1. */ - highlightsPerResult?: number -} -``` - - - - - -## `@deepseek-ai/dsh-web-search-perplexity` - -- `inject`: `web` -- `source`: [`packages/web/web-search-perplexity/src/index.ts:30`](../packages/web/web-search-perplexity/src/index.ts) - -```ts config-catalog -/** Plugin config (all optional — `apply` fills env-var and constant defaults). */ -export interface Config { - /** Perplexity API key. Falls back to `$PERPLEXITY_API_KEY`. Empty → unavailable. */ - apiKey?: string - /** Endpoint base; `/chat/completions` is appended. Defaults to the public API. */ - baseURL?: string - /** Search model name. Defaults to `sonar`. */ - model?: string - /** Upper bound on generated answer tokens. Defaults to 1024. */ - maxTokens?: number - /** Recency window sent as `search_recency_filter`. Omitted = no filter. */ - searchRecency?: 'day' | 'week' | 'month' | 'year' -} -``` - - diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 7521e1cb61..46104ab7d9 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -4573,7 +4573,7 @@ export type ApprovalPolicy = 'ask' | 'never' ## `@deepseek-ai/dsh-web` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/web/web/src/index.ts:57`](../packages/web/web/src/index.ts) +- `source`: [`packages/web/web/src/index.ts:62`](../packages/web/web/src/index.ts) ```ts config-catalog /** @@ -4632,17 +4632,16 @@ export interface Config { - `inject`: `web` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/web/web-deepseek-api/src/index.ts:38`](../packages/web/web-deepseek-api/src/index.ts) +- `source`: [`packages/web/web-deepseek-api/src/index.ts:58`](../packages/web/web-deepseek-api/src/index.ts) ```ts config-catalog -/** Settings read by the next search or fetch without re-registering either provider. */ +/** + * Settings read by the next search or fetch without re-registering either provider. A non-empty `*BaseURL` or + * `*ApiKeyEnv` gives that capability its own endpoint and key; with neither, it follows the conversation's DeepSeek + * credential. Stored fields of earlier settings (`baseURL`, `apiKeyEnv`, `apiKey`) are not schema fields: they load + * without error and have no effect. + */ export interface Config { - /** Literal DeepSeek API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ - apiKey: Volatile - /** Credential reference resolved for each operation; defaults to `DEEPSEEK_API_KEY`. */ - apiKeyEnv: Volatile - /** API base including its path prefix; `/search` and `/fetch` are appended. */ - baseURL: Volatile /** Search API results per search, from 1 through 50; a smaller request bound wins. Defaults to 10. */ count: Volatile /** @@ -4653,6 +4652,20 @@ export interface Config { searchRequestsPerSecond: Volatile /** Send the triggering user question, bounded to 128 characters, as `ori_question`. Defaults to true. */ sendOriginalQuestion: Volatile + /** + * Own Search API base including its path prefix; `/search` is appended. With only {@link searchApiKeyEnv} set, the + * official base applies. + */ + searchBaseURL: Volatile + /** Own Search API credential reference. With only {@link searchBaseURL} set, `DEEPSEEK_API_KEY` applies. */ + searchApiKeyEnv: Volatile + /** + * Own Fetch API base including its path prefix; `/fetch` is appended. With only {@link fetchApiKeyEnv} set, the + * official base applies. + */ + fetchBaseURL: Volatile + /** Own Fetch API credential reference. With only {@link fetchBaseURL} set, `DEEPSEEK_API_KEY` applies. */ + fetchApiKeyEnv: Volatile } ``` @@ -4689,7 +4702,7 @@ export interface Config { - `inject`: `web` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/web/web-search-deepseek/src/index.ts:51`](../packages/web/web-search-deepseek/src/index.ts) +- `source`: [`packages/web/web-search-deepseek/src/index.ts:54`](../packages/web/web-search-deepseek/src/index.ts) ```ts config-catalog /** Plugin config (all optional — `apply` fills env-var and constant defaults). */ @@ -4712,56 +4725,6 @@ export interface Config { ``` - - - -## `@deepseek-ai/dsh-web-search-exa` - -- `inject`: `web` -- `source`: [`packages/web/web-search-exa/src/index.ts:35`](../packages/web/web-search-exa/src/index.ts) - -```ts config-catalog -/** Plugin config (all optional — `apply` fills env-var and constant defaults). */ -export interface Config { - /** Exa API key. Falls back to `$EXA_API_KEY`. Empty → provider unavailable. */ - apiKey?: string - /** Endpoint base; `/search` is appended. Defaults to the public API. */ - baseURL?: string - /** Retrieval mode sent as Exa's `type`. Defaults to `auto`. */ - searchType?: 'auto' | 'keyword' | 'neural' - /** Default result count when a request carries no `maxResults`. Omitted = none. */ - numResults?: number - /** Highlight sentences requested per result. Defaults to 1. */ - highlightsPerResult?: number -} -``` - - - - - -## `@deepseek-ai/dsh-web-search-perplexity` - -- `inject`: `web` -- `source`: [`packages/web/web-search-perplexity/src/index.ts:30`](../packages/web/web-search-perplexity/src/index.ts) - -```ts config-catalog -/** Plugin config (all optional — `apply` fills env-var and constant defaults). */ -export interface Config { - /** Perplexity API key. Falls back to `$PERPLEXITY_API_KEY`. Empty → unavailable. */ - apiKey?: string - /** Endpoint base; `/chat/completions` is appended. Defaults to the public API. */ - baseURL?: string - /** Search model name. Defaults to `sonar`. */ - model?: string - /** Upper bound on generated answer tokens. Defaults to 1024. */ - maxTokens?: number - /** Recency window sent as `search_recency_filter`. Omitted = no filter. */ - searchRecency?: 'day' | 'week' | 'month' | 'year' -} -``` - - diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d88ab4e38d..e4adc50a6a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -89,6 +89,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `forge-session`, `liveshare`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present), `ui-trajectory` | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | +| `web/providers-changed` | `emit` | [`packages/web/web/src/types.ts:21`](../packages/web/web/src/types.ts) | [`web`](../packages/web/web) (`events.dispatch`) | `remotes` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 986d33e3eb..1aef1e479d 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -91,6 +91,7 @@ | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `forge-session`, `liveshare`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present), `ui-trajectory` | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | +| `web/providers-changed` | `emit` | [`packages/web/web/src/types.ts:21`](../packages/web/web/src/types.ts) | [`web`](../packages/web/web) (`events.dispatch`) | `remotes` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 3236cc783c..505b2ad74b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -93,8 +93,6 @@ flowchart TD pkg_web_deepseek_api["web-deepseek-api"] pkg_web_fetch_http["web-fetch-http"] pkg_web_search_deepseek["web-search-deepseek"] - pkg_web_search_exa["web-search-exa"] - pkg_web_search_perplexity["web-search-perplexity"] end subgraph group_spill["packages/spill"] pkg_spill["spill"] @@ -517,10 +515,6 @@ flowchart TD pkg_skill_office --> pkg_skill pkg_web_fetch_http --> pkg_timeout pkg_web_fetch_http --> pkg_web - pkg_web_search_exa --> pkg_launch_environment - pkg_web_search_exa --> pkg_web - pkg_web_search_perplexity --> pkg_launch_environment - pkg_web_search_perplexity --> pkg_web pkg_spill --> pkg_brand pkg_spill --> pkg_llm pkg_spill --> pkg_session @@ -767,16 +761,11 @@ flowchart TD pkg_fs_sandbox --> pkg_fs_local pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy - pkg_web_deepseek_api --> pkg_agent - pkg_web_deepseek_api --> pkg_credentials - pkg_web_deepseek_api --> pkg_deepseek_account - pkg_web_deepseek_api --> pkg_launch_environment - pkg_web_deepseek_api --> pkg_session - pkg_web_deepseek_api --> pkg_web pkg_web_search_deepseek --> pkg_agent pkg_web_search_deepseek --> pkg_credentials pkg_web_search_deepseek --> pkg_deepseek_account pkg_web_search_deepseek --> pkg_launch_environment + pkg_web_search_deepseek --> pkg_llm_deepseek pkg_web_search_deepseek --> pkg_session pkg_web_search_deepseek --> pkg_web pkg_api_account_controller --> pkg_agent @@ -904,6 +893,16 @@ flowchart TD pkg_tool_web --> pkg_system_prompt pkg_tool_web --> pkg_tools pkg_tool_web --> pkg_web + pkg_web_deepseek_api --> pkg_agent + pkg_web_deepseek_api --> pkg_credentials + pkg_web_deepseek_api --> pkg_deepseek_account + pkg_web_deepseek_api --> pkg_launch_environment + pkg_web_deepseek_api --> pkg_llm + pkg_web_deepseek_api --> pkg_llm_deepseek + pkg_web_deepseek_api --> pkg_llm_deepseek_account + pkg_web_deepseek_api --> pkg_llm_deepseek_api_key + pkg_web_deepseek_api --> pkg_session + pkg_web_deepseek_api --> pkg_web pkg_tool_todo --> pkg_agent pkg_tool_todo --> pkg_session pkg_tool_todo --> pkg_session_projection @@ -1681,8 +1680,6 @@ flowchart TD | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) | | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | -| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | -| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | @@ -1759,8 +1756,7 @@ flowchart TD | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`web-deepseek-api`](../packages/web/web-deepseek-api) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | `credentials` | [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`host-webserver`](../packages/host/webserver) | @@ -1785,6 +1781,7 @@ flowchart TD | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `skill` | [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | +| [`web-deepseek-api`](../packages/web/web-deepseek-api) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-account`](../packages/llm/llm-deepseek-account), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 8ce9f2337b..ce635be04d 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -95,8 +95,6 @@ flowchart TD pkg_web_deepseek_api["web-deepseek-api"] pkg_web_fetch_http["web-fetch-http"] pkg_web_search_deepseek["web-search-deepseek"] - pkg_web_search_exa["web-search-exa"] - pkg_web_search_perplexity["web-search-perplexity"] end subgraph group_spill["packages/spill"] pkg_spill["spill"] @@ -519,10 +517,6 @@ flowchart TD pkg_skill_office --> pkg_skill pkg_web_fetch_http --> pkg_timeout pkg_web_fetch_http --> pkg_web - pkg_web_search_exa --> pkg_launch_environment - pkg_web_search_exa --> pkg_web - pkg_web_search_perplexity --> pkg_launch_environment - pkg_web_search_perplexity --> pkg_web pkg_spill --> pkg_brand pkg_spill --> pkg_llm pkg_spill --> pkg_session @@ -769,16 +763,11 @@ flowchart TD pkg_fs_sandbox --> pkg_fs_local pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy - pkg_web_deepseek_api --> pkg_agent - pkg_web_deepseek_api --> pkg_credentials - pkg_web_deepseek_api --> pkg_deepseek_account - pkg_web_deepseek_api --> pkg_launch_environment - pkg_web_deepseek_api --> pkg_session - pkg_web_deepseek_api --> pkg_web pkg_web_search_deepseek --> pkg_agent pkg_web_search_deepseek --> pkg_credentials pkg_web_search_deepseek --> pkg_deepseek_account pkg_web_search_deepseek --> pkg_launch_environment + pkg_web_search_deepseek --> pkg_llm_deepseek pkg_web_search_deepseek --> pkg_session pkg_web_search_deepseek --> pkg_web pkg_api_account_controller --> pkg_agent @@ -906,6 +895,16 @@ flowchart TD pkg_tool_web --> pkg_system_prompt pkg_tool_web --> pkg_tools pkg_tool_web --> pkg_web + pkg_web_deepseek_api --> pkg_agent + pkg_web_deepseek_api --> pkg_credentials + pkg_web_deepseek_api --> pkg_deepseek_account + pkg_web_deepseek_api --> pkg_launch_environment + pkg_web_deepseek_api --> pkg_llm + pkg_web_deepseek_api --> pkg_llm_deepseek + pkg_web_deepseek_api --> pkg_llm_deepseek_account + pkg_web_deepseek_api --> pkg_llm_deepseek_api_key + pkg_web_deepseek_api --> pkg_session + pkg_web_deepseek_api --> pkg_web pkg_tool_todo --> pkg_agent pkg_tool_todo --> pkg_session pkg_tool_todo --> pkg_session_projection @@ -1683,8 +1682,6 @@ flowchart TD | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) | | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | -| [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | -| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | @@ -1761,8 +1758,7 @@ flowchart TD | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`web-deepseek-api`](../packages/web/web-deepseek-api) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | `credentials` | [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`host-webserver`](../packages/host/webserver) | @@ -1787,6 +1783,7 @@ flowchart TD | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `skill` | [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | +| [`web-deepseek-api`](../packages/web/web-deepseek-api) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-account`](../packages/llm/llm-deepseek-account), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction) | diff --git a/docs/subsystems/web.i18n.yaml b/docs/subsystems/web.i18n.yaml index 35ace09aeb..55cee3b518 100644 --- a/docs/subsystems/web.i18n.yaml +++ b/docs/subsystems/web.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/web.md /web-access: - en: 86d12e6e81fb63b8 - zh: 52ed66dcacaf6d85 + en: 3186d79f26bd8c45 + zh: de31c570ea8be79c /web-access/why-one-capability-has-two-operations: en: 380da3cd0aabd52b zh: 1317c26089c3ab07 @@ -15,8 +15,8 @@ en: b96bcdf42ced9eab zh: aa927e2450238557 /web-access/provider-availability: - en: 38515cfa348e0950 - zh: 890b624c15d8aa41 + en: ba99c3876cb05fd9 + zh: cd366d7c3279bd87 /web-access/fetch-network-policy: en: 6851874be5841d90 zh: dc085bf3b82d77df diff --git a/docs/subsystems/web.md b/docs/subsystems/web.md index 14d2e2d573..097b388fd1 100644 --- a/docs/subsystems/web.md +++ b/docs/subsystems/web.md @@ -2,7 +2,7 @@ English | [中文](web.zh.md) -The web access seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) that spans **two operations** (search and fetch) on one `ctx.web` service, split across packages: Service Definition ([dsh-web](../../packages/web/web), `ctx.web` + the provider registries), Service Providers ([dsh-web-search-exa](../../packages/web/web-search-exa), [dsh-web-search-perplexity](../../packages/web/web-search-perplexity), [dsh-web-search-deepseek](../../packages/web/web-search-deepseek), [dsh-web-deepseek-api](../../packages/web/web-deepseek-api), [dsh-web-fetch-http](../../packages/web/web-fetch-http)), and Consumer ([dsh-tool-web](../../packages/web/tool-web), the `web_search`/`web_fetch` tool schemas). Web is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A search-provider swap does not change how the model asks for a query, and a fetch-provider swap does not change how the model asks for a URL. +The web access seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) that spans **two operations** (search and fetch) on one `ctx.web` service, split across packages: Service Definition ([dsh-web](../../packages/web/web), `ctx.web` + the provider registries), Service Providers ([dsh-web-search-deepseek](../../packages/web/web-search-deepseek), [dsh-web-deepseek-api](../../packages/web/web-deepseek-api), [dsh-web-fetch-http](../../packages/web/web-fetch-http)), and Consumer ([dsh-tool-web](../../packages/web/tool-web), the `web_search`/`web_fetch` tool schemas). Web is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A search-provider swap does not change how the model asks for a query, and a fetch-provider swap does not change how the model asks for a URL. Source: [`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts) @@ -26,9 +26,9 @@ interface WebSearchRequest { /** * Upper bound on returned sources; the seam truncates to it. Omitted = no * bound. `dsh-tool-web` always sets it. A provider whose API supports a - * result-count control (Exa's `numResults`) should apply it at the request - * layer as a cost/latency optimization; the seam enforces the bound - * regardless. + * result-count control (the DeepSeek Search API's `count`) should apply it + * at the request layer as a cost/latency optimization; the seam enforces the + * bound regardless. */ readonly maxResults?: number } @@ -37,8 +37,8 @@ interface WebSearchRequest { ```ts type-equiv /** * Normalized search outcome. `content` is optional provider-generated answer - * text or summary (Exa and DeepSeek return none; Perplexity returns a - * generated answer). + * text or summary (DeepSeek returns none; a provider whose API generates an + * answer returns it here). * `sources[]` is the portable citation shape. `truncated` is set by the seam * when it cut `sources[]` down to `maxResults`. */ @@ -56,8 +56,8 @@ interface WebSearchResult { /** * One citeable source. A source always has a URL; `title`, `snippet`, `summary`, * and `publishedAt` are optional because not every provider returns them — forcing - * adapters to invent them would make the seam lie (Perplexity citations may be - * URL-only). `dsh-tool-web` renders `title ?? hostname(url)` for display. + * adapters to invent them would make the seam lie (some providers return + * URL-only citations). `dsh-tool-web` renders `title ?? hostname(url)` for display. */ interface WebSearchSource { readonly url: string @@ -123,9 +123,9 @@ type WebFetchBody = ## Provider availability -A provider's `available(): boolean` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to execution-time selection, not a health system: `search()`/`fetch()` read it to pick a usable provider, and a selection failure surfaces as the structured `WebError` the caller routes on — which carries the branchable detail (the missing id or ambiguous candidate set) in its code and message. +A provider's `available(): boolean` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to execution-time selection, not a health system: `search()`/`fetch()` read it to pick a usable provider, and a selection failure surfaces as the structured `WebError` the caller routes on — which carries the branchable detail (the missing id or ambiguous candidate set) in its code and message. Registration, unlike availability, is observable before an operation runs: `listProviders()` returns every registered search and fetch provider by `id`, optional `title`, and optional localized `description`, and the payload-free `web/providers-changed` event fires after each registration or disposal; availability still shows only when an operation runs. -A search or fetch provider may also implement `ready(llmProvider): Promise`, a configuration-only check that must not make network calls: it answers whether an operation made for a model request on that chat route has a credential to send. `searchReady(llmProvider?)` and `fetchReady(llmProvider?)` ask only the provider that `search()` or `fetch()` would select, treat a provider without `ready()` as ready, and answer true when selection fails, because the operation reports that failure as a `WebError`. Readiness is not a selection input; `dsh-tool-web` makes it the activation condition of `web_search` and `web_fetch`, so a model step whose route answers false neither declares nor dispatches the tool. +A search or fetch provider may also implement `ready(llmProvider): Promise`, a configuration-only check that must not make network calls: it answers whether an operation made for a model request on that chat route has a credential to send. `searchReady(llmProvider?)` and `fetchReady(llmProvider?)` ask only the provider that `search()` or `fetch()` would select, treat a provider without `ready()` as ready, and answer true when selection fails, because the operation reports that failure as a `WebError`. Readiness is not a selection input; `dsh-tool-web` makes it the activation condition of `web_search` and `web_fetch`, so a model step whose route answers false neither declares nor dispatches the tool. The DeepSeek Search and Fetch API providers use the route to choose between the account token and the API key, as the [web-deepseek-api authentication](../../packages/web/web-deepseek-api/README.md#authentication) section describes. Selection never depends on registration, config, or HMR order: a capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or the matching env var feeding the same field), or auto-selects when exactly one usable provider is registered; multiple usable providers with no configured id is `WEB_PROVIDER_AMBIGUOUS`, not first-wins. Each operation reads the configured id when it runs, so a saved settings change applies to the next search or fetch without remounting `ctx.web`. @@ -155,7 +155,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.web` — `WebRuntime` -The web access service. Registered as `ctx.web` (one instance per context). +The web access service. Registered as `ctx.web` (one instance per context) and exposed to Clients as the `web` Remote namespace (`listProviders`). Selection semantics (resolved at execution time, never order-dependent): @@ -168,23 +168,32 @@ Selection semantics (resolved at execution time, never order-dependent): ```ts cordis-catalog /** - * Register a search provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` - * if its id is already registered for search. Returns a disposer; disposed - * with the calling fiber. + * Register a search provider. `listProviders()` lists it until the + * registration is disposed, which happens with the calling fiber or through + * the returned disposer; registration and disposal each emit + * `web/providers-changed`. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` + * if its id is already registered for search. * @param provider - the provider; its `id` is the registry key. * @returns the disposer that unregisters the provider. */ registerSearchProvider(provider: WebSearchProvider): () => void /** - * Register a fetch provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` - * if its id is already registered for fetch. Returns a disposer; disposed - * with the calling fiber. + * Register a fetch provider, with the listing, disposal, and event behavior + * of {@link WebRuntime.registerSearchProvider}. Throws {@link WebError} + * `WEB_DUPLICATE_PROVIDER` if its id is already registered for fetch. * @param provider - the provider; its `id` is the registry key. * @returns the disposer that unregisters the provider. */ registerFetchProvider(provider: WebFetchProvider): () => void +/** + * List every registered search and fetch provider for provider pickers. + * Availability does not filter the lists. + * @returns detached `{ id, title, description }` entries per capability, in registration order. + */ +@Remote listProviders(): WebProviderList + /** * Run one search through the selected provider. Resolves the provider at call * time with the selection rules above; throws {@link WebError} when the @@ -228,4 +237,28 @@ async fetchReady(llmProvider?: string): Promise ``` Source: [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts) + + + +### `web/*` events + + + +#### `web/providers-changed` — emit + +The set of registered search or fetch providers changed: one provider registered, or its registration was disposed. Payload-free; consumers re-read `ctx.web.listProviders()` (Remote `web/listProviders`) for the new list. Fires after each committed registry mutation. Observer failures are contained and cannot veto the mutation. + +```ts cordis-catalog +/** + * The set of registered search or fetch providers changed: one provider + * registered, or its registration was disposed. Payload-free; consumers + * re-read `ctx.web.listProviders()` (Remote `web/listProviders`) for the + * new list. Fires after each committed registry mutation. Observer failures + * are contained and cannot veto the mutation. + * @mode emit + */ +'web/providers-changed'(): void +``` + +Source: [`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts) diff --git a/docs/subsystems/web.zh.md b/docs/subsystems/web.zh.md index 92441547a9..38a1b088f4 100644 --- a/docs/subsystems/web.zh.md +++ b/docs/subsystems/web.zh.md @@ -2,7 +2,7 @@ [English](web.md) | 中文 -Web 访问 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md),在同一个 `ctx.web` 服务上横跨**两项操作**(search 与 fetch),并拆分到多个包:Service Definition([dsh-web](../../packages/web/web),`ctx.web` + 提供方注册表)、Service Provider([dsh-web-search-exa](../../packages/web/web-search-exa)、[dsh-web-search-perplexity](../../packages/web/web-search-perplexity)、[dsh-web-search-deepseek](../../packages/web/web-search-deepseek)、[dsh-web-deepseek-api](../../packages/web/web-deepseek-api)、[dsh-web-fetch-http](../../packages/web/web-fetch-http))与 Consumer([dsh-tool-web](../../packages/web/tool-web),即 `web_search`/`web_fetch` 工具 schema)。Web 是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.zh.md) 中。更换 search 提供方不会改变模型提交查询的方式,更换 fetch 提供方也不会改变模型请求 URL 的方式。 +Web 访问 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md),在同一个 `ctx.web` 服务上横跨**两项操作**(search 与 fetch),并拆分到多个包:Service Definition([dsh-web](../../packages/web/web),`ctx.web` + 提供方注册表)、Service Provider([dsh-web-search-deepseek](../../packages/web/web-search-deepseek)、[dsh-web-deepseek-api](../../packages/web/web-deepseek-api)、[dsh-web-fetch-http](../../packages/web/web-fetch-http))与 Consumer([dsh-tool-web](../../packages/web/tool-web),即 `web_search`/`web_fetch` 工具 schema)。Web 是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.zh.md) 中。更换 search 提供方不会改变模型提交查询的方式,更换 fetch 提供方也不会改变模型请求 URL 的方式。 源码:[`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts) @@ -26,9 +26,9 @@ interface WebSearchRequest { /** * Upper bound on returned sources; the seam truncates to it. Omitted = no * bound. `dsh-tool-web` always sets it. A provider whose API supports a - * result-count control (Exa's `numResults`) should apply it at the request - * layer as a cost/latency optimization; the seam enforces the bound - * regardless. + * result-count control (the DeepSeek Search API's `count`) should apply it + * at the request layer as a cost/latency optimization; the seam enforces the + * bound regardless. */ readonly maxResults?: number } @@ -37,8 +37,8 @@ interface WebSearchRequest { ```ts type-equiv /** * Normalized search outcome. `content` is optional provider-generated answer - * text or summary (Exa and DeepSeek return none; Perplexity returns a - * generated answer). + * text or summary (DeepSeek returns none; a provider whose API generates an + * answer returns it here). * `sources[]` is the portable citation shape. `truncated` is set by the seam * when it cut `sources[]` down to `maxResults`. */ @@ -56,8 +56,8 @@ interface WebSearchResult { /** * One citeable source. A source always has a URL; `title`, `snippet`, `summary`, * and `publishedAt` are optional because not every provider returns them — forcing - * adapters to invent them would make the seam lie (Perplexity citations may be - * URL-only). `dsh-tool-web` renders `title ?? hostname(url)` for display. + * adapters to invent them would make the seam lie (some providers return + * URL-only citations). `dsh-tool-web` renders `title ?? hostname(url)` for display. */ interface WebSearchSource { readonly url: string @@ -123,9 +123,9 @@ type WebFetchBody = ## 提供方可用性 -提供方的 `available(): boolean` 是一个廉价的本地检查(凭证是否存在、配置是否可解析),**禁止发起网络调用**。它是执行时选择提供方的输入,而不是健康检查系统:`search()`/`fetch()` 会读取它来选择可用的提供方。选择失败时,调用方会收到可据以分支处理的结构化 `WebError`;其错误代码和消息会说明缺失的 id 或存在歧义的候选集。 +提供方的 `available(): boolean` 是一个廉价的本地检查(凭证是否存在、配置是否可解析),**禁止发起网络调用**。它是执行时选择提供方的输入,而不是健康检查系统:`search()`/`fetch()` 会读取它来选择可用的提供方。选择失败时,调用方会收到可据以分支处理的结构化 `WebError`;其错误代码和消息会说明缺失的 id 或存在歧义的候选集。与可用性不同,注册情况在操作运行前即可观测:`listProviders()` 按 `id`、可选的 `title` 和可选的多语言 `description` 返回每个已注册的搜索与抓取提供方,每次注册或注销后都会触发不带载荷的 `web/providers-changed` 事件;可用性仍只在操作运行时体现。 -搜索或抓取提供方还可以实现 `ready(llmProvider): Promise`,这是只检查配置、禁止发起网络调用的判断:它回答为该对话路由上的模型请求发起的操作是否有可发送的凭据。`searchReady(llmProvider?)` 与 `fetchReady(llmProvider?)` 只询问 `search()` 或 `fetch()` 会选中的提供方,把没有 `ready()` 的提供方视为就绪,并在选择失败时回答 true,因为对应操作会把该失败报告为 `WebError`。就绪判断不是选择输入;`dsh-tool-web` 把它作为 `web_search` 与 `web_fetch` 的激活条件,因此路由回答 false 的模型步骤既不声明也不派发该工具。 +搜索或抓取提供方还可以实现 `ready(llmProvider): Promise`,这是只检查配置、禁止发起网络调用的判断:它回答为该对话路由上的模型请求发起的操作是否有可发送的凭据。`searchReady(llmProvider?)` 与 `fetchReady(llmProvider?)` 只询问 `search()` 或 `fetch()` 会选中的提供方,把没有 `ready()` 的提供方视为就绪,并在选择失败时回答 true,因为对应操作会把该失败报告为 `WebError`。就绪判断不是选择输入;`dsh-tool-web` 把它作为 `web_search` 与 `web_fetch` 的激活条件,因此路由回答 false 的模型步骤既不声明也不派发该工具。DeepSeek Search 与 Fetch API 提供方依据该路由在账号令牌与 API 密钥之间选择,见 [web-deepseek-api 鉴权](../../packages/web/web-deepseek-api/README.zh.md#authentication)一节。 选择从不依赖注册顺序、配置顺序或 HMR(热模块替换)顺序:一项能力要么有显式的提供方 id(配置 `searchProvider`/`fetchProvider`,或填充同一字段的对应环境变量),要么在恰好只有一个可用提供方注册时自动选择;如果存在多个可用提供方却未配置 id,则抛出 `WEB_PROVIDER_AMBIGUOUS`,而不会选用最先注册的提供方。每次操作在执行时读取配置的 id,因此保存的设置变更无需重新挂载 `ctx.web` 即对下一次搜索或抓取生效。 @@ -155,7 +155,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.web` — `WebRuntime` -The web access service. Registered as `ctx.web` (one instance per context). +The web access service. Registered as `ctx.web` (one instance per context) and exposed to Clients as the `web` Remote namespace (`listProviders`). Selection semantics (resolved at execution time, never order-dependent): @@ -168,23 +168,32 @@ Selection semantics (resolved at execution time, never order-dependent): ```ts cordis-catalog /** - * Register a search provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` - * if its id is already registered for search. Returns a disposer; disposed - * with the calling fiber. + * Register a search provider. `listProviders()` lists it until the + * registration is disposed, which happens with the calling fiber or through + * the returned disposer; registration and disposal each emit + * `web/providers-changed`. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` + * if its id is already registered for search. * @param provider - the provider; its `id` is the registry key. * @returns the disposer that unregisters the provider. */ registerSearchProvider(provider: WebSearchProvider): () => void /** - * Register a fetch provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` - * if its id is already registered for fetch. Returns a disposer; disposed - * with the calling fiber. + * Register a fetch provider, with the listing, disposal, and event behavior + * of {@link WebRuntime.registerSearchProvider}. Throws {@link WebError} + * `WEB_DUPLICATE_PROVIDER` if its id is already registered for fetch. * @param provider - the provider; its `id` is the registry key. * @returns the disposer that unregisters the provider. */ registerFetchProvider(provider: WebFetchProvider): () => void +/** + * List every registered search and fetch provider for provider pickers. + * Availability does not filter the lists. + * @returns detached `{ id, title, description }` entries per capability, in registration order. + */ +@Remote listProviders(): WebProviderList + /** * Run one search through the selected provider. Resolves the provider at call * time with the selection rules above; throws {@link WebError} when the @@ -228,4 +237,28 @@ async fetchReady(llmProvider?: string): Promise ``` Source: [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts) + + + +### `web/*` events + + + +#### `web/providers-changed` — emit + +The set of registered search or fetch providers changed: one provider registered, or its registration was disposed. Payload-free; consumers re-read `ctx.web.listProviders()` (Remote `web/listProviders`) for the new list. Fires after each committed registry mutation. Observer failures are contained and cannot veto the mutation. + +```ts cordis-catalog +/** + * The set of registered search or fetch providers changed: one provider + * registered, or its registration was disposed. Payload-free; consumers + * re-read `ctx.web.listProviders()` (Remote `web/listProviders`) for the + * new list. Fires after each committed registry mutation. Observer failures + * are contained and cannot veto the mutation. + * @mode emit + */ +'web/providers-changed'(): void +``` + +Source: [`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts) diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 4a45861506..879a5e290a 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -6,8 +6,8 @@ en: 0d7399a58529b9eb zh: 7f445b06430f21a4 /testing-policy/tiers: - en: c024da7e8d9cb056 - zh: c4cc1a66d2903656 + en: ae5eb9cc384542cb + zh: 3473df2dc1e36bd0 /testing-policy/how-specs-execute: en: 022c534e72e23142 zh: c07a20994224f37c diff --git a/docs/testing.md b/docs/testing.md index 1ba63f9c35..9064aef93d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -8,7 +8,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Unit** (`pnpm run test`): vitest over package and example specs under their `tests/**` directories plus repository script specs under `scripts/**/*.spec.ts`; tests stay with the code area they exercise. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent tests for contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate flags for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. Per-file 100% on `packages/shell/pwsh-local/src` needs a real `pwsh`: without one its executor suites self-skip and `vitest.config.ts` exempts the file so pwsh-less hosts stay green, while CI runners ship pwsh and enforce the full bar. -- **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). +- **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys; each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Owner-local expected output** (`pnpm run test:expected`): keyless assembled CLI/process expectations without a recorded-session round trip. Drivers use `*.expected.e2e.ts` beside `tests/expected/`; CI runs built exports. Package/script expectations use `test`, while browser expectations use `test:web`. - **Performance benchmarks** (`pnpm run test:bench`; required Linux PR gate `node 24 / benchmarks`): `benchmarks/` groups user-path gates. It builds libraries and workers; timed code runs under plain Node, never TSX. Synthetic inputs enforce time, heap, and scaling budgets; package-local `.perf.ts` stays diagnostic ([rules](../.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md)). - **Snapshot** (`pnpm run test:snapshot`): a top-level scenario's highest recorded parent generation supplies user input and model replay, then serves as the expected persisted result. Parent filenames are `session[.vN].jsonl`; child roles are `session.[.vN].jsonl`; v0 omits `.v0`, positive versions require lowercase `.vN`, and each filename must agree with its header. Process scenarios start through `dsh`: headless owns one-shot behavior, the SDK owns persistent control, ACP owns automation-protocol behavior, and Web retains browser/ARIA evidence beside the same Session. `snapshot.yml` declares the profile, composition/header class, recording policy, exceptional replay or input metadata, and workspace facts. Typed tokens preserve parent/child identity relationships; only header pins own prompt/schema sidecars. A mutating scenario independently compares the complete `workspace.expected/` tree, which record and refresh never rewrite. Use `test:snapshot:record` when a model transcript changes and `test:snapshot:refresh` when replay input remains valid; review every resulting diff. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index c7545a9686..f756cc9306 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -8,7 +8,7 @@ - **单元测试**(`pnpm run test`):vitest 运行包和示例各自的 `tests/**` 目录下的测试,以及匹配 `scripts/**/*.spec.ts` 的仓库脚本测试;测试文件与其所覆盖的代码区域放在一起。每个注册表都有一个 HMR(热模块替换)安全测试(对向该注册表贡献内容的 fiber 执行 dispose(资源释放),并断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及针对约定回归的永久测试(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 - **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。`packages/shell/pwsh-local/src` 的按文件 100% 覆盖需要真实的 `pwsh`:缺少它时其执行器套件会自动跳过,`vitest.config.ts` 会豁免该文件以使无 pwsh 的主机保持绿色,而 CI runner 自带 pwsh,仍按完整标准执行门禁。 -- **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md))。 +- **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制,缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.zh.md))。 - **所属位置的预期输出**(`pnpm run test:expected`):无录制会话往返的无密钥组装 CLI/进程预期。驱动使用 `*.expected.e2e.ts`,并与 `tests/expected/` 同属一处;CI 针对构建产物运行。包/脚本预期使用 `test`,浏览器预期使用 `test:web`。 - **性能基准**(`pnpm run test:bench`;必需的 Linux PR gate `node 24 / benchmarks`):`benchmarks/` 按用户路径组织门禁。它先构建 library 和 worker;被计时代码在纯 Node 下运行,不使用 TSX。合成输入执行耗时、堆和缩放预算;包内 `.perf.ts` 保留为诊断([规则](../.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md))。 - **快照**(`pnpm run test:snapshot`):顶层场景数值最高的已录制 parent generation 同时提供用户输入和模型回放,并作为持久化结果的预期值。parent 文件名是 `session[.vN].jsonl`;child 角色使用 `session.[.vN].jsonl`;v0 省略 `.v0`,正版本必须使用小写 `.vN`,且每个文件名必须与其 header 一致。进程级场景都通过 `dsh` 启动:headless 负责一次性行为,SDK 负责持久控制,ACP 负责自动化协议行为,Web 在同一 Session 旁保留浏览器与 ARIA 证据。`snapshot.yml` 声明 profile、组合与请求头类别、录制策略、例外回放或输入元数据以及 workspace 事实。带类型的 token 保留父子身份关系;只有请求头 pin 拥有 prompt/schema sidecar。变更 workspace 的场景会独立比较完整的 `workspace.expected/` 目录,record 与 refresh 绝不改写该目录。当模型 transcript(文本记录)变化时使用 `test:snapshot:record`,回放输入仍有效时使用 `test:snapshot:refresh`;请审查所有结果差异。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.i18n.yaml new file mode 100644 index 0000000000..f0cb2c310b --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.md +/: + en: 5b07a9a586fe0ef8 + zh: 957123dc3a23c9a9 +/deepseek-web-search-and-fetch-follow-the-deepseek-model-route: + en: bf3ccc3a2347cdbf + zh: ff4557b79d972e56 +/deepseek-web-search-and-fetch-follow-the-deepseek-model-route/change: + en: ff12048c0d6a6e11 + zh: 159e178776739bbe +/deepseek-web-search-and-fetch-follow-the-deepseek-model-route/migration: + en: 97da49ad7b5342fa + zh: ce0f49d638045891 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.md new file mode 100644 index 0000000000..aa3eefe5fc --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "DeepSeek web search and fetch take their endpoint from the DeepSeek model route; the shared baseURL, apiKeyEnv, and apiKey fields are removed in favor of per-capability fields." +--- + +# DeepSeek web search and fetch follow the DeepSeek model route + +English | [中文](guide.zh.md) + +## Change + +Previously, the DeepSeek Search API and Fetch API (`web-deepseek-api`) sent their credential to one shared `baseURL` (default `https://api.deepseek.com/v1/tools`): the account token when the Session's latest model request used the `deepseek-account` route and the user was signed in, otherwise the key under `apiKeyEnv` (default `DEEPSEEK_API_KEY`) or a literal `apiKey`. Now each capability follows the same route rule but takes the endpoint from the route: on `deepseek-account` the account token goes to the account route's origin with `/v1/tools`; otherwise, including a signed-out account conversation, the key under the `apiKeyEnv` of the `deepseek-official` route goes to that route's origin with `/v1/tools`, so `DEEPSEEK_BASE_URL` now moves Search and Fetch too. Without a `deepseek-official` route, `DEEPSEEK_API_KEY` goes to `https://api.deepseek.com/v1/tools`; a mounted route whose settings do not resolve fails the operation instead. The `baseURL`, `apiKeyEnv`, and `apiKey` fields are removed; stored values load without error and are ignored. Setting either `searchBaseURL` or `searchApiKeyEnv` (`fetchBaseURL` or `fetchApiKeyEnv` for Fetch) pins that capability to its own endpoint (default `https://api.deepseek.com/v1/tools`) and key (default `DEEPSEEK_API_KEY`) and never sends the account token. These fields are YAML-only. Users without those YAML fields, without `DEEPSEEK_BASE_URL`, and without a custom model base URL see no change. + +## Migration + +1. Users who set `baseURL`, `apiKeyEnv`, or a literal `apiKey` on the `web-deepseek-api` row: for each of Search and Fetch, compare the old endpoint and key with what the route now sends, the origin of the DeepSeek model provider's base URL with `/v1/tools` and that provider's `apiKeyEnv`. When both match, remove the old fields. Otherwise set the pair, `searchBaseURL` with `searchApiKeyEnv` (`fetchBaseURL` with `fetchApiKeyEnv`), to the old endpoint and key reference, setting both fields even when one old value equals the route's: an unset field of the pair falls back to `https://api.deepseek.com/v1/tools` or `DEEPSEEK_API_KEY`, never to the route's value. For a literal `apiKey`, store it in the credential store or the launching environment under a reference and use that reference. A pinned capability never sends the account token. Confirm: `dsh` starts, and a search and a fetch reach the intended endpoint without a credential error. +2. Users with `DEEPSEEK_BASE_URL` or a custom base URL on the DeepSeek model provider whose host does not serve `/v1/tools`: set the same pair to `https://api.deepseek.com/v1/tools` and the reference of a key that endpoint accepts. Confirm: a search returns results. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.zh.md new file mode 100644 index 0000000000..ad2ff9a93b --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.zh.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "DeepSeek 网页搜索与获取从 DeepSeek 模型路由取得端点;共享的 baseURL、apiKeyEnv 与 apiKey 字段已移除,改用按能力区分的字段。" +--- + +# DeepSeek 网页搜索与获取跟随 DeepSeek 模型路由 + +[English](guide.md) | 中文 + +## 变更 + +过去,DeepSeek Search API 与 Fetch API(`web-deepseek-api`)把凭据发往同一个共享的 `baseURL`(默认 `https://api.deepseek.com/v1/tools`):会话最近一次模型请求使用 `deepseek-account` 路由且用户已登录时发送账号令牌,否则发送 `apiKeyEnv`(默认 `DEEPSEEK_API_KEY`)下的密钥或字面 `apiKey`。现在每个能力沿用相同的路由规则,但端点取自路由:在 `deepseek-account` 上把账号令牌发往账号路由的 origin 加 `/v1/tools`;其他情况(包括未登录的账号会话)把 `deepseek-official` 路由 `apiKeyEnv` 下的密钥发往该路由的 origin 加 `/v1/tools`,因此 `DEEPSEEK_BASE_URL` 现在也会改变 Search 与 Fetch 的端点。没有 `deepseek-official` 路由时,把 `DEEPSEEK_API_KEY` 发往 `https://api.deepseek.com/v1/tools`;已挂载的路由设置无法解析时,操作直接失败。`baseURL`、`apiKeyEnv` 与 `apiKey` 字段已移除;已保存的值可以正常加载,但会被忽略。只要设置 `searchBaseURL` 或 `searchApiKeyEnv` 中的任一项(Fetch 为 `fetchBaseURL` 或 `fetchApiKeyEnv`),就会把该能力固定到其自己的端点(默认 `https://api.deepseek.com/v1/tools`)和密钥(默认 `DEEPSEEK_API_KEY`),且从不发送账号令牌。这些字段只能在 YAML 中设置。没有设置这些 YAML 字段、没有设置 `DEEPSEEK_BASE_URL`、也没有自定义模型基础地址的用户不受影响。 + +## 迁移 + +1. 在 `web-deepseek-api` 条目上设置过 `baseURL`、`apiKeyEnv` 或字面 `apiKey` 的用户:对 Search 与 Fetch 分别比较原端点和密钥与路由现在发送的值,即 DeepSeek 模型提供方基础地址的 origin 加 `/v1/tools`,以及该提供方的 `apiKeyEnv`。两者都一致时,删除旧字段。否则把成对字段 `searchBaseURL` 与 `searchApiKeyEnv`(Fetch 为 `fetchBaseURL` 与 `fetchApiKeyEnv`)设为原端点和原密钥引用;即使其中一个原值与路由的值相同,也要两个字段都设置:成对字段中未设置的一项回退到 `https://api.deepseek.com/v1/tools` 或 `DEEPSEEK_API_KEY`,不会继承路由的值。设置过字面 `apiKey` 时,把该值以某个引用存入凭据存储或启动环境,并使用该引用。单独配置的能力从不发送账号令牌。确认:`dsh` 正常启动,且搜索和抓取都到达预期端点,没有凭据错误。 +2. 设置了 `DEEPSEEK_BASE_URL` 或为 DeepSeek 模型提供方设置了自定义基础地址、且该主机不提供 `/v1/tools` 的用户:把同一对字段设为 `https://api.deepseek.com/v1/tools` 和该端点接受的密钥引用。确认:搜索返回结果。 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.i18n.yaml new file mode 100644 index 0000000000..bee12dc3c8 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.md +/: + en: 2a8012259f83b47f + zh: 913cf3290acedfd0 +/search-defaults-to-the-deepseek-search-api-exa-and-perplexity-are-removed: + en: d74d9ae56bde707f + zh: 45eafef25e659780 +/search-defaults-to-the-deepseek-search-api-exa-and-perplexity-are-removed/change: + en: acb404257af50295 + zh: c22c29e18fc1dbab +/search-defaults-to-the-deepseek-search-api-exa-and-perplexity-are-removed/migration: + en: 5f69931cdd39ea2f + zh: 7e569007c068151c diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.md new file mode 100644 index 0000000000..59d6706645 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "The shipped Search default moves from legacy Messages search to the DeepSeek Search API, and the optional Exa and Perplexity search packages are removed." +--- + +# Search defaults to the DeepSeek Search API; Exa and Perplexity are removed + +English | [中文](guide.zh.md) + +## Change + +Previously, the shared base composition set `web.searchProvider` to `deepseek-official`: each search was an auxiliary Messages request to `web-search-deepseek`'s endpoint, sending `Perform a web search for the query: ` to `deepseek-v4-flash` with a server-side `web_search` tool, billed as model tokens. Now it sets `deepseek-search-api`: each search is one direct request to the DeepSeek Search API, without a model request, and may incur Search API charges instead of model tokens. That request sends `query`, `count`, `need_content: false`, and, unless `web-deepseek-api.sendOriginalQuestion` is `false`, the user's latest prompt cut to 128 characters as `ori_question`. Its credential and endpoint follow the [DeepSeek model route](../web-search-credential-modes/guide.md). Compositions that set `web.searchProvider` themselves see no change, and Fetch still defaults to `http`. + +The optional packages `@deepseek-ai/dsh-web-search-exa` (provider `exa`) and `@deepseek-ai/dsh-web-search-perplexity` (provider `perplexity`) are removed. A row naming either package no longer loads, and `searchProvider: exa` or `searchProvider: perplexity` names no provider. + +## Migration + +1. To keep legacy Messages search, set `searchProvider: deepseek-official` in the `config` of the `@deepseek-ai/dsh-web` row, or select DeepSeek (legacy) in the Web search provider menu on the Web search and fetch settings page. Confirm: that menu shows DeepSeek (legacy), and a search appends a `web/deepseek-search-llm-request` event to the Session log. +2. Deployments that mounted Exa or Perplexity: delete the `@deepseek-ai/dsh-web-search-exa` or `@deepseek-ai/dsh-web-search-perplexity` row from `cordis.yml` or the profile patch, remove `EXA_API_KEY` or `PERPLEXITY_API_KEY` if nothing else uses it, and set `searchProvider` to `deepseek-search-api` or `deepseek-official`, or delete it to take the default. Confirm: `dsh` starts without a load error for either package, the Web search provider menu shows the chosen provider, and a search returns results. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.zh.md new file mode 100644 index 0000000000..09614fdd2c --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-alpha.2/web-search-default-provider/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "交付的 Search 默认值从旧版 Messages 搜索改为 DeepSeek Search API,可选的 Exa 与 Perplexity 搜索包已移除。" +--- + +# Search 默认使用 DeepSeek Search API;Exa 与 Perplexity 已移除 + +[English](guide.md) | 中文 + +## 变更 + +过去,共享的基础组合把 `web.searchProvider` 设为 `deepseek-official`:每次搜索都是发往 `web-search-deepseek` 端点的一次辅助 Messages 请求,以 `Perform a web search for the query: ` 调用带服务端 `web_search` 工具的 `deepseek-v4-flash`,按模型 token 计费。现在它设为 `deepseek-search-api`:每次搜索是一次直接发往 DeepSeek Search API 的请求,不调用模型,可能产生 Search API 费用而非模型 token 费用。该请求发送 `query`、`count`、`need_content: false`,并且除非 `web-deepseek-api.sendOriginalQuestion` 为 `false`,还会把用户最近一条提示截取前 128 个字符作为 `ori_question` 发送。其凭据和端点跟随 [DeepSeek 模型路由](../web-search-credential-modes/guide.zh.md)。自行设置了 `web.searchProvider` 的组合不受影响,Fetch 仍默认为 `http`。 + +可选包 `@deepseek-ai/dsh-web-search-exa`(提供方 `exa`)与 `@deepseek-ai/dsh-web-search-perplexity`(提供方 `perplexity`)已移除。指名任一包的条目不再能加载,`searchProvider: exa` 或 `searchProvider: perplexity` 也不再对应任何提供方。 + +## 迁移 + +1. 要继续使用旧版 Messages 搜索,在 `@deepseek-ai/dsh-web` 条目的 `config` 中设置 `searchProvider: deepseek-official`,或在「网页搜索与获取」设置页的「网页搜索提供方」菜单中选择 DeepSeek(旧版)。确认:该菜单显示 DeepSeek(旧版),且每次搜索都会向会话日志追加一个 `web/deepseek-search-llm-request` 事件。 +2. 挂载过 Exa 或 Perplexity 的部署:从 `cordis.yml` 或 profile 补丁中删除 `@deepseek-ai/dsh-web-search-exa` 或 `@deepseek-ai/dsh-web-search-perplexity` 条目;若 `EXA_API_KEY` 或 `PERPLEXITY_API_KEY` 不再被其他地方使用,一并移除;再把 `searchProvider` 设为 `deepseek-search-api` 或 `deepseek-official`,或删除该字段以采用默认值。确认:`dsh` 启动时没有这两个包的加载错误,「网页搜索提供方」菜单显示所选提供方,且搜索返回结果。 diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index 64b8496be6..4178adfa26 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/README.i18n.yaml @@ -15,11 +15,11 @@ en: a7a78c6d1aeebeb3 zh: 4bb69f32d8b05ba3 /deepseek-ai-dsh-api-remotes/use-this-package: - en: 6e3448339c646a7e - zh: 5dc00ff4cceaa88c + en: 5e5770cc1cbf49db + zh: b9ee3c309d0cd93c /deepseek-ai-dsh-api-remotes/forwarded-host-events: - en: 31bb9a2f50094b84 - zh: 226c055739d5def7 + en: 1dea001229f8c06a + zh: 3acb1fa1c5d24f8c /deepseek-ai-dsh-api-remotes/build-boundary: en: a73c2b7e1069a750 zh: 5b3f87a9ea29cb14 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index a05773a4af..71e5dc385f 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -29,7 +29,7 @@ Two-sided BFF for Host Remote capabilities selected by this application. The Hos [`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.md) owns Agent and Session identity policy, including the Typert lookup resolvers used by other namespaces. This package selects and mounts that generated Session contribution without a direct runtime dependency on `@deepseek-ai/dsh-session`; activation policy stays with Session Controller. -The Client assembly mounts Office conversion, Commands, Agent Teams, credentials, settings, Goal, Schedule, dynamic Cordis, file and Session references, read-only Host plugin inventory, plugin-installation registry response comparison, message feedback, permission presets, Session Controller, subagents, and Workspace Controller contributions. The `permissionPresets` namespace returns the complete process-level catalog used by current-session controls. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. +The Client assembly mounts Office conversion, Commands, Agent Teams, credentials, settings, Goal, Schedule, dynamic Cordis, file and Session references, read-only Host plugin inventory, plugin-installation registry response comparison, message feedback, permission presets, Session Controller, subagents, web provider list, and Workspace Controller contributions. The `permissionPresets` namespace returns the complete process-level catalog used by current-session controls. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. This facade is also the front door for the wire type vocabulary a Client package names. It re-exports, type-only, the Remote failure vocabulary (`RemoteResult`, `RemoteFailure`, `RemoteErrorCode`, `RemoteErrorDetailsMap`), the Host facts (`RemoteHostFacts`), and each selected domain's client-safe payload types, so a Client feature package imports one specifier instead of reaching into `dsh-typert-protocol`, the Gateway, or an owner's Host entry. Two kinds of package deliberately skip this door: the API-layer packages this assembly itself selects — importing it back would close a dependency cycle — and their tests, which take the failure vocabulary from `dsh-typert-protocol` directly. A UI package's tests instead take the `RemoteError` constructor from [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md). @@ -48,7 +48,7 @@ An `emit` entry may declare `resources`, readers keyed by `TypertResourceMap` ki The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. Each scoped waterfall request must carry its routed Agent directly as `request.agent`; the Host rejects a missing or mismatched identity before forwarding. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active and carries the Host home for Client path display. Withdrawing the registration aborts active streams. -The payload-free `schedule/changed` event invalidates Schedule task queries; Clients re-read after a notification and reconnect. The payload-free `permission-presets/catalog-changed` event invalidates the process catalog. A Client subscribes before its first `permissionPresets.catalog()` read and re-reads the complete snapshot after a notification; the event carries no catalog state and changes no Session sequence. +The payload-free `schedule/changed` event invalidates Schedule task queries; Clients re-read after a notification and reconnect. The payload-free `permission-presets/catalog-changed` event invalidates the process catalog. A Client subscribes before its first `permissionPresets.catalog()` read and re-reads the complete snapshot after a notification; the event carries no catalog state and changes no Session sequence. The payload-free `web/providers-changed` event invalidates the `web.listProviders()` provider lists; Clients re-read after a notification and reconnect. ## Build boundary diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index bc616b9588..5bef7cb29c 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" [`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.zh.md) 拥有 agent(智能体)与会话身份策略,包括供其他 namespace 使用的 Typert lookup 解析器。本包选择并挂载生成的会话 contribution,无需直接依赖 `@deepseek-ai/dsh-session` 的运行时;激活策略由会话控制器负责。 -Client 组合挂载 Office 转换、Commands、Agent Teams、凭据、settings、Goal、Schedule、动态 Cordis、文件与会话引用、只读 Host 插件清单、插件安装源响应比较、消息反馈、权限预设、会话控制器、subagents 和 Workspace 控制器 contribution。`permissionPresets` namespace 返回 current-session 控件使用的完整进程级目录。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪的 namespace 服务、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 +Client 组合挂载 Office 转换、Commands、Agent Teams、凭据、settings、Goal、Schedule、动态 Cordis、文件与会话引用、只读 Host 插件清单、插件安装源响应比较、消息反馈、权限预设、会话控制器、subagents、Web 提供方列表和 Workspace 控制器 contribution。`permissionPresets` namespace 返回 current-session 控件使用的完整进程级目录。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪的 namespace 服务、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 本 facade 同时是 Client 包指称 wire 类型词汇的正门。它以 type-only 方式转出 Remote 失败词汇(`RemoteResult`、`RemoteFailure`、`RemoteErrorCode`、`RemoteErrorDetailsMap`)、Host 事实(`RemoteHostFacts`),以及各已选领域对 Client 安全的载荷类型,因此 Client 功能包只 import 一个 specifier,不必伸手进 `dsh-typert-protocol`、Gateway 或某个拥有方的 Host 入口。有两类包刻意不走这道门:本装配自己选中的 API 层包——反向 import 会形成依赖环——以及它们的测试,后者直接从 `dsh-typert-protocol` 取失败词汇。UI 包的测试则从 [`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 取 `RemoteError` 构造器。 @@ -48,7 +48,7 @@ Client 组合挂载 Office 转换、Commands、Agent Teams、凭据、settings Host entry 为每条 Client 流独立注册一组 allowlist listener 和一个队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。每个作用域 waterfall 请求都必须以 `request.agent` 直接携带路由所用的 agent;Host 会在转发前拒绝缺失或不匹配的身份。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` 逻辑流前同步挂好所有 listener,因此首个 `ready` 项既能证明增量投递已就绪,也会携带供 Client 显示路径的 Host home。撤回注册会中止活动流。 -无 payload 的 `schedule/changed` 事件使 Schedule 任务查询失效;Client 在收到通知及重连后重新读取。无 payload 的 `permission-presets/catalog-changed` 事件使进程目录失效。Client 在首次读取 `permissionPresets.catalog()` 前先订阅,并在通知后重新读取完整快照;该事件不携带目录状态,也不改变 Session 序号。 +无 payload 的 `schedule/changed` 事件使 Schedule 任务查询失效;Client 在收到通知及重连后重新读取。无 payload 的 `permission-presets/catalog-changed` 事件使进程目录失效。Client 在首次读取 `permissionPresets.catalog()` 前先订阅,并在通知后重新读取完整快照;该事件不携带目录状态,也不改变 Session 序号。无 payload 的 `web/providers-changed` 事件使 `web.listProviders()` 提供方列表失效;Client 在收到通知及重连后重新读取。 ## 构建边界 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 1fe16a4a87..eb11a2aa1a 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -100,6 +100,7 @@ "@deepseek-ai/dsh-deepseek-account": "workspace:*", "@deepseek-ai/dsh-client-product-analytics": "workspace:*", "@deepseek-ai/dsh-agent-team": "workspace:*", - "@deepseek-ai/dsh-client-ui-trajectory": "workspace:*" + "@deepseek-ai/dsh-client-ui-trajectory": "workspace:*", + "@deepseek-ai/dsh-web": "workspace:*" } } diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 86112b7cc2..e321b350d4 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -31,6 +31,7 @@ import jobRemote from '@deepseek-ai/dsh-api-job-controller/remote' import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote' import terminalRemote from '@deepseek-ai/dsh-api-terminal-controller/remote' import workspaceFilesRemote from '@deepseek-ai/dsh-api-workspace-files/remote' +import webRemote from '@deepseek-ai/dsh-web/remote' import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' @@ -54,6 +55,7 @@ export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inven export type {} from '@deepseek-ai/dsh-agent-preset-registry/remote' export type {} from '@deepseek-ai/dsh-user-questions/remote' export type {} from '@deepseek-ai/dsh-commands/remote' +export type {} from '@deepseek-ai/dsh-web/remote' export type {} from '@deepseek-ai/dsh-api-settings-controller/remote' export type {} from '@deepseek-ai/dsh-api-account-controller/remote' export type {} from '@deepseek-ai/dsh-goal/remote' @@ -85,6 +87,7 @@ export type { ApiRemoteForwardedEvent } from '../types.ts' // signatures `$on` hands to a listener, so a consumer reads the very // declaration the Host emits rather than a flattened restatement of it. export type {} from '@deepseek-ai/dsh-commands/types' +export type {} from '@deepseek-ai/dsh-web/types' export type {} from '@deepseek-ai/dsh-cordis-host-runner/types' export type {} from '@deepseek-ai/dsh-credentials/types' export type {} from '@deepseek-ai/dsh-llm/types' @@ -197,7 +200,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { messageFeedbackRemote, sessionFeedbackRemote, fileUploadsRemote, sessionReferencesRemote, permissionPresetsRemote, subagentsRemote, sessionRemote, jobRemote, workspaceRemote, workspaceFilesRemote, terminalRemote, - officeToPdfRemote, userQuestionsRemote, + officeToPdfRemote, userQuestionsRemote, webRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index d2fa2989f1..1ede5adf89 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -28,6 +28,7 @@ import type {} from '@deepseek-ai/dsh-permission-presets/types' import type {} from '@deepseek-ai/dsh-settings/types' import type {} from '@deepseek-ai/dsh-user-approval' import type {} from '@deepseek-ai/dsh-user-questions' +import type {} from '@deepseek-ai/dsh-web/types' export type {} from '@deepseek-ai/dsh-api-session-controller/types' export { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts' diff --git a/packages/api/remotes/src/remote-events.ts b/packages/api/remotes/src/remote-events.ts index 38f87e99b4..514723b031 100644 --- a/packages/api/remotes/src/remote-events.ts +++ b/packages/api/remotes/src/remote-events.ts @@ -14,6 +14,7 @@ import type {} from '@deepseek-ai/dsh-plugin-manager/types' import type {} from '@deepseek-ai/dsh-plugin-market/types' import type {} from '@deepseek-ai/dsh-schedule/client' import type { TypertForwardableEventEntry } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-web/types' /** * Host events this application forwards without renaming. The explicit mode is @@ -54,4 +55,5 @@ export const API_REMOTE_FORWARDED_EVENTS = [ { event: 'settings/document-updated', mode: 'emit' }, { event: 'schedule/changed', mode: 'emit' }, { event: 'user-questions/request', mode: 'waterfall' }, + { event: 'web/providers-changed', mode: 'emit' }, ] as const satisfies readonly TypertForwardableEventEntry[] diff --git a/packages/api/remotes/tests/remote-events.host.spec.ts b/packages/api/remotes/tests/remote-events.host.spec.ts index 977257ee44..a44297e56b 100644 --- a/packages/api/remotes/tests/remote-events.host.spec.ts +++ b/packages/api/remotes/tests/remote-events.host.spec.ts @@ -133,6 +133,16 @@ describe('Remote event Host source', () => { value: { event: 'schedule/changed', args: [] }, }) + emitRaw(ctx, 'web/providers-changed', []) + await expect(first.next()).resolves.toEqual({ + done: false, + value: { event: 'web/providers-changed', args: [] }, + }) + await expect(second.next()).resolves.toEqual({ + done: false, + value: { event: 'web/providers-changed', args: [] }, + }) + const firstDone = first.next() firstAbort.abort(new Error('first Client disconnected')) emitRaw(ctx, 'commands/change', []) diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 49b3180c43..c1a5b408eb 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -114,6 +114,9 @@ }, { "path": "../../credentials/deepseek-account" + }, + { + "path": "../../web/web" } ] } diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index 3fd7ea3bc4..746901c789 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -91,6 +91,9 @@ }, { "path": "../../credentials/deepseek-account" + }, + { + "path": "../../web/web" } ] } diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 38a57db211..d94f5cf2dd 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -461,21 +461,28 @@ # The shared base enables the stable model-facing web_search and web_fetch # tools. The Web app disables this host row and composes both tools per agent - # preset; products with a stricter network policy override tool-web. DeepSeek - # search resolves the same DEEPSEEK_API_KEY - # credential the Models page manages for chat, at each search; its Messages - # endpoint is configured independently from the model route, so it takes its own - # base-URL override. Anonymous fetch accepts only public HTTP(S) destinations, - # resolves and validates every destination, and pins every actual connection. - # Search is a full auxiliary model request with server-side retrieval, so this - # shipped DeepSeek route gets 60s while the provider-neutral tool default - # remains 30s. The DeepSeek Search and Fetch APIs register as - # deepseek-search-api and deepseek-fetch-api with the same credential; the Web - # search settings switch selects both in place of these defaults. + # preset; products with a stricter network policy override tool-web. Search + # defaults to the DeepSeek Search API (deepseek-search-api) and fetch to the + # anonymous local fetcher (http). The DeepSeek Search and Fetch APIs + # (deepseek-search-api, deepseek-fetch-api) follow the conversation: on the + # deepseek-account route they send the account token to that route's + # origin, and otherwise the API key of the DeepSeek API-key route to its + # origin, so a DEEPSEEK_API_KEY alone serves both without a sign-in. + # searchBaseURL / searchApiKeyEnv (fetch* for Fetch) pin one capability to + # its own endpoint and key instead. + # Legacy Messages search (deepseek-official) is selectable separately and + # uses its own endpoint, with the account token or DEEPSEEK_API_KEY. + # Anonymous fetch accepts only public + # HTTP(S) destinations, resolves and validates every destination, and pins + # every actual connection. This composition gives web_search 60s whichever + # provider serves it; the provider-neutral tool default remains 30s. The Web + # search provider and Web fetch provider + # menus on the Web search and fetch settings page select a provider for each + # capability in place of these defaults. - id: web name: '@deepseek-ai/dsh-web' config: - searchProvider: deepseek-official + searchProvider: deepseek-search-api fetchProvider: http - id: web-search-deepseek @@ -485,8 +492,6 @@ - id: web-deepseek-api name: '@deepseek-ai/dsh-web-deepseek-api' - config: - apiKeyEnv: DEEPSEEK_API_KEY - id: web-fetch-http name: '@deepseek-ai/dsh-web-fetch-http' diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index 0976268a07..4a9d5b5c54 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -41,12 +41,18 @@ describe('dsh-base bundle', () => { }) expect(rows.filter(row => row.id === 'subagent-codex')).toHaveLength(0) expect(rows.filter(row => row.id === 'subagent-claude-code')).toHaveLength(0) - expect(rows.find(row => row.id === 'web')?.config).toMatchObject({ fetchProvider: 'http' }) + expect(rows.find(row => row.id === 'web')?.config).toEqual({ searchProvider: 'deepseek-search-api', fetchProvider: 'http' }) + expect(rows.find(row => row.id === 'web-deepseek-api')).toEqual({ id: 'web-deepseek-api', name: '@deepseek-ai/dsh-web-deepseek-api' }) expect(rows.find(row => row.id === 'web-fetch-http')).toBeDefined() + expect(rows.find(row => row.id === 'web-search-deepseek')).toMatchObject({ + name: '@deepseek-ai/dsh-web-search-deepseek', config: { apiKeyEnv: 'DEEPSEEK_API_KEY' }, + }) expect(rows.find(row => row.id === 'tool-web')?.config).toMatchObject({ fetch: true }) expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-codex') expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-claude-code') expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-web-fetch-http') + expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-web-deepseek-api') + expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-web-search-deepseek') }) it('gates each shell stack by platform with a symmetric disabled expression', () => { diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index d636b5d4b3..136146268d 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/README.i18n.yaml @@ -21,8 +21,8 @@ en: b3d0ddfb56a1ac7f zh: c1762158e49a4851 /deepseek-ai-dsh-headless/use-this-package/credentials: - en: ca8217837ee9c698 - zh: fa20daf27d4e09ef + en: bf02dd1c6888da25 + zh: 83dca9600cd7e8ff /deepseek-ai-dsh-headless/use-this-package/choosing-the-session-identity: en: 85120c2d6db2cac4 zh: bac532df6c84e755 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index f9c53e7d2e..8bcc97a4ca 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -55,6 +55,8 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a The shipped headless profile disables the managed credential store and its authorization and DeepSeek account consumers. API-key routes use the launch environment, then `/.env`, then `$DSH_HOME/.env`. Keys and sign-ins saved by the GUI are ignored without changing their file. Providers that require a saved sign-in fail unless stored authentication is restored; external provider credential chains, such as AWS profiles, remain available. +The DeepSeek Search and Fetch APIs [follow the session's DeepSeek credentials](../../web/web-deepseek-api/README.md#authentication). In the shipped headless profile, they send the DeepSeek model route's `DEEPSEEK_API_KEY` to that route's origin, so no sign-in is needed. Without that key, the model request omits `web_search`. + Unset an empty launch variable to allow its `.env` fallback; an exported empty value shadows that fallback. To restore stored authentication, add these rows to `$DSH_HOME/profiles/headless/cordis.patch.yml` or a `--patch` overlay: diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 8600a6f2f3..c6b7138278 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -55,6 +55,8 @@ agent 会完成该任务,把提供方的每个非空推理(reasoning)增 随附的 headless profile 禁用托管凭据存储,以及依赖它的 authorization 和 DeepSeek account 消费方。API 密钥路由依次使用启动环境、`<运行命令的目录>/.env` 和 `$DSH_HOME/.env`。GUI 保存的密钥和登录凭据不会被使用,存储文件保持原样。需要已保存登录凭据的提供方会失败,除非恢复存储认证;AWS profile 等提供方外部凭据链仍然可用。 +DeepSeek Search 与 Fetch API [跟随会话的 DeepSeek 凭据](../../web/web-deepseek-api/README.zh.md#authentication)。在随附的 headless profile 中,它们将 DeepSeek 模型路由的 `DEEPSEEK_API_KEY` 发往该路由的 origin,因此无需登录。没有该密钥时,模型请求不包含 `web_search`。 + 启动变量为空时,请取消导出该变量,让 `.env` 后备生效;已导出的空值会遮蔽后备值。 要恢复存储认证,请把以下行加入 `$DSH_HOME/profiles/headless/cordis.patch.yml` 或 `--patch` 覆盖文件: diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 1c3494a2f5..f77d3835d7 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 /client-web-gui-browser-half/packages: - en: d7adae6baeed495b - zh: 9e49fb00adc8eec1 + en: 2165938f26300774 + zh: 5f739534a8509545 /client-web-gui-browser-half/related-documentation: en: d1d506e68fab7333 zh: 1a90fe6f3f8f24a9 diff --git a/packages/client/README.md b/packages/client/README.md index a61299c2d2..3047a181c1 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -81,7 +81,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-settings-agent-loop/`](ui-settings-agent-loop/README.md) | Provides the agent-loop settings page on the Plugins page | — | | [`ui-settings-session-log/`](ui-settings-session-log/README.md) | Controls API Session-log upload in General settings | — | | [`ui-settings-subagent/`](ui-settings-subagent/README.md) | Provides the Subagent settings page on the Plugins page | — | -| [`ui-settings-web-search/`](ui-settings-web-search/README.md) | Provides the web-search settings page on the Plugins page | — | +| [`ui-settings-web-search/`](ui-settings-web-search/README.md) | Provides the web search and fetch settings page on the Plugins page | — | | [`ui-plugin-manager/`](ui-plugin-manager/README.md) | Contributes the sidebar Plugins panel: install, enable, disable, retry, and compose installed packages | — | | [`ui-entry-customization/`](ui-entry-customization/README.md) | Adds region-local More menus that pin or unpin plugin launchers and start a plugin draft | — | | [`ui-plugin-market/`](ui-plugin-market/README.md) | Contributes the Discover tab to the Plugins page: browse market indexes and install their bundles | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 1e865a79c2..e327a717d9 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -81,7 +81,7 @@ kind: "package-group" | [`ui-settings-agent-loop/`](ui-settings-agent-loop/README.zh.md) | 在插件页提供 Agent 循环设置页 | — | | [`ui-settings-session-log/`](ui-settings-session-log/README.zh.md) | 在通用设置中控制随 API 上传会话日志 | — | | [`ui-settings-subagent/`](ui-settings-subagent/README.zh.md) | 在插件页提供子智能体设置页 | — | -| [`ui-settings-web-search/`](ui-settings-web-search/README.zh.md) | 在插件页提供网页搜索设置页 | — | +| [`ui-settings-web-search/`](ui-settings-web-search/README.zh.md) | 在插件页提供网页搜索与获取设置页 | — | | [`ui-plugin-manager/`](ui-plugin-manager/README.zh.md) | 贡献侧栏的“插件”面板:安装、启用、停用、重试与组合已安装的包 | — | | [`ui-entry-customization/`](ui-entry-customization/README.zh.md) | 添加分区域的“更多”菜单,固定或收起插件入口,并开始创建插件的草稿 | — | | [`ui-plugin-market/`](ui-plugin-market/README.zh.md) | 为“插件”页面贡献“发现”标签页:浏览市场索引并安装其中的组合包 | — | diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 4eea9e69b2..737ae32b4f 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package: - en: 5a06147f2a79eccf - zh: 6ca5899680e3cc6f + en: 01ba764b51f010e8 + zh: e43a158a5b09d987 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: en: cd4e17d62c407541 zh: 9610d3ee55c0e56a diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 5d71122e31..53a1c9a44e 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -31,7 +31,7 @@ Below 768px, the page uses 20px horizontal insets to align with the conversation [Ralph loops](../../experimental/ralph-bundle/README.md) and [Session search](../../experimental/session-search/README.md) remain bundled but are hidden from both plugin lists unless the profile enables or separately installs them. Existing selections remain manageable; switching off an installation-carried copy hides it again. Enter either exact package name in **Add plugin**, then choose **Enable now** to use the shipped copy without downloading it again. Sidebar session search is independent. LiveShare declares the same default-hidden intent in its bundle manifest (`dsh.bundle.listedByDefault: false`), so it stays out of both lists, including **More**, until a declared row runs or it is enabled. The page's manifest rule is generic: a bundle whose manifest does not declare `listedByDefault: false` is listed; one that does is listed only when the profile enables it, the installation carries it selected, or one of its declared rows runs; a hidden bundle is not opened by its ID either. -Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists the official bundles eligible for display, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Switching on a bundle that page holds back adds it to the main list in the position that page shows it, and switching it off leaves it on that page alone unless its manifest hides unselected bundles; other bundles keep their place on both. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section. +Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search and fetch, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists the official bundles eligible for display, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Switching on a bundle that page holds back adds it to the main list in the position that page shows it, and switching it off leaves it on that page alone unless its manifest hides unselected bundles; other bundles keep their place on both. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section. As available header width decreases, Refresh and Add plugin first become icon-only controls with accessible names and tooltips; the tabs then shrink from their usual minimum width to fit their labels, and the action group wraps only when necessary. The information button follows the subtitle text. The main page header and list share one scroll container. Header controls move with the scroll position until they reach their compact positions; scrolling back follows the same coordinates. The header retains its resting layout height, so moving its controls does not change the list's scroll range. A barely scrolling list can stop before the header is fully compact. The More list's back-and-title row stays fixed, and detail pages scroll their head row with the content. The Installed/Discover tabs use the compact 34px control with a 2px inset around the selection indicator. The subtitle scrolls out of view; with tabs present, the title also scrolls out, and the tabs settle beside Refresh and Add plugin where space permits. On narrow pages, the tabs and icon actions share a row beneath the heading where they fit; otherwise the tabs stay above the actions. Their relative order stays the same throughout scrolling. The resting and compact layouts retain their 28px and 16px page-top insets respectively, with 16px below the compact controls. macOS adds the frame's titlebar clearance while resting; its compact bar keeps 16px above and below with the sidebar expanded, and with the sidebar collapsed it also clears the frame's top strip so the controls stay below the native window controls. Only on macOS, when the sidebar is collapsed, the compact controls keep 8px rather than 16px above them in addition to the unchanged frame clearance, at every width. The resting layout and compact bottom spacing stay unchanged. Windows relies on the frame's caption offset without adding it again. The divider spans the page at the bottom of the compact header; the list's initial top spacing scrolls with its content. Hover styling applies only where the pointer can hover, so a tap leaves no card, row, or menu option in its hover state; cards and icon actions answer a touch-down with the pressed fill instead. The install, registry, and filter fields take a 16px face under a coarse pointer, which keeps iOS Safari from zooming a focused field. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 5978dc9d0e..8b6afcd74b 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" [Ralph 循环](../../experimental/ralph-bundle/README.zh.md)和[会话搜索](../../experimental/session-search/README.zh.md)仍随包提供,但默认不在两个插件列表中展示,只有 profile 启用或另行安装后才显示。已有的启用状态仍可管理;关闭随安装提供的版本后,它会再次隐藏。在**添加插件**中输入准确包名,再选择**立即启用**,即可使用随包提供的版本,无需再次下载。侧栏会话搜索独立于此功能。LiveShare 在其 bundle manifest 中声明相同的默认隐藏意图(`dsh.bundle.listedByDefault: false`),因此在自身声明的行开始运行或被启用前,它不会出现在两个列表(含**更多**页)中。页面的 manifest 规则是通用的:未声明 `listedByDefault: false` 的组合包会列出;声明了的,只有在 profile 启用、安装随附版本被选中,或它声明的某一行开始运行时才列出;隐藏的组合包也不能通过 ID 打开详情。 -在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出符合展示条件的官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。开启仅在该页列出的组合包会按该页中的位置把它加入主列表,关闭后它只留在该页,但 manifest 声明默认隐藏的组合包会从该页消失;其他组合包在两个列表中均保持原位。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。 +在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索与获取、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出符合展示条件的官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。开启仅在该页列出的组合包会按该页中的位置把它加入主列表,关闭后它只留在该页,但 manifest 声明默认隐藏的组合包会从该页消失;其他组合包在两个列表中均保持原位。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。 页头可用宽度缩小时,“刷新”和“添加插件”先变为保留可访问名称与 tooltip 的图标按钮;标签再从常规最小宽度收紧为文案所需宽度,仍不足时操作组才换行。信息按钮紧随副标题文字。主页面的页头和列表共用一个滚动容器。页头控件随滚动位置移动,到达紧凑态位置后停住;反向滚动沿同样的坐标返回。页头保留静止态的布局高度,因此控件移动不会改变列表的滚动范围。只能滚动少量距离的列表可能在页头完全收拢前到达底部。“更多”列表的返回与标题行固定,详情页的页头随内容滚动。“已安装/发现”标签使用总高 34px 的紧凑控件,选中指示块与外框之间留 2px。副标题随滚动离开视口;有标签时标题也随之滚出,标签在空间足够时停靠到“刷新”“添加插件”旁边。窄页面中,标签与图标操作优先在标题下方共用一行;放不下时,标签始终在操作组上方。滚动过程中两组控件的相对顺序保持不变。静止态与紧凑态分别保留 28px 和 16px 的页面顶部留白,紧凑态控件下方保留 16px。macOS 静止态保留框架的标题栏安全区;折叠态在侧栏展开时上下各留 16px,侧栏收起时再避让框架顶部安全区,使控件位于原生窗口控件下方。仅在 macOS 侧栏收起时,紧凑态控件在原有框架安全区之外的顶部留白统一由 16px 减为 8px,不受窗口宽度影响;静止态布局与紧凑态底部留白均不变。Windows 使用框架已有的标题栏偏移,不重复叠加。分隔线在紧凑页头底部横跨页面,列表初始的顶部留白随内容滚动。悬停样式只在可悬停的指针下生效,点按不会让卡片、行或菜单项停留在悬停态;卡片与图标操作改以按下填充回馈触摸。安装、注册表与筛选输入框在粗指针下使用 16px 字号,iOS Safari 不会因聚焦而放大页面。 diff --git a/packages/client/ui-settings-web-search/README.i18n.yaml b/packages/client/ui-settings-web-search/README.i18n.yaml index 8083d1d0de..8b4a91da4b 100644 --- a/packages/client/ui-settings-web-search/README.i18n.yaml +++ b/packages/client/ui-settings-web-search/README.i18n.yaml @@ -3,26 +3,26 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-web-search/README.md /: - en: 88147277504a84c5 - zh: 7262e02458f31515 + en: 88b8ad7d3580e897 + zh: 36380f07046212c5 /deepseek-ai-dsh-client-ui-settings-web-search: en: 968c0f6ba2415331 zh: aaf9e2aa22eafc98 /deepseek-ai-dsh-client-ui-settings-web-search/summary: - en: f0f8d6a8f0f74de3 - zh: 1926ea7e173eea51 + en: 93af84c75a57345b + zh: 67836fe89e423a52 /deepseek-ai-dsh-client-ui-settings-web-search/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-settings-web-search/use-this-package: - en: ba0053e60d4a5e9e - zh: 1e79aad162155e9c + en: bf26f223253bb9b8 + zh: b687ab66557c1f95 /deepseek-ai-dsh-client-ui-settings-web-search/understand-the-implementation: - en: d200933a35bf4972 - zh: 2484eb9635102357 + en: 078184ee0b31389a + zh: 8d74eb3b687a2d6f /deepseek-ai-dsh-client-ui-settings-web-search/further-exploration: - en: 72585c993e443510 - zh: 90cf6894ccfe8588 + en: 782f6f99769f5645 + zh: 310a9544ae051c9d /deepseek-ai-dsh-client-ui-settings-web-search/model-experience: en: 6b7c6f82ab6c8435 zh: 4517689c899abcbe @@ -30,8 +30,8 @@ en: ca75c51c89c2c9b0 zh: 6e1c9a8fdde89577 /deepseek-ai-dsh-client-ui-settings-web-search/known-limitations-and-deferred-work: - en: 2f7f093f506401cd - zh: a9b748ae7d4c64e4 + en: 0024baf967a2913b + zh: c6bd2f9eb52d83e3 /deepseek-ai-dsh-client-ui-settings-web-search/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: e88aeb617ffb74ea diff --git a/packages/client/ui-settings-web-search/README.md b/packages/client/ui-settings-web-search/README.md index 6f995c2050..e22c2bc9fc 100644 --- a/packages/client/ui-settings-web-search/README.md +++ b/packages/client/ui-settings-web-search/README.md @@ -1,5 +1,5 @@ --- -description: "The DeepSeek web-search settings page on the dsh web client's Plugins page: the Search and Fetch API switch, the API key, endpoint, and per-search limit." +description: "The web search and fetch settings page on the dsh web client's Plugins page: the Search and Fetch provider menus, the DeepSeek Search API result count, and legacy Messages search settings." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Open **Plugins** in the sidebar and select **Web search** in the Official group to switch between DeepSeek Messages search and the DeepSeek Search and Fetch APIs, and to set the selected API's key, endpoint, and per-search limit. The page stages what is typed and writes it only on save; the key is written through the credentials domain rather than the settings document, so its literal never rides a response. The page exists while the Host serves the `web-search-deepseek` namespace. +Open **Plugins** in the sidebar and select **Web search and fetch** in the Official group to choose the providers web search and web fetch use, set the Search API result count, or configure legacy Messages search. The DeepSeek Search and Fetch APIs take no credential settings here: they use the conversation's DeepSeek account or a DeepSeek API key. The page writes only on save; a legacy key goes through the credentials domain. The page exists while the Host serves the `web` namespace; each official provider's controls appear only while the Host serves that provider's namespace. ## Table of Contents @@ -25,9 +25,15 @@ Open **Plugins** in the sidebar and select **Web search** in the Official group ## Use this package -The **Web search** card in the Official group opens the page. **API key** starts blank on every load and reports only whether a key is configured; without one, it says that only conversations using a DeepSeek Account model can search, through the default endpoint, because those searches authenticate with the account sign-in; a blank draft keeps the stored key, and the control is disabled when the credential cannot be written from here, such as a key the process environment supplies. The API base URL and the active limit, **Max searches per request** for Messages or **Results per search** for the Search API, render the effective value, carry an **Overridden** badge with **Reset to default** once overridden, and save as a reset when emptied. Nothing is written until **Save**; leaving the page drops the drafts. +The **Web search and fetch** card in the Official group opens the page. It has a **Web search provider** section and a **Web fetch provider** section, each with a provider menu directly under its title. A menu lists every provider the Host registers, including providers that plugins register, so a newly installed provider needs no change to this page; [Add another provider](../../web/web/README.md#adding-a-provider) shows how to mount one in a profile. The official ones come first while registered: the Search menu lists **DeepSeek Search API** (`deepseek-search-api`) followed by **DeepSeek (legacy)** (`deepseek-official`), and the Fetch menu lists **Direct fetch** (`http`) and then **DeepSeek Fetch API** (`deepseek-fetch-api`); every other provider follows in registration order and shows its registered title or id. The shipped composition selects DeepSeek Search API for Search and Direct fetch for Fetch, and a selection stored in settings or composed by the deployment still applies; opening the page writes nothing. Each menu has no label of its own; its accessible name is its section title followed by the selected provider, such as **Web search provider DeepSeek Search API**. A selected id the Host does not register appears as an extra **(not loaded)** entry and stays selected. While the deployment composes no provider for a capability, its menu starts with **Automatic**, which clears the stored selection so the Host chooses again. Selecting the deployment's composed provider clears the stored selection instead of writing a copy. When the provider list cannot be read, each section says its menu may be incomplete and offers **Retry**; the menus keep the last list read, or only the selected ids before any list arrives. Under each menu, the section shows the `description` the selected provider registered, in the active language, as plain text; a provider that registers none, or a selected id the Host does not register, shows no description. The official providers' descriptions state each one's credentials, charges, model calls, reachable addresses, and content limits, and Direct fetch's ends by suggesting DeepSeek Fetch API as plain text. A plugin provider's section adds, after its description, that it is configured in that plugin's own settings. -**Use the new Web Search API** defaults off, so search keeps using Messages and fetch keeps using local HTTP. Its hint links to the DeepSeek search and fetch guide for the new API's features and billing. Saving it on selects the DeepSeek Search and Fetch APIs together; saving it off clears both selections back to the deployment's defaults, or stores Messages search with local HTTP fetch when the deployment composes the APIs. The switch appears only while the Host also serves the `web` and `web-deepseek-api` namespaces. With the switch on, the page shows **Results per search** (an integer from 1 through 50, default 10) in place of the Messages-only **Max searches per request**, and invalid result counts block saving. The switch is disabled while an invalid edit or a failed save is staged, so a field it would hide stays visible. Each mode keeps its own API base when switching. The **Messages API base URL** and **Search / Fetch API base URL** labels identify the active protocol; their hints name the required prefix and appended operation path. One save writes the provider settings before the selection, so a refused section leaves the switch unsaved. See [web-search-deepseek](../../web/web-search-deepseek/README.md#use-this-package) and [web-deepseek-api](../../web/web-deepseek-api/README.md#use-this-package) for API behavior. +Under **Web search provider**, DeepSeek Search API adds a link to the DeepSeek search and fetch guide after its description, and a gray panel below the menu holds **Results per search** (an integer from 1 through 50, default 10; invalid counts block saving). The DeepSeek Fetch API section shows its provider menu and description. Both APIs follow the DeepSeek credential of the conversation, and their own endpoint fields are YAML-only ([web-deepseek-api authentication](../../web/web-deepseek-api/README.md#authentication)). A provider with no settings on this page, such as DeepSeek Fetch API, Direct fetch, or a plugin provider, shows no panel. Every control sits directly in its section; nothing is collapsed. + +**DeepSeek (legacy)** shows its API key, independent Messages Base URL, and **Maximum searches per request** (`maxUses`, a positive integer, default 5) in the same panel layout. The key writes the `web-search-deepseek.apiKeyEnv` reference (`DEEPSEEK_API_KEY` by default); endpoint and limit writes stay in `web-search-deepseek`, separate from API settings. Switching away drops an unsaved key but preserves endpoint and limit settings. + +Value fields render the effective value, carry an **Overridden** badge with **Reset to default** once overridden, and save as a reset when emptied; a value equal to the deployment default saves as an override. The provider menus are disabled while an invalid edit or a failed save is staged, so a field the change would hide stays visible. + +A staged edit to a hidden provider's field is kept and saved. One save writes the provider settings before the selection, so a refused section leaves the selection unsaved. Nothing is written until **Save**; leaving the page drops the drafts. See [web-deepseek-api](../../web/web-deepseek-api/README.md#use-this-package) for API behavior. ----- @@ -37,7 +43,7 @@ The **Web search** card in the Official group opens the page. **API key** starts
Implementation internals — click to expand -The Host half is an empty `apply`, present only so the package holds a Loader row the client module system serves the browser half for. The browser half binds the `web`, `web-search-deepseek`, and `web-deepseek-api` namespaces through `ctx.configForms.get` and keeps one shared `SettingsFormModel` of `ui-primitives` per namespace in `WebSearchCardController`. The switch stages `searchProvider` and `fetchProvider` in `web`; a target equal to the composed value stages a reset instead. The key is the selection form's one secret control: its write goes to `remote.credentials.set` under the reference the selected API section's `apiKeyEnv` names (`DEEPSEEK_API_KEY` when it names none), and success is read back from `remote.credentials.describe`. The controller combines synchronous scope publications and reference switches into one credential read in the next microtask; scope refreshes also resynchronize credentials after a connection reset. Reference changes clear the previous credential state immediately. Host `credentials/reference-updated` events for the watched reference and writes trigger independent reads, including while an earlier read is pending. Only the latest read for the selected reference updates the card; a save uses its own readback result. Disposal stops queued reads and suppresses late answers. The page registers `WebSearchCard` into the Plugins page's `plugins.item` slot through `ctx.configForms.whileServed`. +The Host half is an empty `apply`, present only so the package holds a Loader row the client module system serves the browser half for. The browser half binds the `web`, `web-search-deepseek`, and `web-deepseek-api` namespaces through `ctx.configForms.get` and keeps one shared `SettingsFormModel` of `ui-primitives` per namespace in `WebSearchCardController`. The menus stage `searchProvider` and `fetchProvider` in `web`; a target equal to the composed value stages a reset instead, and so does the **Automatic** entry, which the menu offers while the composed `base` has no value for that field. The provider list comes from `remote.web.listProviders`, which keeps registration order; the controller moves the registered official providers to the front in its fixed order and keeps the rest in Host order. The list is read at construction, on each `web/providers-changed` event, on `connection/reset`, and when the `web` namespace becomes served; only the latest read publishes, a failed read keeps the previous list and sets the snapshot's `providersFailed` until a later read succeeds, and before the first successful answer a menu holds only the selected id. The face's `retryProviders` re-reads through the same sequence and disposal checks. The `web-deepseek-api` form stages only `count`. The selection form's secret control is `legacyApiKey`, which addresses `web-search-deepseek.apiKeyEnv`. Its draft records the reference the control addressed when it was typed; on every republish the draft is dropped when the page no longer shows the control or the reference changed. A save fixes the draft reference when the selection form plans its writes, so a key write planned before its provider disappears still writes under that reference. A write goes to `remote.credentials.set`, and success is read back from `remote.credentials.describe`, which one read asks for the current reference and the written one. The controller combines synchronous scope publications and reference switches into one credential read in the next microtask; scope refreshes also resynchronize credentials after a connection reset. A reference change clears the credential state immediately. Host `credentials/reference-updated` events for the watched reference and writes trigger independent reads, including while an earlier read is pending. Only the latest read updates the card, and only while its reference is unchanged; a save uses its own readback result. Disposal stops queued reads and suppresses late answers. `WebSearchCard` renders the provider menus with one local menu button over the `Menu` primitive and the legacy fields with `SettingsSecretField` and `SettingsValueField`. The page registers `WebSearchCard` into the Plugins page's `plugins.item` slot through `ctx.configForms.whileServed([web])`.
@@ -49,10 +55,9 @@ The Host half is an empty `apply`, present only so the package holds a Loader ro - [ui-plugin-manager](../ui-plugin-manager/README.md) — the Plugins page and the `plugins.item` slot the page registers into. - [ui-settings](../ui-settings/README.md) — the settings scope and the served-namespace watch the page rides. - [ui-primitives](../ui-primitives/README.md) — the settings form model and fields the page renders. -- [credentials](../../credentials/README.md) — the credential-reference seam the key writes through. -- [web-search-deepseek](../../web/web-search-deepseek/README.md) — the Messages search provider whose namespace keeps the page served. -- [web-deepseek-api](../../web/web-deepseek-api/README.md) — the Search and Fetch API providers the switch selects. -- [web](../../web/web/README.md) — the provider selection the switch writes. +- [credentials](../../credentials/README.md) — the credential-reference seam the legacy key writes through. +- [web-deepseek-api](../../web/web-deepseek-api/README.md) — the Search and Fetch API providers whose result count the Search section edits. +- [web](../../web/web/README.md) — the provider selection and provider list the menus use. ----- @@ -69,7 +74,7 @@ None; this package neither assembles nor sends a provider request. -- **Three fields of the namespace** — the provider's model, API version, and token budget stay at their composed values; the page edits the key, the endpoint, and the search budget only. +- **Automatic selection reads only the deployment composition** — a menu offers it when the composition layer sets no provider for that capability. While automatic selection applies, the Host's `DSH_WEB_SEARCH_PROVIDER` or `DSH_WEB_FETCH_PROVIDER` environment variable picks the provider instead, and the page does not show that choice. Without either, automatic selection with more than one usable registered provider makes each operation fail with `WEB_PROVIDER_AMBIGUOUS`. ### Dev Note diff --git a/packages/client/ui-settings-web-search/README.zh.md b/packages/client/ui-settings-web-search/README.zh.md index 78fd2b2703..e1908e5602 100644 --- a/packages/client/ui-settings-web-search/README.zh.md +++ b/packages/client/ui-settings-web-search/README.zh.md @@ -1,5 +1,5 @@ --- -description: "dsh Web 客户端插件页上的 DeepSeek 网页搜索设置页:Search 与 Fetch API 开关、API Key、接口地址与每次搜索的上限。" +description: "dsh Web 客户端插件页上的网页搜索与获取设置页:Search 与 Fetch 提供方下拉框、DeepSeek Search API 的结果数,以及旧版 Messages 搜索的设置。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在侧栏打开**插件**,在官方分组里选择**网页搜索**,即可在 DeepSeek Messages 搜索与 DeepSeek Search 与 Fetch API 之间切换,并设置所选 API 的密钥、接口地址和每次搜索的上限。页面暂存输入、只在保存时写入;密钥通过凭据域写入而不进设置文件,其明文从不出现在任何响应里。页面只在 Host 服务 `web-search-deepseek` 命名空间期间存在。 +在侧栏打开**插件**,在官方分组里选择**网页搜索与获取**,即可选择网页搜索和网页获取使用的提供方、设置 Search API 的结果数,或配置旧版 Messages 搜索。DeepSeek Search 与 Fetch API 在这里没有凭据设置:它们使用会话的 DeepSeek 账号或 DeepSeek API Key。页面只在保存时写入;旧版搜索的密钥经凭据域写入。页面在 Host 服务 `web` 命名空间期间存在;各官方提供方的控件只在 Host 服务该提供方命名空间时显示。 ## 目录 @@ -25,9 +25,15 @@ kind: "package-reference" ## 使用本包 -官方分组里的**网页搜索**卡片打开这一页。**API Key** 每次加载都是空的,只报告是否已配置密钥;未配置时提示仅使用 DeepSeek 账号模型的对话可以通过默认接口地址搜索,因为这类搜索使用账号登录鉴权;留空保存等于保留现有密钥,而当凭据不能从这里写入时(例如由进程环境变量提供的密钥)该控件会被禁用。API 基础地址和当前模式的上限(Messages 的**单次请求最多搜索次数**或 Search API 的**每次搜索结果数**)显示生效值,一旦被覆盖就带**已覆盖**标签和**恢复默认**,清空后保存等于重置。点击**保存**之前不会写入任何内容;离开页面即丢弃草稿。 +官方分组里的**网页搜索与获取**卡片打开这一页。页面分为**网页搜索提供方**与**网页获取提供方**两节,每节标题下方紧接一个提供方下拉框。下拉框列出 Host 注册的全部提供方,包括插件注册的提供方,因此新安装的提供方无需修改本页;在 profile 中挂载提供方的方法见[添加其他提供方](../../web/web/README.zh.md#adding-a-provider)。已注册的官方提供方排在最前:搜索下拉框依次为 **DeepSeek Search API**(`deepseek-search-api`)与 **DeepSeek(旧版)**(`deepseek-official`),获取下拉框依次为**本机直接获取**(`http`)和 **DeepSeek Fetch API**(`deepseek-fetch-api`);其他提供方按注册顺序排在其后,显示其注册的标题或 id。随附组合默认搜索选择 DeepSeek Search API、获取选择本机直接获取,设置中已存的选择或部署组合的选择仍然生效;打开页面不会写入任何内容。下拉框没有单独的标签,其无障碍名称由所在节的标题和当前选中的提供方组成,例如**网页搜索提供方 DeepSeek Search API**。Host 未注册的已选 id 额外显示为一项**(未加载)**,并保持选中。部署没有为某项能力组合提供方时,其下拉框首项为**自动选择**,选中它会清除已存的选择,由 Host 重新选择。选择部署组合的提供方时清除已存的选择,而不写入一份副本。提供方列表读取失败时,每节提示其下拉框可能不完整并提供**重试**;下拉框保留上一次读到的列表,尚未读到任何列表时只包含已选 id。每节在下拉框下方以纯文本显示所选提供方注册的 `description`(按当前语言);未注册说明的提供方或 Host 未注册的已选 id 不显示说明。官方提供方的说明写明各自的凭据、计费、模型调用、可访问的地址和内容限制,本机直接获取的说明末尾以纯文本建议改用 DeepSeek Fetch API。插件提供方所在的节在其说明之后提示到该插件自己的设置中配置。 -**启用新版 Web 搜索 API** 默认关闭,搜索继续使用 Messages,抓取继续使用本地 HTTP。提示中链接到 DeepSeek 网页搜索与抓取指南,说明新版 API 的功能与计费。开启并保存后同时选择 DeepSeek Search 与 Fetch API;关闭并保存后两项选择都恢复为部署默认值,部署默认已组合这两个 API 时则写入 Messages 搜索和本地 HTTP 抓取。只有 Host 同时提供 `web` 和 `web-deepseek-api` 命名空间时才显示该开关。开关开启时以**每次搜索结果数**(1–50 的整数,默认 10)代替仅适用于 Messages 的**单次请求最多搜索次数**,无效的结果条数会阻止保存。暂存了无效编辑或保存失败时开关不可用,以免它隐藏的字段不再可见。切换时分别保留两种模式的 API 基础地址。**Messages API 基础地址**与 **Search / Fetch API 基础地址**标签标明当前协议,提示说明应包含的前缀和程序追加的操作路径。一次保存先写入提供方设置再写入选择,因此某一节被拒绝时开关不会保存。API 行为见 [web-search-deepseek](../../web/web-search-deepseek/README.zh.md#use-this-package) 和 [web-deepseek-api](../../web/web-deepseek-api/README.zh.md#use-this-package)。 +**网页搜索提供方**一节中,DeepSeek Search API 在说明之后显示 DeepSeek 网页搜索与获取指南链接,下拉框下方的灰色面板中是**每次搜索结果数**(1–50 的整数,默认 10;无效值阻止保存)。DeepSeek Fetch API 所在节显示提供方下拉框及说明。两个 API 都跟随会话的 DeepSeek 凭据,单独配置的端点字段只能在 YAML 中设置([web-deepseek-api 鉴权](../../web/web-deepseek-api/README.zh.md#authentication))。在本页没有设置项的提供方(例如 DeepSeek Fetch API、本机直接获取或插件提供方)不显示面板。所有控件直接位于所在节中,没有折叠区。 + +**DeepSeek(旧版)**在同样的面板布局中显示 API Key、独立的 Messages Base URL 与**单次请求最多搜索次数**(`maxUses`,正整数,默认 5)。密钥写入 `web-search-deepseek.apiKeyEnv` 指向的引用(默认 `DEEPSEEK_API_KEY`);端点和次数上限写入 `web-search-deepseek`,与 API 设置分开。切换其他提供方会丢弃未保存的密钥,但保留端点和次数设置。 + +数值字段显示生效值,一旦被覆盖就带**已覆盖**标签和**恢复默认**,清空后保存等于重置;填写部署默认值则作为覆盖值保存。暂存了无效编辑或保存失败时提供方下拉框不可用,以免切换后隐藏的字段不再可见。 + +对已隐藏提供方字段的暂存编辑会被保留并保存。一次保存先写入提供方设置再写入选择,因此某一节被拒绝时选择不会保存。点击**保存**之前不会写入任何内容;离开页面即丢弃草稿。API 行为见 [web-deepseek-api](../../web/web-deepseek-api/README.zh.md#use-this-package)。 ----- @@ -37,7 +43,7 @@ kind: "package-reference"
实现细节——点击展开 -宿主半侧是一个空的 `apply`,只为让本包占一条 Loader 行,客户端模块系统据此送出浏览器半侧。浏览器半侧通过 `ctx.configForms.get` 绑定 `web`、`web-search-deepseek` 和 `web-deepseek-api` 命名空间,在 `WebSearchCardController` 里为每个命名空间维护一个 `ui-primitives` 共享的 `SettingsFormModel`。开关在 `web` 中暂存 `searchProvider` 和 `fetchProvider`;目标值等于组合值时改为暂存重置。密钥是选择表单里唯一的密文控件:写入走 `remote.credentials.set`,引用名取自当前所选 API 设置节的 `apiKeyEnv`(未指定时为 `DEEPSEEK_API_KEY`),是否成功由 `remote.credentials.describe` 回读判定。控制器把同步发布的 scope 更新和引用切换合并成下一个微任务中的一次凭据读取;连接重置后的 scope 刷新也会重新同步凭据。引用变化会立即清除旧凭据状态。Host 对所监视引用发出 `credentials/reference-updated` 事件以及写入时,都会独立触发读取,即使此前的读取仍未完成。只有所选引用的最新读取会更新卡片;保存使用自身的回读结果。释放控制器后不再执行排队的读取,也不再发布迟到的响应。页面通过 `ctx.configForms.whileServed` 把 `WebSearchCard` 注册进插件页的 `plugins.item` slot。 +宿主半侧是一个空的 `apply`,只为让本包占一条 Loader 行,客户端模块系统据此送出浏览器半侧。浏览器半侧通过 `ctx.configForms.get` 绑定 `web`、`web-search-deepseek` 和 `web-deepseek-api` 命名空间,在 `WebSearchCardController` 里为每个命名空间维护一个 `ui-primitives` 共享的 `SettingsFormModel`。下拉框在 `web` 中暂存 `searchProvider` 和 `fetchProvider`;目标值等于组合值时改为暂存重置;组合的 `base` 对该字段没有值时,下拉框提供**自动选择**项,选中它同样暂存重置。提供方列表来自 `remote.web.listProviders`,该列表保持注册顺序;控制器把已注册的官方提供方按固定顺序移到最前,其余保持 Host 顺序。列表在构造时、每次 `web/providers-changed` 事件、`connection/reset` 以及 `web` 命名空间开始被服务时读取;只有最新一次读取会发布,读取失败时保留上一份列表,并在快照中设置 `providersFailed`,直到之后某次读取成功;首次成功应答前下拉框只包含已选 id。面上的 `retryProviders` 沿用同一套序号与释放检查重新读取。`web-deepseek-api` 表单只暂存 `count`。选择表单的密文控件为 `legacyApiKey`,使用 `web-search-deepseek.apiKeyEnv` 引用。草稿记录输入时控件所使用的引用;每次重新发布时,页面不再显示该控件或引用已变化时丢弃草稿。保存在选择表单规划写入时固定草稿的引用,因此在提供方消失前已规划的密钥写入仍写到该引用。写入走 `remote.credentials.set`,是否成功由 `remote.credentials.describe` 回读判定,一次读取同时询问当前引用和刚写入的引用。控制器把同步发布的 scope 更新和引用切换合并成下一个微任务中的一次凭据读取;连接重置后的 scope 刷新也会重新同步凭据。引用变化会立即清除旧凭据状态。Host 对所监视引用发出 `credentials/reference-updated` 事件以及写入时,都会独立触发读取,即使此前的读取仍未完成。只有最新一次读取会更新卡片,且只在其引用未变时更新;保存使用自身的回读结果。释放控制器后不再执行排队的读取,也不再发布迟到的响应。`WebSearchCard` 用基于 `Menu` 基础组件的同一个本地下拉按钮渲染提供方下拉框,用 `SettingsSecretField` 和 `SettingsValueField` 渲染旧版搜索的字段。页面通过 `ctx.configForms.whileServed([web])` 把 `WebSearchCard` 注册进插件页的 `plugins.item` slot。
@@ -49,10 +55,9 @@ kind: "package-reference" - [ui-plugin-manager](../ui-plugin-manager/README.zh.md)——插件页以及本页注册进去的 `plugins.item` slot。 - [ui-settings](../ui-settings/README.zh.md)——本页依赖的设置 scope 与"命名空间被服务期间"的监视。 - [ui-primitives](../ui-primitives/README.zh.md)——本页渲染的设置表单模型与字段。 -- [credentials](../../credentials/README.zh.md)——密钥写入所经的凭据引用 seam。 -- [web-search-deepseek](../../web/web-search-deepseek/README.zh.md)——其命名空间决定页面是否出现的 Messages 搜索提供方。 -- [web-deepseek-api](../../web/web-deepseek-api/README.zh.md)——开关选择的 Search 与 Fetch API 提供方。 -- [web](../../web/web/README.zh.md)——开关写入的提供方选择。 +- [credentials](../../credentials/README.zh.md)——旧版搜索密钥写入所经的凭据引用 seam。 +- [web-deepseek-api](../../web/web-deepseek-api/README.zh.md)——Search 一节编辑其结果数的 Search 与 Fetch API 提供方。 +- [web](../../web/web/README.zh.md)——下拉框使用的提供方选择与提供方列表。 ----- @@ -69,7 +74,7 @@ kind: "package-reference" -- **只编辑命名空间的三个字段**——提供方的模型、API 版本和 token 预算保持组合值不变;本页只编辑密钥、接口地址和搜索次数。 +- **自动选择只看部署组合层**——组合层没有为该能力设置提供方时,下拉框才提供自动选择。自动选择生效时,Host 的 `DSH_WEB_SEARCH_PROVIDER` 或 `DSH_WEB_FETCH_PROVIDER` 环境变量会改为由它指定提供方,而页面不会显示这一选择。两者都没有时,如果注册了多个可用提供方,自动选择会让每次操作以 `WEB_PROVIDER_AMBIGUOUS` 失败。 ### 开发备注 diff --git a/packages/client/ui-settings-web-search/locale/de.json b/packages/client/ui-settings-web-search/locale/de.json index 5aa238488d..5685413461 100644 --- a/packages/client/ui-settings-web-search/locale/de.json +++ b/packages/client/ui-settings-web-search/locale/de.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Einstellungen für die Websuche", - "description": "Konfigurieren Sie den Schlüssel, die Dienstadresse und das Anforderungsbudget für die DeepSeek-Websuche." + "title": "Einstellungen für Websuche und Webabruf", + "description": "Wählen Sie die Anbieter für Websuche und Webabruf und konfigurieren Sie Schlüssel, Dienstadresse und Limits der DeepSeek-Anbieter." } } diff --git a/packages/client/ui-settings-web-search/locale/es.json b/packages/client/ui-settings-web-search/locale/es.json index 1e1d3f5e7b..edd5312027 100644 --- a/packages/client/ui-settings-web-search/locale/es.json +++ b/packages/client/ui-settings-web-search/locale/es.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Configuración de búsqueda web", - "description": "Configure la clave, la dirección del servicio y solicite el presupuesto para la búsqueda web DeepSeek." + "title": "Configuración de búsqueda y obtención web", + "description": "Elija los proveedores de búsqueda y obtención web y configure la clave, la dirección del servicio y los límites de los proveedores DeepSeek." } } diff --git a/packages/client/ui-settings-web-search/locale/fr.json b/packages/client/ui-settings-web-search/locale/fr.json index 6c20e4212a..88dfeaa252 100644 --- a/packages/client/ui-settings-web-search/locale/fr.json +++ b/packages/client/ui-settings-web-search/locale/fr.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Paramètres de recherche sur le Web", - "description": "Configurez la clé, l'adresse du service et le budget de demande pour la recherche sur le Web DeepSeek." + "title": "Paramètres de recherche et de récupération web", + "description": "Choisissez les fournisseurs de recherche et de récupération web, et configurez la clé, l'adresse du service et les limites des fournisseurs DeepSeek." } } diff --git a/packages/client/ui-settings-web-search/locale/ja.json b/packages/client/ui-settings-web-search/locale/ja.json index d97e381a6e..1a5d6993fd 100644 --- a/packages/client/ui-settings-web-search/locale/ja.json +++ b/packages/client/ui-settings-web-search/locale/ja.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Web 検索設定", - "description": "DeepSeek Web 検索のキー、サービスアドレス、リクエスト予算を設定します。" + "title": "Web 検索と取得の設定", + "description": "Web 検索と Web 取得のプロバイダーを選択し、DeepSeek プロバイダーのキー、サービスアドレス、上限を設定します。" } } diff --git a/packages/client/ui-settings-web-search/locale/pt.json b/packages/client/ui-settings-web-search/locale/pt.json index ce7d68d86c..5823554d37 100644 --- a/packages/client/ui-settings-web-search/locale/pt.json +++ b/packages/client/ui-settings-web-search/locale/pt.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Configurações de pesquisa na web", - "description": "Configure a chave, o endereço do serviço e solicite o orçamento para DeepSeek pesquisa na web." + "title": "Configurações de pesquisa e obtenção na web", + "description": "Escolha os provedores de pesquisa e obtenção na web e configure a chave, o endereço do serviço e os limites dos provedores DeepSeek." } } diff --git a/packages/client/ui-settings-web-search/locale/ru.json b/packages/client/ui-settings-web-search/locale/ru.json index b8bd1cd774..7057a7f80e 100644 --- a/packages/client/ui-settings-web-search/locale/ru.json +++ b/packages/client/ui-settings-web-search/locale/ru.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Настройки веб-поиска", - "description": "Настройте ключ, адрес службы и бюджет запроса для веб-поиска DeepSeek." + "title": "Настройки веб-поиска и загрузки", + "description": "Выберите поставщиков веб-поиска и загрузки и настройте ключ, адрес службы и ограничения поставщиков DeepSeek." } } diff --git a/packages/client/ui-settings-web-search/locale/zh.json b/packages/client/ui-settings-web-search/locale/zh.json index e450e1004c..f817687041 100644 --- a/packages/client/ui-settings-web-search/locale/zh.json +++ b/packages/client/ui-settings-web-search/locale/zh.json @@ -1,6 +1,6 @@ { "meta": { - "title": "网页搜索设置", - "description": "配置 DeepSeek 网页搜索的密钥、服务地址和请求预算。" + "title": "网页搜索与获取设置", + "description": "选择网页搜索与网页获取的提供方,并配置 DeepSeek 提供方的密钥、服务地址和上限。" } } diff --git a/packages/client/ui-settings-web-search/package.json b/packages/client/ui-settings-web-search/package.json index 963d83c94a..7286063b97 100644 --- a/packages/client/ui-settings-web-search/package.json +++ b/packages/client/ui-settings-web-search/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-web-search", - "description": "Settings page of the DeepSeek web-search provider on the dsh web client's Plugins page: its API key, endpoint, and per-request search budget", + "description": "Settings page for web search and fetch on the dsh web client's Plugins page: the Search and Fetch provider menus, the DeepSeek Search API result count, and legacy Messages search settings", "version": "0.2.2-alpha.2", "publishConfig": { "access": "public" @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-web": "workspace:*", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-settings-web-search/src/client/WebSearchCard.module.css b/packages/client/ui-settings-web-search/src/client/WebSearchCard.module.css index a3f2d17cdd..e3e77da80f 100644 --- a/packages/client/ui-settings-web-search/src/client/WebSearchCard.module.css +++ b/packages/client/ui-settings-web-search/src/client/WebSearchCard.module.css @@ -1,16 +1,89 @@ -.feature { - display: grid; - gap: 6px; +/* + * Web search and fetch page: two provider sections, each a title and a menu + * with its hints. Under Search, the DeepSeek Search API shows its result count, + * and the legacy DeepSeek search its API key, base URL, and searches per + * request, in a gray panel. The sections are told apart by spacing and the + * panel; the only hairlines are the settings fields' dividers inside the panel. + */ + +.section { + display: flex; + flex-direction: column; } -.toggleRow { +.section + .section { + margin-top: 32px; +} + +.sectionTitle { + margin: 0; + font-size: 14px; + font-weight: 500; + line-height: 20px; + color: var(--dsw-alias-label-primary); +} + +.field { display: flex; - align-items: flex-start; + flex-direction: column; + gap: 6px; + padding: 8px 0 12px; +} + +.selector { + display: inline-flex; + align-items: center; justify-content: space-between; - gap: 16px; + gap: 12px; + width: 100%; + height: 34px; + padding: 0 12px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-bg-layer-3); + font: inherit; font-size: 13px; line-height: 1.5; color: var(--dsw-alias-label-primary); + text-align: start; + cursor: pointer; +} + +.selector:focus-visible { + outline: none; + border-color: var(--dsw-alias-state-business-primary); +} + +.selector:disabled { + color: var(--dsw-alias-label-tertiary); + cursor: default; +} + +@media (hover: hover) { + .selector:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + } +} + +.selectorLabel { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chevron { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +/* The gray panel holding a section's settings below its provider menu. */ +.panel { + display: flex; + flex-direction: column; + padding: 0 16px; + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-bg-module-platform); } .hint { @@ -20,6 +93,16 @@ color: var(--dsw-alias-label-tertiary); } +.notice { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 12px; + line-height: 1.5; + color: var(--dsw-alias-state-error-primary); +} + .link { color: var(--dsw-alias-link); text-decoration: none; diff --git a/packages/client/ui-settings-web-search/src/client/WebSearchCard.tsx b/packages/client/ui-settings-web-search/src/client/WebSearchCard.tsx index 011f9a4530..221d110d4f 100644 --- a/packages/client/ui-settings-web-search/src/client/WebSearchCard.tsx +++ b/packages/client/ui-settings-web-search/src/client/WebSearchCard.tsx @@ -1,26 +1,53 @@ /** - * The web-search settings page: the switch between Messages search and the - * Search and Fetch APIs, the selected API's endpoint and per-search limit, and - * the key — which - * is written through the credentials domain, never into the settings section, - * so the literal never rides a response. + * The web search and fetch settings page: one section per capability, each + * with a provider menu and its explanation. The selected provider's settings + * below the menu sit in one gray panel: the result count for the DeepSeek + * Search API, and the key, endpoint, and search limit for legacy Messages + * search. A provider with no settings here shows no panel. */ +import { useRef, useState, type ReactNode } from 'react' import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' -import { SettingsForm, SettingsSecretField, SettingsValueField, Switch } from '@deepseek-ai/dsh-client-ui-primitives' +import { + Button, IconChevronDownOutlineRegular, Menu, SettingsForm, SettingsSecretField, SettingsValueField, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { formLabels } from './locales.ts' +import { formLabels, type WebSearchSettingsLocaleKey } from './locales.ts' import styles from './WebSearchCard.module.css' -import type { WebSearchCardFace } from './web-search-card-controller.ts' +import { + PROVIDER_IDS, type WebProviderOption, type WebProviderSelection, type WebSearchCardFace, type WebSearchCardState, +} from './web-search-card-controller.ts' -/** Props the renderer binds for the web-search page. */ +/** Props the renderer binds for the web search and fetch page. */ export type WebSearchCardProps = PropsRuntime<'plugins.item'> & PropsLocale<'settings.webSearch'> & InjectFace +type T = WebSearchCardProps['t'] + +/** Dictionary names of the official providers; any other provider shows its registered title or id. */ +const PROVIDER_LABELS: Record = { + [PROVIDER_IDS.apiSearch]: 'providerDeepSeek', + [PROVIDER_IDS.legacySearch]: 'providerLegacy', + [PROVIDER_IDS.localFetch]: 'providerLocal', + [PROVIDER_IDS.apiFetch]: 'providerDeepSeekFetch', +} + /** - * Render the web-search provider's one-liner or its settings form, as the Plugins page asks. + * The menu label of one provider. + * @param t - the page's locale reader. + * @param option - the provider entry. + * @returns the localized name, marked when the Host does not register it. + */ +function providerLabel(t: T, option: WebProviderOption): string { + const key = PROVIDER_LABELS[option.id] + const name = key === undefined ? option.title ?? option.id : t(key) + return option.missing ? t('providerMissing', { id: name }) : name +} + +/** + * Render the page's one-liner or its settings form, as the Plugins page asks. * @param props - the view asked for, locale copy, the form snapshot, and its actions. * @returns the one-liner, or the form. */ @@ -29,62 +56,174 @@ export function WebSearchCard(props: WebSearchCardProps) { const state = props.useWebSearchCard(snapshot => snapshot) if (props.view === 'summary') return t('description') const disabled = !state.writable || state.saving - const searchApi = state.useSearchApi - const limitField = searchApi ? 'count' : 'maxUses' + const menuDisabled = disabled || state.selectionBlocked + const { search, fetch } = state + // The selected provider's own description, in the active language; an empty translation shows nothing. + const describe = (section: WebSearchCardState['search' | 'fetch']): string | undefined => { + const description = section.options.find(option => option.id === section.selected)?.description + return description === undefined ? undefined : props.resolveText(description) || undefined + } return ( - {state.apiSwitchAvailable && ( -
- -

- {t('useSearchApiHint')} - {t('searchGuide')} -

-
- )} - { props.edit('apiKey', text) }} - /> - { props.edit('baseURL', text) }} - onReset={() => { props.resetField('baseURL') }} - /> - { props.edit(limitField, text) }} - onReset={() => { props.resetField(limitField) }} - /> +
+

{t('searchSection')}

+ {t('searchGuide')} + : search.kind === 'plugin' ? t('pluginProviderHint') : undefined} /> + {search.kind === 'deepseek' && ( +
+ { props.edit('count', text) }} onReset={() => { props.resetField('count') }} /> +
+ )} + {search.kind === 'legacy' && state.legacyKey !== undefined && ( +
+ { props.edit('legacyApiKey', text) }} /> + { props.edit('messagesBaseURL', text) }} onReset={() => { props.resetField('messagesBaseURL') }} /> + { props.edit('maxUses', text) }} onReset={() => { props.resetField('maxUses') }} /> +
+ )} +
+
+

{t('fetchSection')}

+ +
) } + +/** Menu id of the automatic-selection entry, matching a selection that names no provider. */ +const AUTO_ID = '' + +/** + * A menu button that shows its current value and opens a list of choices. + * Its accessible name is the element `labelledBy` names followed by the current + * value, so the label does not replace the value. + * @param props - the control id, the id of the element naming it, the entries, the current entry and its + * label, the lock state, and the selection callback. + * @returns the anchored menu. + */ +function Dropdown(props: { + id: string + /** Id of the element whose text names the menu. */ + labelledBy: string + items: readonly { id: string; label: string }[] + selectedId: string + /** The label the button shows for the current value. */ + valueLabel: string + disabled: boolean + /** Called with a newly chosen entry; choosing the current one calls nothing. */ + onSelect: (id: string) => void +}) { + const [open, setOpen] = useState(false) + const trigger = useRef(null) + // A disabled menu stays closed even if it was open when the lock arrived. + const shown = open && !props.disabled + const valueId = `${props.id}-value` + const anchor = ( + + ) + return ( + { setOpen(false) }} + items={props.items} + selectedId={props.selectedId} + onSelect={(id) => { + trigger.current?.focus({ preventScroll: true }) + setOpen(false) + if (id !== props.selectedId) props.onSelect(id) + }} + portal + autoFocus + minWidthFromAnchor + anchor={anchor} + /> + ) +} + +/** + * A notice that a list could not be read, with a retry. + * @param props - the notice copy, the retry label, and the retry callback. + * @returns the status line. + */ +function ReadFailure(props: { message: string; retry: string; onRetry: () => void }) { + return ( +
+ {props.message} + +
+ ) +} + +/** + * One section's provider menu, with the selected provider's description and + * the page's own line about it below, and a notice with a retry while the provider list could not be read. + * @param props - the control id, the id of the section title naming it, the section state, the selection callback, and the list read state. + * @returns the labelled menu. + */ +function ProviderSelect(props: { + id: string + /** Id of the section title, which names the menu. */ + labelledBy: string + t: T + section: WebSearchCardState['search' | 'fetch'] + disabled: boolean + field: keyof WebProviderSelection + onSelect: WebSearchCardFace['selectProvider'] + /** The selected provider's registered description, resolved to the active language, if it has one. */ + description: string | undefined + /** The page's own line about the selected provider, after its description, if it has one. */ + hint: ReactNode + /** Whether the latest provider list read failed. */ + providersFailed: boolean + onRetry: WebSearchCardFace['retryProviders'] +}) { + const { t, section } = props + const current = section.options.find(option => option.id === section.selected) + const items = [ + ...section.auto ? [{ id: AUTO_ID, label: t('providerAuto') }] : [], + ...section.options.map(option => ({ id: option.id, label: providerLabel(t, option) })), + ] + return ( +
+ { props.onSelect(props.field, id) }} /> + {props.description !== undefined &&

{props.description}

} + {props.hint !== undefined &&

{props.hint}

} + {props.providersFailed && } +
+ ) +} diff --git a/packages/client/ui-settings-web-search/src/client/index.ts b/packages/client/ui-settings-web-search/src/client/index.ts index b4ed1c6779..6f73122098 100644 --- a/packages/client/ui-settings-web-search/src/client/index.ts +++ b/packages/client/ui-settings-web-search/src/client/index.ts @@ -1,12 +1,11 @@ /** - * The web-search settings page, browser half: the switch between DeepSeek - * Messages search and the Search and Fetch APIs, which writes the `web` - * provider selection, and the selected API's key, endpoint, and per-search - * limit over the `web-search-deepseek` or `web-deepseek-api` namespace. The - * page registers into the Plugins page's `plugins.item` slot while the Host - * serves `web-search-deepseek`, so a deployment without that provider shows no - * trace of it; the switch appears only while the Host also serves `web` and - * `web-deepseek-api`. + * The web search and fetch settings page, browser half: one provider menu per + * capability, which writes the `web` provider selection, the Search API result + * count over the `web-deepseek-api` namespace, and legacy Messages search's + * key, endpoint, and search limit over `web-search-deepseek`. The page + * registers into the Plugins page's `plugins.item` slot while the Host serves + * `web`; a provider's own controls appear only while the Host serves its + * namespace. */ // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -26,13 +25,13 @@ import { en, ja, es, fr, de, ru, pt, zh, type WebSearchSettingsLocaleKey } from export type { WebSearchCardProps } from './WebSearchCard.tsx' export type { - WebApiSettings, WebProviderSelection, WebSearchCardFace, WebSearchCardScopes, WebSearchCardState, WebSearchSettings, + WebApiSettings, WebLegacySettings, WebProviderSelection, WebSearchCardFace, WebSearchCardScopes, WebSearchCardState, } from './web-search-card-controller.ts' export type { WebSearchSettingsLocaleKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { - /** Web-search settings page copy. */ + /** Web search and fetch settings page copy. */ 'settings.webSearch': WebSearchSettingsLocaleKey } } @@ -41,27 +40,36 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { export const NS = 'settings.webSearch' /** Required services (cordis fiber inject). */ -export const inject = ['slots', 'locale', 'remote', 'remote.credentials', 'configForms'] +export const inject = ['slots', 'locale', 'remote', 'remote.credentials', 'remote.web', 'configForms'] /** - * Mount the web-search settings page while the Host serves its namespace. + * Mount the web search and fetch settings page while the Host serves the `web` namespace. * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { const t = ctx.locale.bind(NS) ctx.effect(() => ctx.locale.register(NS, { zh, en, ja, es, fr, de, ru, pt }), 'ui-settings-web-search: dictionaries') const card = new WebSearchCardController({ - web: ctx.configForms.get(WEB_NS), messages: ctx.configForms.get(WEB_SEARCH_NS), api: ctx.configForms.get(WEB_API_NS), + web: ctx.configForms.get(WEB_NS), api: ctx.configForms.get(WEB_API_NS), legacy: ctx.configForms.get(WEB_SEARCH_NS), }, ctx) ctx.effect(() => () => { card.dispose() }, 'ui-settings-web-search: form subscription') - // The credential the page reports is not part of any settings section, so + // The credentials the page reports are not part of any settings section, so // no scope publishes when one is written. This is the only signal // that a key written on another surface reached the Host. ctx.effect( () => ctx.remote.$on('credentials/reference-updated', (ref) => { card.refreshCredential(ref) }), 'ui-settings-web-search: credential invalidations', ) - ctx.effect(() => ctx.configForms.whileServed([WEB_SEARCH_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ + // Plugins register and retract providers without touching any settings + // section, and a reconnected Host may hold a different set. + ctx.effect(() => { + const disposers = [ + ctx.remote.$on('web/providers-changed', () => { card.refreshProviders() }), + ctx.on('connection/reset', () => { card.refreshProviders() }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-settings-web-search: provider invalidations') + ctx.effect(() => ctx.configForms.whileServed([WEB_NS], () => ctx.slots.inject('plugins.item', () => ctx.slots.register({ name: 'plugins.item', id: 'web-search', order: 20, label: () => t('title'), locale: NS, inject: () => card.inject(), }, WebSearchCard))), 'ui-settings-web-search: page') } diff --git a/packages/client/ui-settings-web-search/src/client/locales.ts b/packages/client/ui-settings-web-search/src/client/locales.ts index 1290fb9000..1165f08054 100644 --- a/packages/client/ui-settings-web-search/src/client/locales.ts +++ b/packages/client/ui-settings-web-search/src/client/locales.ts @@ -4,30 +4,43 @@ import type { SettingsFormLabels } from '@deepseek-ai/dsh-client-ui-primitives' /** Locale keys the page renders. */ export type WebSearchSettingsLocaleKey = - | 'title' | 'description' | 'useSearchApi' | 'useSearchApiHint' | 'searchGuide' | 'searchGuideUrl' - | 'apiKey' | 'apiKeyHint' | 'apiKeySet' | 'apiKeyUnset' - | 'baseUrl' | 'baseUrlHint' | 'searchApiBaseUrl' | 'searchApiBaseUrlHint' | 'maxUses' | 'maxUsesHint' | 'count' | 'countHint' + | 'title' | 'description' | 'searchSection' | 'fetchSection' + | 'providerDeepSeek' | 'providerLocal' | 'providerDeepSeekFetch' | 'providerMissing' | 'providerAuto' + | 'providerLegacy' | 'legacyKeyHint' | 'maxUses' | 'maxUsesHint' | 'invalidMaxUses' + | 'providersFailed' | 'retry' + | 'pluginProviderHint' | 'searchGuide' | 'searchGuideUrl' + | 'credential' | 'apiKeySet' | 'apiKeyUnsetApi' + | 'baseUrl' | 'baseUrlHint' + | 'count' | 'countHint' | 'overridden' | 'reset' | 'readOnly' | 'unavailable' | 'save' | 'saving' | 'saveFailed' | 'invalidNumber' | 'invalidCount' /** English copy. */ export const en: Record = { - title: 'Web search', - description: 'Set up the DeepSeek search provider.', - useSearchApi: 'Use the new Web Search API', - useSearchApiHint: 'Off by default: search runs through DeepSeek Messages. For the new Web Search API\'s features and pricing, see the ', + title: 'Web search and fetch', + description: 'Choose the providers web search and web fetch use.', + searchSection: 'Web search provider', + fetchSection: 'Web fetch provider', + providerDeepSeek: 'DeepSeek Search API', + providerLegacy: 'DeepSeek (legacy)', + legacyKeyHint: 'Uses the signed-in account on account-model requests; otherwise uses this API key ({ref}), shared with Models by default.', + maxUses: 'Maximum searches per request', + maxUsesHint: 'Maximum server-side searches per Messages request: a positive integer, default 5.', + invalidMaxUses: 'Enter a positive integer, or leave blank to use the default.', + providerLocal: 'Direct fetch', + providerDeepSeekFetch: 'DeepSeek Fetch API', + providerMissing: '{id} (not loaded)', + providerAuto: 'Automatic', + providersFailed: "Couldn't load the provider list, so this menu may be incomplete.", + retry: 'Retry', + pluginProviderHint: 'Configure this provider in its own plugin settings.', + credential: 'API key', + baseUrl: 'Base URL', + baseUrlHint: 'Include the API prefix. Leave blank to use the official address. The provider appends {path}.', + apiKeyUnsetApi: 'No key is configured.', searchGuide: 'Search & Fetch guide', searchGuideUrl: 'https://api-docs.deepseek.com/guides/search', - apiKey: 'API key', - apiKeyHint: 'Stored outside the settings file. Leave blank to keep the current key.', apiKeySet: 'A key is configured.', - apiKeyUnset: 'No key is configured; only conversations using a DeepSeek Account model can search, through the default endpoint.', - baseUrl: 'Messages API base URL', - baseUrlHint: 'Include the API prefix, such as https://api.deepseek.com/anthropic/v1. The provider appends /messages. Leave blank to restore the default.', - searchApiBaseUrl: 'Search / Fetch API base URL', - searchApiBaseUrlHint: 'Include the API prefix, such as https://api.deepseek.com/v1/tools. The provider appends /search or /fetch. Leave blank to restore the default.', - maxUses: 'Max searches per request', - maxUsesHint: 'How many searches the auxiliary model may run within one Messages request. Applies only to the Messages API.', count: 'Results per search', countHint: 'Results returned per query: an integer from 1 to 50, default 10. This controls result count, not search calls. web_search keeps no more sources than its own result limit.', overridden: 'Overridden', @@ -43,22 +56,30 @@ export const en: Record = { /** Simplified Chinese copy. */ export const zh: Record = { - title: '网页搜索', - description: '设置 DeepSeek 的搜索提供方。', - useSearchApi: '启用新版 Web 搜索 API', - useSearchApiHint: '默认关闭,搜索通过 DeepSeek Messages 完成。新版 Web 搜索 API 功能与计费详见', - searchGuide: '网页搜索与抓取指南', - searchGuideUrl: 'https://api-docs.deepseek.com/zh-cn/guides/search', - apiKey: 'API Key', - apiKeyHint: '不写入设置文件。留空表示保持当前密钥。', - apiKeySet: '已配置密钥。', - apiKeyUnset: '未配置密钥;仅使用 DeepSeek 账号模型的对话可以通过默认接口地址搜索。', - baseUrl: 'Messages API 基础地址', - baseUrlHint: '填写含 API 前缀的地址,例如 https://api.deepseek.com/anthropic/v1;程序追加 /messages,留空恢复默认。', - searchApiBaseUrl: 'Search / Fetch API 基础地址', - searchApiBaseUrlHint: '填写含 API 前缀的地址,例如 https://api.deepseek.com/v1/tools;程序追加 /search 或 /fetch,留空恢复默认。', + title: '网页搜索与获取', + description: '选择网页搜索与网页获取使用的提供方。', + searchSection: '网页搜索提供方', + fetchSection: '网页获取提供方', + providerDeepSeek: 'DeepSeek Search API', + providerLegacy: 'DeepSeek(旧版)', + legacyKeyHint: '账号模型发起的请求使用已登录账号;其他请求使用此 API Key({ref}),默认与「模型」页共用', maxUses: '单次请求最多搜索次数', - maxUsesHint: '辅助模型在一次 Messages 请求内最多可执行的搜索次数,仅适用于 Messages API。', + maxUsesHint: '每次 Messages 请求允许的服务端搜索次数上限,须为正整数,默认 5', + invalidMaxUses: '请填正整数;留空表示使用默认值', + providerLocal: '本机直接获取', + providerDeepSeekFetch: 'DeepSeek Fetch API', + providerMissing: '{id}(未加载)', + providerAuto: '自动选择', + providersFailed: '无法读取提供方列表,此菜单可能不完整', + retry: '重试', + pluginProviderHint: '请在该插件自己的设置中配置', + credential: 'API Key', + baseUrl: 'Base URL', + baseUrlHint: '填写含 API 前缀的地址;留空时使用官方地址;程序追加 {path}', + apiKeyUnsetApi: '未配置密钥。', + searchGuide: '网页搜索与获取指南', + searchGuideUrl: 'https://api-docs.deepseek.com/zh-cn/guides/search', + apiKeySet: '已配置密钥。', count: '每次搜索结果数', countHint: '每个查询返回的结果条数,须为 1–50 的整数,默认 10;不控制搜索调用次数。web_search 保留的来源数不超过其自身的结果上限。', overridden: '已覆盖', @@ -83,22 +104,30 @@ export function formLabels(t: (key: WebSearchSettingsLocaleKey) => string): Sett /** Japanese copy, checked against the English keys. */ export const ja = { - 'title': 'Web 検索', - 'description': 'DeepSeek 検索プロバイダーを設定します。', - 'useSearchApi': '新しい Web Search API を使用する', - 'useSearchApiHint': 'デフォルトではオフです。検索は DeepSeek Messages 経由で実行されます。新しい Web Search API の機能と料金については、', + 'title': 'Web 検索と取得', + 'description': 'Web 検索と Web 取得に使用するプロバイダーを選択します。', + 'searchSection': 'Web 検索プロバイダー', + 'fetchSection': 'Web 取得プロバイダー', + 'providerDeepSeek': 'DeepSeek Search API', + 'providerLegacy': 'DeepSeek(旧版)', + 'legacyKeyHint': 'アカウントモデルのリクエストではログイン中のアカウントを使い、それ以外ではこの API キー({ref}、既定ではモデル設定と共有)を使います。', + 'maxUses': 'リクエストごとの最大検索回数', + 'maxUsesHint': 'Messages リクエストごとのサーバー側検索回数の上限です。正の整数で、既定値は 5 です。', + 'invalidMaxUses': '正の整数を入力するか、既定値を使う場合は空欄にしてください。', + 'providerLocal': '直接取得', + 'providerDeepSeekFetch': 'DeepSeek Fetch API', + 'providerMissing': '{id}(未読み込み)', + 'providerAuto': '自動', + 'providersFailed': 'プロバイダー一覧を読み込めなかったため、このメニューは不完全な可能性があります。', + 'retry': '再試行', + 'pluginProviderHint': 'このプロバイダーは、そのプラグイン自身の設定で構成してください。', + 'credential': 'API キー', + 'baseUrl': 'ベース URL', + 'baseUrlHint': 'API プレフィックスを含めてください。空欄の場合は公式アドレスを使用します。プロバイダーは {path} を追加します。', + 'apiKeyUnsetApi': 'キーが設定されていません。', 'searchGuide': '検索と取得のガイド', 'searchGuideUrl': 'https://api-docs.deepseek.com/guides/search', - 'apiKey': 'API キー', - 'apiKeyHint': '設定ファイルの外に保存されます。現在のキーを維持するには空欄のままにしてください。', 'apiKeySet': 'キーが設定されています。', - 'apiKeyUnset': 'キーが設定されていません。DeepSeek Account のモデルを使用する会話のみ、デフォルトのエンドポイント経由で検索できます。', - 'baseUrl': 'Messages API ベース URL', - 'baseUrlHint': 'https://api.deepseek.com/anthropic/v1 など、API プレフィックスを含めてください。プロバイダーは /messages を追加します。デフォルトに戻すには空欄にしてください。', - 'searchApiBaseUrl': 'Search / Fetch API ベース URL', - 'searchApiBaseUrlHint': 'https://api.deepseek.com/v1/tools など、API プレフィックスを含めてください。プロバイダーは /search または /fetch を追加します。デフォルトに戻すには空欄にしてください。', - 'maxUses': 'リクエストあたりの最大検索回数', - 'maxUsesHint': '1 回の Messages リクエスト内で補助モデルが実行できる検索回数です。Messages API にのみ適用されます。', 'count': '検索あたりの結果数', 'countHint': 'クエリごとに返される結果数: 1〜50 の整数、デフォルトは 10 です。これは結果数であり、検索呼び出し数ではありません。web_search は自身の結果上限を超えるソースを保持しません。', 'overridden': '上書き済み', @@ -114,22 +143,30 @@ export const ja = { /** Spanish dictionary, checked against the English keys. */ export const es = { - title: 'búsqueda web', - description: 'Configure el proveedor de búsqueda DeepSeek.', - useSearchApi: 'Utilice la nueva búsqueda web API', - useSearchApiHint: 'Desactivado de forma predeterminada: la búsqueda se ejecuta a través de Mensajes DeepSeek. Para conocer las características y los precios del nuevo Web Search API, consulte el ', + title: 'Búsqueda y obtención web', + description: 'Elija los proveedores que usan la búsqueda web y la obtención web.', + searchSection: 'Proveedor de búsqueda web', + fetchSection: 'Proveedor de obtención web', + providerDeepSeek: 'DeepSeek Search API', + providerLegacy: 'DeepSeek (heredado)', + legacyKeyHint: 'Las solicitudes del modelo de cuenta usan la cuenta conectada; las demás usan esta clave API ({ref}), compartida con Modelos de forma predeterminada.', + maxUses: 'Búsquedas máximas por solicitud', + maxUsesHint: 'Máximo de búsquedas del servidor por solicitud Messages: entero positivo, predeterminado 5.', + invalidMaxUses: 'Introduzca un entero positivo o deje vacío para usar el valor predeterminado.', + providerLocal: 'Obtención directa', + providerDeepSeekFetch: 'DeepSeek Fetch API', + providerMissing: '{id} (no cargado)', + providerAuto: 'Automático', + providersFailed: 'No se pudo cargar la lista de proveedores, por lo que este menú puede estar incompleto.', + retry: 'Reintentar', + pluginProviderHint: 'Configure este proveedor en la configuración de su propio complemento.', + credential: 'Clave de API', + baseUrl: 'URL base', + baseUrlHint: 'Incluya el prefijo de la API. Déjelo en blanco para usar la dirección oficial. El proveedor agrega {path}.', + apiKeyUnsetApi: 'No hay ninguna clave configurada.', searchGuide: 'Guía de búsqueda y recuperación', searchGuideUrl: 'https://api-docs.deepseek.com/guides/search', - apiKey: 'Clave de API', - apiKeyHint: 'Almacenado fuera del archivo de configuración. Déjelo en blanco para conservar la clave actual.', apiKeySet: 'Se configura una clave.', - apiKeyUnset: 'No hay ninguna clave configurada; solo se pueden buscar conversaciones que utilicen un modelo de cuenta DeepSeek, a través del punto final predeterminado.', - baseUrl: 'Mensajes API URL base', - baseUrlHint: 'Incluya el prefijo API, como https://api.deepseek.com/anthropic/v1.. El proveedor agrega /messages. Déjelo en blanco para restaurar el valor predeterminado.', - searchApiBaseUrl: 'Buscar/obtener URL base API', - searchApiBaseUrlHint: 'Incluya el prefijo API, como https://api.deepseek.com/v1/tools.. El proveedor agrega /search o /fetch. Déjelo en blanco para restaurar el valor predeterminado.', - maxUses: 'Búsquedas máximas por solicitud', - maxUsesHint: 'Cuántas búsquedas puede ejecutar el modelo auxiliar dentro de una solicitud de Mensajes. Aplica sólo para Mensajes API.', count: 'Resultados por búsqueda', countHint: 'Resultados devueltos por consulta: un número entero del 1 al 50, valor predeterminado 10. Esto controla el recuento de resultados, no las llamadas de búsqueda. web_search no guarda más fuentes que su propio límite de resultados.', overridden: 'Anulado', @@ -145,22 +182,30 @@ export const es = { /** French dictionary, checked against the English keys. */ export const fr = { - title: 'Recherche sur le Web', - description: 'Configurez le moteur de recherche DeepSeek.', - useSearchApi: 'Utilisez la nouvelle recherche Web API', - useSearchApiHint: "Désactivé par défaut : la recherche s'effectue dans les messages DeepSeek. Pour connaître les fonctionnalités et les tarifs du nouveau Web Search API, consultez le ", + title: 'Recherche et récupération web', + description: 'Choisissez les fournisseurs utilisés par la recherche web et la récupération web.', + searchSection: 'Fournisseur de recherche web', + fetchSection: 'Fournisseur de récupération web', + providerDeepSeek: 'DeepSeek Search API', + providerLegacy: 'DeepSeek (ancienne version)', + legacyKeyHint: 'Les requêtes du modèle de compte utilisent le compte connecté ; les autres utilisent cette clé API ({ref}), partagée avec Modèles par défaut.', + maxUses: 'Recherches maximales par requête', + maxUsesHint: 'Maximum de recherches côté serveur par requête Messages : entier positif, 5 par défaut.', + invalidMaxUses: 'Saisissez un entier positif ou laissez vide pour utiliser la valeur par défaut.', + providerLocal: 'Récupération directe', + providerDeepSeekFetch: 'DeepSeek Fetch API', + providerMissing: '{id} (non chargé)', + providerAuto: 'Automatique', + providersFailed: 'Impossible de charger la liste des fournisseurs ; ce menu peut être incomplet.', + retry: 'Réessayer', + pluginProviderHint: 'Configurez ce fournisseur dans les paramètres de son propre plugin.', + credential: 'Clé API', + baseUrl: 'URL de base', + baseUrlHint: "Incluez le préfixe de l'API. Laissez vide pour utiliser l'adresse officielle. Le fournisseur ajoute {path}.", + apiKeyUnsetApi: "Aucune clé n'est configurée.", searchGuide: 'Guide de recherche et de récupération', searchGuideUrl: 'https://api-docs.deepseek.com/guides/search', - apiKey: 'Clé API', - apiKeyHint: 'Stocké en dehors du fichier de paramètres. Laissez vide pour conserver la clé actuelle.', apiKeySet: 'Une clé est configurée.', - apiKeyUnset: "Aucune clé n'est configurée ; seules les conversations utilisant un modèle de compte DeepSeek peuvent effectuer une recherche, via le point de terminaison par défaut.", - baseUrl: 'URL de base des messages API', - baseUrlHint: 'Incluez le préfixe API, tel que https://api.deepseek.com/anthropic/v1.. Le fournisseur ajoute /messages. Laissez vide pour restaurer la valeur par défaut.', - searchApiBaseUrl: "Rechercher/récupérer l'URL de base de API", - searchApiBaseUrlHint: 'Incluez le préfixe API, tel que https://api.deepseek.com/v1/tools.. Le fournisseur ajoute /search ou /fetch. Laissez vide pour restaurer la valeur par défaut.', - maxUses: 'Nombre maximum de recherches par requête', - maxUsesHint: "Combien de recherches le modèle auxiliaire peut exécuter dans une requête de messages. S'applique uniquement aux messages API.", count: 'Résultats par recherche', countHint: 'Résultats renvoyés par requête : un nombre entier de 1 à 50, 10 par défaut. Cela contrôle le nombre de résultats, pas les appels de recherche. web_search ne conserve pas plus de sources que sa propre limite de résultats.', overridden: 'Remplacé', @@ -176,22 +221,30 @@ export const fr = { /** German dictionary, checked against the English keys. */ export const de = { - title: 'Websuche', - description: 'Richten Sie den Suchanbieter DeepSeek ein.', - useSearchApi: 'Nutzen Sie die neue Websuche API', - useSearchApiHint: 'Standardmäßig deaktiviert: Die Suche erfolgt über DeepSeek-Nachrichten. Informationen zu den Funktionen und Preisen der neuen Websuche API finden Sie im ', + title: 'Websuche und Webabruf', + description: 'Wählen Sie die Anbieter für Websuche und Webabruf.', + searchSection: 'Anbieter für Websuche', + fetchSection: 'Anbieter für Webabruf', + providerDeepSeek: 'DeepSeek Search API', + providerLegacy: 'DeepSeek (Legacy)', + legacyKeyHint: 'Kontomodellanfragen verwenden das angemeldete Konto; andere verwenden diesen API-Schlüssel ({ref}), standardmäßig mit Modelle geteilt.', + maxUses: 'Maximale Suchen pro Anfrage', + maxUsesHint: 'Maximale serverseitige Suchen pro Messages-Anfrage: positive Ganzzahl, Standard 5.', + invalidMaxUses: 'Geben Sie eine positive Ganzzahl ein oder lassen Sie das Feld für den Standard leer.', + providerLocal: 'Direkter Abruf', + providerDeepSeekFetch: 'DeepSeek Fetch API', + providerMissing: '{id} (nicht geladen)', + providerAuto: 'Automatisch', + providersFailed: 'Die Anbieterliste konnte nicht geladen werden, daher ist dieses Menü möglicherweise unvollständig.', + retry: 'Erneut versuchen', + pluginProviderHint: 'Konfigurieren Sie diesen Anbieter in den Einstellungen seines eigenen Plugins.', + credential: 'API-Schlüssel', + baseUrl: 'Basis-URL', + baseUrlHint: 'Fügen Sie das API-Präfix ein. Leer lassen, um die offizielle Adresse zu verwenden. Der Anbieter hängt {path} an.', + apiKeyUnsetApi: 'Es ist kein Schlüssel konfiguriert.', searchGuide: 'Leitfaden zum Suchen und Abrufen', searchGuideUrl: 'https://api-docs.deepseek.com/guides/search', - apiKey: 'API-Schlüssel', - apiKeyHint: 'Außerhalb der Einstellungsdatei gespeichert. Lassen Sie das Feld leer, um den aktuellen Schlüssel beizubehalten.', apiKeySet: 'Ein Schlüssel ist konfiguriert.', - apiKeyUnset: 'Es ist kein Schlüssel konfiguriert; Nur Konversationen, die ein DeepSeek-Kontomodell verwenden, können über den Standardendpunkt suchen.', - baseUrl: 'Nachrichten API Basis-URL', - baseUrlHint: 'Fügen Sie das Präfix API ein, z. B. https://api.deepseek.com/anthropic/v1.. Der Anbieter hängt /messages an. Lassen Sie das Feld leer, um die Standardeinstellung wiederherzustellen.', - searchApiBaseUrl: 'Suchen/Abrufen der API-Basis-URL', - searchApiBaseUrlHint: 'Fügen Sie das Präfix API ein, z. B. https://api.deepseek.com/v1/tools.. Der Anbieter hängt /search oder /fetch an. Lassen Sie das Feld leer, um die Standardeinstellung wiederherzustellen.', - maxUses: 'Maximale Anzahl an Suchanfragen pro Anfrage', - maxUsesHint: 'Wie viele Suchvorgänge das Hilfsmodell innerhalb einer Nachrichtenanforderung ausführen darf. Gilt nur für die Nachrichten API.', count: 'Ergebnisse pro Suche', countHint: 'Pro Abfrage zurückgegebene Ergebnisse: eine Ganzzahl von 1 bis 50, Standard 10. Dies steuert die Ergebnisanzahl, nicht Suchaufrufe. web_search behält nicht mehr Quellen als sein eigenes Ergebnislimit.', overridden: 'Überschrieben', @@ -207,22 +260,30 @@ export const de = { /** Russian dictionary, checked against the English keys. */ export const ru = { - title: 'Веб-поиск', - description: 'Настройте поисковую систему DeepSeek.', - useSearchApi: 'Используйте новый веб-поиск API', - useSearchApiHint: 'По умолчанию отключено: поиск осуществляется по сообщениям DeepSeek. Информацию о новых функциях и ценах веб-поиска API см. ', + title: 'Веб-поиск и загрузка', + description: 'Выберите поставщиков для веб-поиска и загрузки веб-страниц.', + searchSection: 'Поставщик веб-поиска', + fetchSection: 'Поставщик загрузки веб-страниц', + providerDeepSeek: 'DeepSeek Search API', + providerLegacy: 'DeepSeek (прежняя версия)', + legacyKeyHint: 'Запросы модели аккаунта используют активный аккаунт; остальные — этот ключ API ({ref}), по умолчанию общий с настройками моделей.', + maxUses: 'Максимум поисков на запрос', + maxUsesHint: 'Максимум серверных поисков на запрос Messages: положительное целое, по умолчанию 5.', + invalidMaxUses: 'Введите положительное целое или оставьте пустым для значения по умолчанию.', + providerLocal: 'Прямая загрузка', + providerDeepSeekFetch: 'DeepSeek Fetch API', + providerMissing: '{id} (не загружен)', + providerAuto: 'Автоматически', + providersFailed: 'Не удалось загрузить список поставщиков, поэтому это меню может быть неполным.', + retry: 'Повторить', + pluginProviderHint: 'Настройте этого поставщика в настройках его собственного плагина.', + credential: 'Ключ API', + baseUrl: 'Базовый URL', + baseUrlHint: 'Включите префикс API. Оставьте пустым, чтобы использовать официальный адрес. Поставщик добавляет {path}.', + apiKeyUnsetApi: 'Ключ не настроен.', searchGuide: 'Руководство по поиску и получению', searchGuideUrl: 'https://api-docs.deepseek.com/guides/search', - apiKey: 'Ключ API', - apiKeyHint: 'Хранится вне файла настроек. Оставьте пустым, чтобы сохранить текущий ключ.', apiKeySet: 'Ключ настроен.', - apiKeyUnset: 'Ключ не настроен; только разговоры, использующие модель учетной записи DeepSeek, могут выполнять поиск через конечную точку по умолчанию.', - baseUrl: 'Базовый URL-адрес сообщений API', - baseUrlHint: 'Включите префикс API, например https://api.deepseek.com/anthropic/v1.. Поставщик добавляет /messages. Оставьте пустым, чтобы восстановить значение по умолчанию.', - searchApiBaseUrl: 'Найдите/получите базовый URL-адрес API', - searchApiBaseUrlHint: 'Включите префикс API, например https://api.deepseek.com/v1/tools.. Поставщик добавляет /search или /fetch. Оставьте пустым, чтобы восстановить значение по умолчанию.', - maxUses: 'Максимальное количество запросов на запрос', - maxUsesHint: 'Сколько поисков вспомогательная модель может выполнить в рамках одного запроса сообщений. Применяется только к сообщениям API.', count: 'Результаты по поиску', countHint: 'Результаты, возвращаемые по запросу: целое число от 1 до 50, по умолчанию 10. Это контролирует количество результатов, а не поисковые вызовы. web_search хранит не больше источников, чем его собственный лимит результатов.', overridden: 'Переопределено', @@ -238,22 +299,30 @@ export const ru = { /** Portuguese dictionary, checked against the English keys. */ export const pt = { - title: 'Pesquisa na web', - description: 'Configure o provedor de pesquisa DeepSeek.', - useSearchApi: 'Use a nova Pesquisa na Web API', - useSearchApiHint: 'Desativado por padrão: a pesquisa é executada em DeepSeek Mensagens. Para conhecer os recursos e preços da nova Pesquisa na Web API, consulte o ', + title: 'Pesquisa e obtenção na web', + description: 'Escolha os provedores usados pela pesquisa na web e pela obtenção na web.', + searchSection: 'Provedor de pesquisa na web', + fetchSection: 'Provedor de obtenção na web', + providerDeepSeek: 'DeepSeek Search API', + providerLegacy: 'DeepSeek (legado)', + legacyKeyHint: 'Pedidos do modelo de conta usam a conta conectada; os demais usam esta chave API ({ref}), compartilhada com Modelos por padrão.', + maxUses: 'Máximo de pesquisas por pedido', + maxUsesHint: 'Máximo de pesquisas no servidor por pedido Messages: inteiro positivo, padrão 5.', + invalidMaxUses: 'Insira um inteiro positivo ou deixe vazio para usar o padrão.', + providerLocal: 'Obtenção direta', + providerDeepSeekFetch: 'DeepSeek Fetch API', + providerMissing: '{id} (não carregado)', + providerAuto: 'Automático', + providersFailed: 'Não foi possível carregar a lista de provedores, por isso este menu pode estar incompleto.', + retry: 'Tentar novamente', + pluginProviderHint: 'Configure este provedor nas configurações do próprio plugin.', + credential: 'Chave de API', + baseUrl: 'URL base', + baseUrlHint: 'Inclua o prefixo da API. Deixe em branco para usar o endereço oficial. O provedor anexa {path}.', + apiKeyUnsetApi: 'Nenhuma chave está configurada.', searchGuide: 'Guia de pesquisa e busca', searchGuideUrl: 'https://api-docs.deepseek.com/guides/search', - apiKey: 'API chave', - apiKeyHint: 'Armazenado fora do arquivo de configurações. Deixe em branco para manter a chave atual.', apiKeySet: 'Uma chave está configurada.', - apiKeyUnset: 'Nenhuma chave está configurada; somente conversas que usam um modelo de conta DeepSeek podem pesquisar, por meio do endpoint padrão.', - baseUrl: 'URL base de mensagens API', - baseUrlHint: 'Inclua o prefixo API, como https://api.deepseek.com/anthropic/v1. O provedor anexa /messages. Deixe em branco para restaurar o padrão.', - searchApiBaseUrl: 'Pesquisar/buscar API URL base', - searchApiBaseUrlHint: 'Inclua o prefixo API, como https://api.deepseek.com/v1/tools. O provedor anexa /search ou /fetch. Deixe em branco para restaurar o padrão.', - maxUses: 'Máximo de pesquisas por solicitação', - maxUsesHint: 'Quantas pesquisas o modelo auxiliar pode executar em uma solicitação de Mensagens. Aplica-se somente às Mensagens API.', count: 'Resultados por pesquisa', countHint: 'Resultados retornados por consulta: um número inteiro de 1 a 50, padrão 10. Isso controla a contagem de resultados, não as chamadas de pesquisa. web_search não mantém mais fontes do que seu próprio limite de resultados.', overridden: 'Substituído', diff --git a/packages/client/ui-settings-web-search/src/client/web-search-card-controller.ts b/packages/client/ui-settings-web-search/src/client/web-search-card-controller.ts index b4f014d433..42dbaea4e0 100644 --- a/packages/client/ui-settings-web-search/src/client/web-search-card-controller.ts +++ b/packages/client/ui-settings-web-search/src/client/web-search-card-controller.ts @@ -1,26 +1,38 @@ /** - * The web-search page's staged form over three settings namespaces: `web`, - * whose provider selection the Search API switch writes, `web-search-deepseek` - * for Messages search, and `web-deepseek-api` for the Search and Fetch APIs. + * The web search and fetch page's staged form over three settings namespaces: + * `web`, whose `searchProvider` and `fetchProvider` the two provider menus + * write, `web-deepseek-api` for the DeepSeek Search and Fetch APIs, and + * `web-search-deepseek` for legacy Messages search. * - * The switch selects `deepseek-search-api` and `deepseek-fetch-api` together, or - * Messages search with local HTTP fetch. A selection equal to the deployment's - * composed value clears the override instead of storing a copy of it. + * A selection equal to the deployment's composed value clears the override + * instead of storing a copy of it, and while the deployment composes no + * provider for a capability its menu also offers automatic selection, which + * clears the override. The menus offer the providers the Host reports through + * `web/listProviders`, the official ones first in a fixed order and the rest + * in registration order, plus a selected id the Host no longer registers. A + * failed read is reported on the page with a retry. * - * The key is the one control that does not live in a section: its literal - * never rides a response, so the page learns only whether one is configured - * and writes it through the credentials domain, addressed by the reference the - * selected API's section names. It is still staged with the rest of the form, - * so one save covers everything the page shows. + * The DeepSeek Search and Fetch APIs take no credential settings here: they + * follow the DeepSeek credential of the conversation, and their own endpoint + * fields are YAML-only. Legacy Messages search keeps its key control. + * + * The legacy key's literal never rides a response, so the page learns only + * whether one is configured and writes it through the credentials domain, + * addressed by the reference `web-search-deepseek.apiKeyEnv` names. A key + * draft belongs to the reference its control addressed when it was typed: it + * is dropped when that reference changes or the page stops showing the + * control, and a save writes it under that reference. The key is still staged + * with the rest of the form, so one save covers everything the page shows. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' // Type-only: pulls the ctx.remote merge into this program. import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type { WebProviderInfo, WebProviderList as ProviderList } from '@deepseek-ai/dsh-web/types' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { SettingsFormModel, settingsNumberField, settingsTextField, - type SettingsFieldState, type SettingsFormActions, type SettingsFormShell, type SettingsFormScope, type SettingsFormScopeSnapshot, + type SettingsFieldState, type SettingsFormActions, type SettingsFormShell, type SettingsFormScope, } from '@deepseek-ai/dsh-client-ui-primitives' /** @@ -33,17 +45,37 @@ export const WEB_API_NS = 'web-deepseek-api' /** Namespace of the web service's provider selection. */ export const WEB_NS = 'web' -/** Provider ids the switch writes into the web service's selection. */ -const PROVIDERS = { - searchProvider: { api: 'deepseek-search-api', messages: 'deepseek-official' }, - fetchProvider: { api: 'deepseek-fetch-api', messages: 'http' }, +/** Provider ids of the official providers whose settings this page edits. */ +export const PROVIDER_IDS = { + apiSearch: 'deepseek-search-api', + legacySearch: 'deepseek-official', + localFetch: 'http', + apiFetch: 'deepseek-fetch-api', } as const -/** Credential reference the providers resolve when a section names none. */ +/** + * The official providers each menu lists first, in this order, while the Host + * registers them; the Host's own list stays in registration order. + */ +const MENU_ORDER = { + search: [PROVIDER_IDS.apiSearch, PROVIDER_IDS.legacySearch], + fetch: [PROVIDER_IDS.localFetch, PROVIDER_IDS.apiFetch], +} as const satisfies Record + +/** Credential reference legacy Messages search resolves when its section names none. */ const DEFAULT_API_KEY_REF = 'DEEPSEEK_API_KEY' /** Form field the credential control stages under. */ -const API_KEY_FIELD = 'apiKey' +const API_KEY_FIELD = 'legacyApiKey' + +/** Settings-section fields the face edits as text. */ +export type WebSectionField = 'count' | 'messagesBaseURL' | 'maxUses' + +/** The legacy key control. */ +export type WebApiKeyField = 'legacyApiKey' + +/** Controls the face edits as text: the key controls and the settings-section fields. */ +export type WebSearchCardField = WebApiKeyField | WebSectionField /** The web service's provider selection. */ export interface WebProviderSelection { @@ -54,7 +86,7 @@ export interface WebProviderSelection { } /** The Messages search fields this page edits. */ -export interface WebSearchSettings { +export interface WebLegacySettings { /** Credential reference naming the environment key. */ apiKeyEnv?: string /** Messages endpoint; blank inherits the provider default. */ @@ -65,15 +97,102 @@ export interface WebSearchSettings { /** The Search and Fetch API fields this page edits. */ export interface WebApiSettings { - /** Credential reference naming the environment key. */ - apiKeyEnv?: string - /** Search and Fetch API base with its path prefix; blank inherits the provider default. */ - baseURL?: string /** Search API results per search. */ count?: number } -/** What the credentials domain last reported, and for which reference. */ +/** One entry of a provider menu. */ +export interface WebProviderOption { + /** Provider id, written into the selection. */ + id: string + /** Title the provider registered with, if any. */ + title?: string + /** Description the provider registered with, if any; the page shows it under the menu while the provider is selected. */ + description?: WebProviderInfo['description'] + /** True for a selected id the Host does not currently register. */ + missing: boolean +} + +/** What the Search section shows for its selected provider. */ +export type WebSearchKind = 'deepseek' | 'legacy' | 'plugin' | 'none' +/** What the Fetch section shows for its selected provider. */ +export type WebFetchKind = 'local' | 'deepseek' | 'plugin' | 'none' + +/** The legacy key control. */ +export interface WebApiKeyState extends SettingsFieldState { + /** Whether the Host reports a credential configured for the referenced key. */ + configured: boolean + /** Whether the credentials domain accepts a write for it; false disables the control. */ + writable: boolean + /** The credential reference the control writes: `web-search-deepseek.apiKeyEnv`, or its default. */ + ref: string +} + +/** One provider section: its menu and the controls its selection shows. */ +export interface WebProviderSectionState { + /** Id of the selected provider, or the empty string when the selection names none. */ + selected: string + /** Menu entries: the registered providers, plus a selected id that is not registered. */ + options: readonly WebProviderOption[] + /** Which provider-specific controls the section shows. */ + kind: K + /** Whether the menu offers automatic selection, because the deployment composes no provider for this capability. */ + auto: boolean +} + +/** What the web search and fetch page renders. */ +export interface WebSearchCardState extends SettingsFormShell { + /** Invalid edits or failed saves that changing providers must keep visible. */ + selectionBlocked: boolean + /** + * Whether the latest provider list read failed. The menus keep the last list + * read, or before any list arrives only the selected ids, so they may be + * incomplete. + */ + providersFailed: boolean + /** The Search section. */ + search: WebProviderSectionState + /** The Fetch section. */ + fetch: WebProviderSectionState + /** Search API results per search, shown in the Search section while it selects the DeepSeek Search API. */ + count: SettingsFieldState + /** Legacy Messages endpoint. */ + messagesBaseURL: SettingsFieldState + /** Legacy server-side search limit. */ + maxUses: SettingsFieldState + /** Legacy key control, present only while the legacy provider is selected. */ + legacyKey: WebApiKeyState | undefined +} + +/** The registration-side face the page's slot entry injects. */ +export interface WebSearchCardFace extends Pick { + hooks: { + /** Page snapshot bound by the renderer as useWebSearchCard. */ + webSearchCard: SnapshotStore + } + /** Stage draft text for one control. */ + edit: (field: WebSearchCardField, text: string) => void + /** Stage a clear for one section field. */ + resetField: (field: WebSectionField) => void + /** Stage one provider selection; the empty id stages automatic selection. */ + selectProvider: (field: keyof WebProviderSelection, id: string) => void + /** Re-read the registered providers after a failed read. */ + retryProviders: () => void + /** Resolve a provider description to the active language. */ + resolveText: (text: NonNullable) => string +} + +/** Settings scopes for the namespaces the page edits. */ +export interface WebSearchCardScopes { + /** The web service's provider selection. */ + web: SettingsFormScope + /** Search and Fetch API settings. */ + api: SettingsFormScope + /** Legacy Messages search settings. */ + legacy: SettingsFormScope +} + +/** What the credentials domain last reported for the legacy key control. */ interface CredentialState { /** Reference this answer describes; a stale response for another one is dropped. */ ref: string @@ -83,77 +202,47 @@ interface CredentialState { writable: boolean } -/** What the web-search page renders. */ -export interface WebSearchCardState extends SettingsFormShell { - /** Whether the Host serves both the selection and the Search / Fetch API section. */ - apiSwitchAvailable: boolean - /** Invalid edits or failed saves that switching APIs must keep visible. */ - apiSwitchBlocked: boolean - /** Staged switch position: true selects the Search and Fetch APIs. */ - useSearchApi: boolean - /** Endpoint of the selected API. */ - baseURL: SettingsFieldState - /** Maximum Messages server-tool uses. */ - maxUses: SettingsFieldState - /** Search API results per search. */ - count: SettingsFieldState - /** The staged credential, which starts blank on every load. */ - apiKey: SettingsFieldState - /** Whether the Host reports a credential configured for the referenced key. */ - apiKeyConfigured: boolean - /** Whether the credentials domain accepts a write for it; false disables the control. */ - apiKeyWritable: boolean -} +/** What `credentials/describe` answers, by reference. */ +type CredentialViews = Readonly> -/** The registration-side face the web-search page's slot entry injects. */ -export interface WebSearchCardFace extends SettingsFormActions { - hooks: { - /** Page snapshot bound by the renderer as useWebSearchCard. */ - webSearchCard: SnapshotStore - } - /** Stage the API switch: true selects the Search and Fetch APIs, false Messages search with local fetch. */ - setSearchApi: (enabled: boolean) => void -} - -/** Settings scopes for the namespaces the page edits. */ -export interface WebSearchCardScopes { - /** The web service's provider selection. */ - web: SettingsFormScope - /** Messages search settings. */ - messages: SettingsFormScope - /** Search and Fetch API settings. */ - api: SettingsFormScope -} - -/** Bridges the three settings scopes and the credentials domain onto the page. */ +/** Bridges the settings scopes, the provider lists, and the credentials domain onto the page. */ export class WebSearchCardController { private readonly selection: SettingsFormModel - private readonly messages: SettingsFormModel private readonly api: SettingsFormModel + private readonly legacy: SettingsFormModel private readonly store: SnapshotStore private readonly unsubscribe: (() => void)[] - private credential: CredentialState = { ref: '', configured: false, writable: true } + private credential: CredentialState private credentialReadQueued = false private credentialReadSequence = 0 + /** Reference the key draft was typed for; read only while that draft is non-empty. */ + private draftRef: string + /** The draft reference the running or last selection save writes under. */ + private savingRef: string + private droppingDraft = false + private providers: ProviderList | undefined + private providersFailed = false + private providerReadSequence = 0 + private selectionServed = false private disposed = false /** * @param scopes - the bound settings scopes for the page's namespaces. * @param ctx - the page plugin's context, whose `remote.credentials` namespace - * answers for the credential the selected section references. + * answers for the credential the legacy key control references, and whose + * `remote.web` namespace lists the registered providers. */ constructor( private readonly scopes: WebSearchCardScopes, private readonly ctx: ClientContext, ) { + this.credential = { ref: this.ref(), configured: false, writable: true } + this.draftRef = this.ref() + this.savingRef = this.draftRef this.selection = new SettingsFormModel(scopes.web, [ settingsTextField('searchProvider'), settingsTextField('fetchProvider'), ], [{ field: API_KEY_FIELD, write: text => this.writeKey(text) }]) - this.messages = new SettingsFormModel(scopes.messages, [ - settingsTextField('baseURL'), settingsNumberField('maxUses'), - ]) this.api = new SettingsFormModel(scopes.api, [ - settingsTextField('baseURL'), { ...settingsNumberField('count'), parse: (text) => { @@ -163,37 +252,138 @@ export class WebSearchCardController { }, }, ]) + this.legacy = new SettingsFormModel(scopes.legacy, [ + settingsTextField('baseURL'), + { ...settingsNumberField('maxUses'), parse: (text) => { + if (text.trim() === '') return { kind: 'clear' } + const value = Number(text) + return Number.isInteger(value) && value >= 1 ? { kind: 'set', value } : undefined + } }, + ]) + this.selectionServed = this.selection.shell().available this.store = createSnapshotStore(this.projection()) - // A staged switch can change which section names the key, without any - // scope publishing; a scope change can rename the reference itself. - const publish = () => { - const ref = this.ref() - if (ref !== this.credential.ref) { - this.resetCredentialReference(ref) - this.queueCredentialRead() - } - this.store.set(this.projection()) - } this.unsubscribe = [ - ...[this.selection, this.messages, this.api].map(form => form.bind(() => form.shell()).subscribe(publish)), - ...[scopes.web, scopes.messages, scopes.api].map(scope => scope.subscribe(() => { this.queueCredentialRead() })), + ...[this.selection, this.api, this.legacy].map(form => form.bind(() => form.shell()).subscribe(() => { this.publish() })), + ...[scopes.web, scopes.api, scopes.legacy].map(scope => scope.subscribe(() => { this.queueCredentialRead() })), + scopes.web.subscribe(() => { + // The provider list comes from the web service; a Host that starts + // serving it registers providers this page has not read. + const served = this.selection.shell().available + if (served && !this.selectionServed) this.refreshProviders() + this.selectionServed = served + }), ] void this.readCredential() + this.refreshProviders() } - /** Whether the Host serves both sections the switch needs. */ - private switchAvailable(): boolean { - return this.selection.shell().available && this.api.shell().available + /** + * Republish after a form or scope change. A staged selection can hide the + * key control without any scope publishing; a scope change can rename the + * reference itself. + */ + private publish(): void { + // Dropping a draft stages an edit, which republishes; publish once after it. + if (this.droppingDraft) return + this.dropStaleKeyDraft() + const ref = this.ref() + if (ref !== this.credential.ref) { + this.resetCredentialReference(ref) + this.queueCredentialRead() + } + this.store.set(this.projection()) } - /** The staged switch position, read from the staged search selection. */ - private searchApi(): boolean { - return this.switchAvailable() && this.selection.field('searchProvider').text === PROVIDERS.searchProvider.api + /** The provider id a selection field stages. */ + private selected(field: keyof WebProviderSelection): string { + return this.selection.field(field).text + } + + /** + * Menu entries for one capability: the registered official providers in + * {@link MENU_ORDER}, then every other registered provider in Host order, + * and the selected id when the Host does not register it. Before the list + * arrives, the menu holds only the selected id. + */ + private options(capability: keyof ProviderList, selected: string): WebProviderOption[] { + const registered = this.providers?.[capability] + if (registered === undefined) return selected === '' ? [] : [{ id: selected, missing: false }] + const first: readonly string[] = MENU_ORDER[capability] + const ordered = [ + ...first.flatMap(id => registered.filter(info => info.id === id)), + ...registered.filter(info => !first.includes(info.id)), + ] + const options = ordered.map(info => ({ + id: info.id, + ...info.title === undefined ? {} : { title: info.title }, + ...info.description === undefined ? {} : { description: info.description }, + missing: false, + })) + if (selected !== '' && !registered.some(info => info.id === selected)) options.push({ id: selected, missing: true }) + return options + } + + /** Whether the Host reports `id` as registered, or has not answered yet. */ + private registered(capability: keyof ProviderList, id: string): boolean { + return this.providers === undefined || this.providers[capability].some(info => info.id === id) + } + + private searchKind(): WebSearchKind { + const id = this.selected('searchProvider') + if (id === '' || !this.registered('search', id)) return 'none' + if (id === PROVIDER_IDS.apiSearch) return this.api.shell().available ? 'deepseek' : 'none' + if (id === PROVIDER_IDS.legacySearch) return this.legacy.shell().available ? 'legacy' : 'none' + return this.providers === undefined ? 'none' : 'plugin' + } + + private fetchKind(): WebFetchKind { + const id = this.selected('fetchProvider') + if (id === '' || !this.registered('fetch', id)) return 'none' + if (id === PROVIDER_IDS.localFetch) return 'local' + if (id === PROVIDER_IDS.apiFetch) return this.api.shell().available ? 'deepseek' : 'none' + return this.providers === undefined ? 'none' : 'plugin' + } + + /** The reference the key control addresses, from the legacy settings or the default. */ + private ref(): string { + return this.scopes.legacy.getSnapshot().value?.apiKeyEnv || DEFAULT_API_KEY_REF + } + + /** Whether the page shows the key control. */ + private keyShown(): boolean { + return this.searchKind() === 'legacy' + } + + /** + * Drop the key draft once the page no longer shows the control or the + * control addresses another reference, so a draft is never written under + * another reference or under one no control shows. + */ + private dropStaleKeyDraft(): void { + if (this.selection.field(API_KEY_FIELD).text === '') return + if (this.keyShown() && this.draftRef === this.ref()) return + this.droppingDraft = true + try { + this.selection.actions().edit(API_KEY_FIELD, '') + } finally { + this.droppingDraft = false + } + } + + /** + * Stage a key draft for the reference the control addresses now. + * @param text - the draft text. + */ + private editKey(text: string): void { + // The page shows no key control, so a draft would belong to no reference. + if (!this.keyShown()) return + this.draftRef = this.ref() + this.selection.actions().edit(API_KEY_FIELD, text) } private projection(): WebSearchCardState { - const primary = this.messages.shell() - const served = [primary, this.selection.shell(), this.api.shell()].filter(item => item.available) + const primary = this.selection.shell() + const served = [primary, this.api.shell(), this.legacy.shell()].filter(item => item.available) const shell: SettingsFormShell = { available: primary.available, writable: served.every(item => item.writable), @@ -202,24 +392,50 @@ export class WebSearchCardController { saving: served.some(item => item.saving), failed: served.some(item => item.failed), } - const searchApi = this.searchApi() + const searchSelected = this.selected('searchProvider') + const fetchSelected = this.selected('fetchProvider') + const base = this.scopes.web.getSnapshot().base as WebProviderSelection | undefined return { ...shell, - apiSwitchAvailable: this.switchAvailable(), - apiSwitchBlocked: shell.invalid || shell.failed, - useSearchApi: searchApi, - baseURL: (searchApi ? this.api : this.messages).field('baseURL'), - maxUses: this.messages.field('maxUses'), + selectionBlocked: shell.invalid || shell.failed, + providersFailed: this.providersFailed, + search: { + selected: searchSelected, + options: this.options('search', searchSelected), + kind: this.searchKind(), + auto: base?.searchProvider === undefined, + }, + fetch: { + selected: fetchSelected, + options: this.options('fetch', fetchSelected), + kind: this.fetchKind(), + auto: base?.fetchProvider === undefined, + }, count: this.api.field('count'), - apiKey: this.selection.field(API_KEY_FIELD), - apiKeyConfigured: this.credential.configured, - apiKeyWritable: this.credential.writable, + messagesBaseURL: this.legacy.field('baseURL'), + maxUses: this.legacy.field('maxUses'), + legacyKey: this.keyShown() ? { ...this.selection.field(API_KEY_FIELD), ...this.credential } : undefined, } } - /** The reference the selected API's section names, or the providers' default. */ - private ref(): string { - return refOf(this.searchApi() ? this.scopes.api.getSnapshot() : this.scopes.messages.getSnapshot()) + /** + * Re-read the registered providers. Only the latest read publishes; a failed + * read keeps the previous list and marks the page's provider list as failed + * until a later read succeeds. + */ + refreshProviders(): void { + void this.readProviders() + } + + private async readProviders(): Promise { + if (this.disposed) return + const sequence = ++this.providerReadSequence + const response = await this.ctx.remote.web.listProviders() + // oxlint-disable-next-line typescript/no-unnecessary-condition -- dispose() can run during the await. + if (this.disposed || sequence !== this.providerReadSequence) return + if (response.ok) this.providers = response.value + this.providersFailed = !response.ok + this.publish() } /** Clear the old reference's answer and invalidate its pending reads. */ @@ -239,13 +455,15 @@ export class WebSearchCardController { } /** - * Ask the credentials domain about the reference the selected section currently names. + * Ask the credentials domain about the reference the key control currently names. * - * Only the latest read for the selected reference publishes its answer. - * A write's readback retains its own result even if another read starts. - * @returns the answer for this read, or undefined after failure, disposal, or a reference change. + * Only the latest read publishes its answer, and only while its reference + * is unchanged. A write's readback retains its own result even if + * another read starts or the reference changes. + * @param written - a reference a save just wrote, asked about in the same read. + * @returns the answer by reference, or undefined after failure or disposal. */ - private async readCredential(): Promise { + private async readCredential(written?: string): Promise { if (this.disposed) return const ref = this.ref() if (ref !== this.credential.ref) { @@ -255,23 +473,26 @@ export class WebSearchCardController { this.store.set(this.projection()) } const sequence = ++this.credentialReadSequence - const response = await this.ctx.remote.credentials.describe([ref]) + const names = [...new Set([ref, ...written === undefined ? [] : [written]])] + const response = await this.ctx.remote.credentials.describe(names) // oxlint-disable-next-line typescript/no-unnecessary-condition -- dispose() can run during the await. - if (this.disposed || !response.ok || ref !== this.ref()) return - const view = response.value[ref] - const next: CredentialState = { - ref, - configured: view?.configured ?? false, - // An unknown reference is treated as writable: the control stays usable - // and the Host is what refuses, rather than the page guessing a refusal. - writable: view?.writable ?? true, + if (this.disposed || !response.ok) return + const views: CredentialViews = response.value + if (sequence !== this.credentialReadSequence) return views + if (ref !== this.ref()) { + // The reference changed outside a publication: drop this answer and read the new one. + this.publish() + return views } - if (sequence === this.credentialReadSequence - && (next.configured !== this.credential.configured || next.writable !== this.credential.writable)) { + const view = views[ref] + // An unknown reference is treated as writable: the control stays usable + // and the Host is what refuses, rather than the page guessing a refusal. + const next: CredentialState = { ref, configured: view?.configured ?? false, writable: view?.writable ?? true } + if (next.configured !== this.credential.configured || next.writable !== this.credential.writable) { this.credential = next - this.store.set(this.projection()) + this.publish() } - return next + return views } /** @@ -288,88 +509,87 @@ export class WebSearchCardController { } /** - * Build the face the page's slot registration injects. `baseURL` edits the - * selected API's endpoint; `maxUses` and `count` edit their own sections. + * Build the face the page's slot registration injects. * @returns the page's snapshot and its form actions. */ inject(): WebSearchCardFace { - const formOf = (field: string): { actions(): SettingsFormActions } => { - if (field === API_KEY_FIELD) return this.selection - if (field === 'maxUses') return this.messages - if (field === 'count') return this.api - return this.searchApi() ? this.api : this.messages - } return { hooks: { webSearchCard: this.store }, - edit: (field, text) => { formOf(field).actions().edit(field, text) }, - resetField: (field) => { formOf(field).actions().resetField(field) }, - setSearchApi: (enabled) => { this.select(enabled) }, + edit: (field, text) => { + if (field === 'legacyApiKey') { this.editKey(text); return } + if (field === 'messagesBaseURL' || field === 'maxUses') { + this.legacy.actions().edit(field === 'messagesBaseURL' ? 'baseURL' : field, text) + return + } + this.api.actions().edit(field, text) + }, + resetField: (field) => { + const form = field === 'messagesBaseURL' || field === 'maxUses' ? this.legacy : this.api + form.actions().resetField(field === 'messagesBaseURL' ? 'baseURL' : field) + }, + selectProvider: (field, id) => { this.select(field, id) }, + retryProviders: () => { this.refreshProviders() }, + resolveText: text => this.ctx.locale.resolveText(text), save: () => { void this.save() }, discard: () => { - for (const form of [this.selection, this.messages, this.api]) form.actions().discard() + for (const form of [this.selection, this.api, this.legacy]) form.actions().discard() }, } } /** - * Stage both provider selections for one switch position. A target equal to - * the composed value clears the override so the deployment default keeps + * Stage one provider selection. Automatic selection and a target equal to + * the composed value clear the override, so the deployment default keeps * applying. - * @param enabled - true for the Search and Fetch APIs. + * @param field - the selection field. + * @param id - the provider id to select, or the empty string for automatic selection. */ - private select(enabled: boolean): void { - const base = this.scopes.web.getSnapshot().base as WebProviderSelection | undefined - const actions = this.selection.actions() - for (const field of ['searchProvider', 'fetchProvider'] as const) { - const target = PROVIDERS[field][enabled ? 'api' : 'messages'] - if (base?.[field] === target) actions.resetField(field) - else actions.edit(field, target) - } + private select(field: keyof WebProviderSelection, id: string): void { + const composed = (this.scopes.web.getSnapshot().base as WebProviderSelection | undefined)?.[field] + if (id === '' || composed === id) this.selection.actions().resetField(field) + else this.selection.actions().edit(field, id) } /** - * Save the provider sections before the selection, so a switch takes effect - * only after the endpoint it uses has landed. A refused section stops the - * selection from saving and keeps every remaining draft for correction. + * Save the API section before the selection, so a selection takes effect + * only after the settings it uses have landed. A refused section stops the + * selection from saving and keeps every remaining draft for correction. The + * key draft's reference is fixed when the selection save plans its writes, + * so a provider change during the save does not redirect a key the save + * already planned. * @returns settlement after every attempted write. */ private async save(): Promise { const state = this.projection() if (!state.available || state.invalid || state.saving) return - for (const form of [this.messages, this.api]) { + for (const form of [this.api, this.legacy]) { if (!form.shell().available || !form.shell().dirty) continue await form.save() if (form.shell().failed) return } - if (this.selection.shell().dirty) await this.selection.save() + if (!this.selection.shell().dirty) return + this.savingRef = this.draftRef + await this.selection.save() } /** - * Write the staged key, then re-read whether the Host now holds one. + * Write the staged key under the reference its draft belonged to when the + * save started, then re-read whether the Host now holds it. * @param value - the staged credential literal. * @returns whether the Host reports a configured credential afterwards. */ private async writeKey(value: string): Promise { + const ref = this.savingRef // Refusals surface through the re-read below: the Host is the only // authority on whether the key now exists. - await this.ctx.remote.credentials.set(this.ref(), value) - return (await this.readCredential())?.configured ?? false + await this.ctx.remote.credentials.set(ref, value) + return (await this.readCredential(ref))?.[ref]?.configured ?? false } /** Release subscriptions and prevent queued reads or late answers from publishing. */ dispose(): void { this.disposed = true for (const off of this.unsubscribe) off() - for (const form of [this.selection, this.messages, this.api]) form.dispose() + for (const form of [this.selection, this.api, this.legacy]) form.dispose() } } - -/** - * The credential reference a section names, or the providers' default. - * @param snapshot - the section's current scope snapshot. - * @returns the reference to address. - */ -function refOf(snapshot: SettingsFormScopeSnapshot<{ apiKeyEnv?: string }>): string { - const declared = snapshot.value?.apiKeyEnv - return declared !== undefined && declared.length > 0 ? declared : DEFAULT_API_KEY_REF -} diff --git a/packages/client/ui-settings-web-search/tests/apply.client.spec.ts b/packages/client/ui-settings-web-search/tests/apply.client.spec.ts index 84efc65063..268e650126 100644 --- a/packages/client/ui-settings-web-search/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-web-search/tests/apply.client.spec.ts @@ -29,12 +29,14 @@ async function bench(served?: string[]) { const describeSettings = vi.fn(() => Promise.resolve(served === undefined ? { ok: false, error: new RemoteError('gateway/internal', 'no provider', {}) } : { ok: true, value: { writable: true, hasDocument: true, namespaces: served.map(ns => view(ns)) } })) + const listProviders = vi.fn(() => Promise.resolve({ ok: true, value: { search: [], fetch: [] } })) const remote = new TestRemote(ctx, { credentials: { describe: describeCredentials, set: vi.fn() }, settings: { describe: describeSettings }, + web: { listProviders }, }) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, remote } + return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, listProviders, remote } } /** The Plugins page's item slot, as its owner declares it. */ @@ -51,11 +53,11 @@ describe('ui-settings-web-search apply', () => { }) it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.credentials', 'configForms']) + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.credentials', 'remote.web', 'configForms']) }) - it('registers the page while the Host serves the namespace, titled in the active locale', async () => { - const { ctx, slots } = await bench(['web-search-deepseek']) + it('registers the page while the Host serves the web namespace, titled in the active locale', async () => { + const { ctx, slots } = await bench(['web']) declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() @@ -63,14 +65,14 @@ describe('ui-settings-web-search apply', () => { await vi.waitFor(() => { expect(slots.entries('plugins.item')).toHaveLength(1) }) const entry = slots.entries('plugins.item')[0]! expect(entry.options).toMatchObject({ id: 'web-search', order: 20 }) - expect(resolveSlotLabel(entry.options.label)).toBe('网页搜索') + expect(resolveSlotLabel(entry.options.label)).toBe('网页搜索与获取') expect(entry.locale).toBe(NS) const face = (entry.inject as () => Pick)() expect(Object.keys(face.hooks)).toEqual(['webSearchCard']) }) - it('registers nothing while the namespace is not served', async () => { - const { ctx, slots, describeSettings } = await bench(['shell']) + it('registers nothing while the web namespace is not served', async () => { + const { ctx, slots, describeSettings } = await bench(['shell', 'web-deepseek-api']) declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() await vi.waitFor(() => { expect(describeSettings).toHaveBeenCalled() }) @@ -78,14 +80,16 @@ describe('ui-settings-web-search apply', () => { expect(slots.entries('plugins.item')).toHaveLength(0) }) - it('re-reads the credential when the Host reports the watched reference changed, and ignores another', async () => { + it('re-reads the credentials when the Host reports a watched reference changed, and ignores another', async () => { const { ctx, slots, describeCredentials, remote } = await bench() declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() }) describeCredentials.mockClear() - remote.emit('credentials/reference-updated', ['SOME_OTHER_KEY']) + for (const ref of ['UNWATCHED_KEY', 'DEEPSEEK_SEARCH_API_KEY', 'DEEPSEEK_FETCH_API_KEY']) { + remote.emit('credentials/reference-updated', [ref]) + } await Promise.resolve() expect(describeCredentials).not.toHaveBeenCalled() @@ -95,8 +99,27 @@ describe('ui-settings-web-search apply', () => { await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) }) }) + it('re-reads the providers when the Host reports a registration change or the connection resets', async () => { + const { ctx, slots, listProviders, remote } = await bench() + declareRoot(slots) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + await vi.waitFor(() => { expect(listProviders).toHaveBeenCalledTimes(1) }) + + remote.emit('web/providers-changed', []) + await vi.waitFor(() => { expect(listProviders).toHaveBeenCalledTimes(2) }) + ctx.emit('connection/reset') + await vi.waitFor(() => { expect(listProviders).toHaveBeenCalledTimes(3) }) + + await fiber.dispose() + remote.emit('web/providers-changed', []) + ctx.emit('connection/reset') + await Promise.resolve() + expect(listProviders).toHaveBeenCalledTimes(3) + }) + it('collapses the page on teardown', async () => { - const { ctx, slots } = await bench(['web-search-deepseek']) + const { ctx, slots } = await bench(['web']) declareRoot(slots) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() diff --git a/packages/client/ui-settings-web-search/tests/card.client.spec.tsx b/packages/client/ui-settings-web-search/tests/card.client.spec.tsx index 0d517266f4..2d4928707f 100644 --- a/packages/client/ui-settings-web-search/tests/card.client.spec.tsx +++ b/packages/client/ui-settings-web-search/tests/card.client.spec.tsx @@ -1,18 +1,19 @@ // @vitest-environment jsdom -/** The web-search page as the Plugins page renders it: its API switch, key control, two fields, and their resets. */ +/** The web search and fetch page as the Plugins page renders it: two provider menus and the selected providers' own settings. */ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsFieldState, SettingsFormShell } from '@deepseek-ai/dsh-client-ui-primitives' import { WebSearchCard, type WebSearchCardProps } from '../src/client/WebSearchCard.tsx' -import type { WebSearchCardState } from '../src/client/web-search-card-controller.ts' +import type { WebProviderOption, WebSearchCardState } from '../src/client/web-search-card-controller.ts' import { en, zh } from '../src/client/locales.ts' afterEach(cleanup) -const t = (key: keyof typeof en) => en[key] +const t = ((key: keyof typeof en, params?: Record) => + en[key].replace(/\{(\w+)\}/g, (match, name: string) => params !== undefined && name in params ? String(params[name]) : match)) as WebSearchCardProps['t'] const settled: SettingsFormShell = { available: true, writable: true, dirty: false, invalid: false, saving: false, failed: false } @@ -20,164 +21,346 @@ function field(text: string, rest: Partial = {}): SettingsFi return { text, overridden: false, invalid: false, ...rest } } -const endpoints = { - messages: field(''), - api: field('https://api.deepseek.com/v1/tools'), +/** Each official provider's registered description; the page shows the active language's entry. */ +const DESCRIPTIONS: Record = { + 'deepseek-search-api': { + en: 'Search API description. Uses the DeepSeek account of the current conversation, or a DeepSeek API key, and may incur API charges.', + zh: 'Search API 说明。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费', + }, + 'http': { en: 'Direct fetch description.', zh: '本机获取说明' }, + 'deepseek-fetch-api': { + en: 'Fetch API description. Uses the DeepSeek account of the current conversation, or a DeepSeek API key, may incur API charges.', + zh: 'Fetch API 说明。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费', + }, +} + +function options(...ids: string[]): WebProviderOption[] { + return ids.map(id => ({ id, ...DESCRIPTIONS[id] === undefined ? {} : { description: DESCRIPTIONS[id] }, missing: false })) +} + +/** Menu entries as the controller orders them. */ +const SEARCH_OPTIONS = options('deepseek-search-api', 'deepseek-official') +const FETCH_OPTIONS = options('http', 'deepseek-fetch-api') + +/** The locale service's resolution, with Chinese active. */ +const resolveText = vi.fn((text: string | { en: string; [locale: string]: string }) => typeof text === 'string' ? text : text.zh ?? text.en) + +/** The shipped selection: the DeepSeek Search API with local fetch. */ +function shipped(): WebSearchCardState { + return { + ...settled, + selectionBlocked: false, + providersFailed: false, + search: { selected: 'deepseek-search-api', options: SEARCH_OPTIONS, kind: 'deepseek', auto: false }, + fetch: { selected: 'http', options: FETCH_OPTIONS, kind: 'local', auto: false }, + count: field('10'), + messagesBaseURL: field(''), + maxUses: field('5'), + legacyKey: undefined, + } +} + +/** Both DeepSeek API providers selected. */ +function apis(): WebSearchCardState { + const base = shipped() + return { ...base, fetch: { ...base.fetch, selected: 'deepseek-fetch-api', kind: 'deepseek' } } +} + +/** Legacy Messages search selected, with its key control. */ +function legacy(key: Partial> = {}): WebSearchCardState { + const base = shipped() + return { + ...base, + search: { ...base.search, selected: 'deepseek-official', kind: 'legacy' }, + legacyKey: { ...field(''), ref: 'DEEPSEEK_API_KEY', configured: false, writable: true, ...key }, + } +} + +/** Search and Fetch providers that take no settings on this page. */ +function plugins(): WebSearchCardState { + const base = shipped() + return { + ...base, + search: { selected: 'exa', kind: 'plugin', auto: false, options: [{ id: 'exa', title: 'Exa', missing: false }] }, + fetch: { selected: '', kind: 'none', auto: false, options: [] }, + } } function cardActions() { - return { edit: vi.fn(), resetField: vi.fn(), setSearchApi: vi.fn(), save: vi.fn(), discard: vi.fn() } + return { + edit: vi.fn(), resetField: vi.fn(), selectProvider: vi.fn(), retryProviders: vi.fn(), save: vi.fn(), discard: vi.fn(), + } } +function renderPage(state: WebSearchCardState = shipped(), view: 'page' | 'summary' = 'page') { + const store = createSnapshotStore(state) + const actions = cardActions() + const props = { ...actions, view, t, resolveText, useWebSearchCard: bindSnapshotSelector(store) } as WebSearchCardProps + render() + return { actions, store } +} + +/** The provider menus, named by their section title followed by the selected provider. */ +const searchMenu = () => screen.getByRole('button', { name: new RegExp(`^${en.searchSection} `) }) +const fetchMenu = () => screen.getByRole('button', { name: new RegExp(`^${en.fetchSection} `) }) +const region = (name: string) => within(screen.getByRole('region', { name })) +/** The text of every paragraph in a section, in order. */ +const paragraphs = (name: string) => [...screen.getByRole('region', { name }).querySelectorAll('p')].map(node => node.textContent) + describe('WebSearchCard', () => { - function renderWebSearch(state: Partial = {}) { - const store = createSnapshotStore({ - ...settled, - apiSwitchAvailable: true, - apiSwitchBlocked: state.invalid === true || state.failed === true, - baseURL: state.useSearchApi === true ? endpoints.api : endpoints.messages, useSearchApi: false, maxUses: field('5'), - count: field('10'), - apiKey: field(''), - apiKeyConfigured: false, - apiKeyWritable: true, - ...state, + it('offers legacy search in both locales and renders its own settings', () => { + const { actions } = renderPage(legacy()) + expect(searchMenu().textContent).toBe('DeepSeek (legacy)') + expect(zh.providerLegacy).toBe('DeepSeek(旧版)') + expect(screen.queryByLabelText(en.count)).toBeNull() + fireEvent.change(screen.getByLabelText(en.credential), { target: { value: 'new-key' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://messages.test/v1' } }) + fireEvent.change(screen.getByLabelText(en.maxUses), { target: { value: '8' } }) + expect(actions.edit.mock.calls).toEqual([ + ['legacyApiKey', 'new-key'], ['messagesBaseURL', 'https://messages.test/v1'], ['maxUses', '8'], + ]) + }) + + it('resets the legacy endpoint and search limit under their own names', () => { + const { actions } = renderPage({ + ...legacy(), messagesBaseURL: field('https://messages.test/v1', { overridden: true }), maxUses: field('8', { overridden: true }), }) - const actions = cardActions() - const props = { ...actions, view: 'page', t, useWebSearchCard: bindSnapshotSelector(store) } as WebSearchCardProps - render() - return actions - } + for (const button of screen.getAllByRole('button', { name: en.reset })) fireEvent.click(button) + expect(actions.resetField.mock.calls).toEqual([['messagesBaseURL'], ['maxUses']]) + }) it('renders its one-liner alone in the summary view', () => { - const store = createSnapshotStore({ - ...settled, baseURL: endpoints.messages, apiSwitchAvailable: true, apiSwitchBlocked: false, useSearchApi: false, - maxUses: field('5'), count: field('10'), apiKey: field(''), apiKeyConfigured: false, apiKeyWritable: true, - }) - const props = { ...cardActions(), view: 'summary', t, useWebSearchCard: bindSnapshotSelector(store) } as WebSearchCardProps - render() - + renderPage(shipped(), 'summary') expect(document.body.textContent).toBe(en.description) - expect(screen.queryByLabelText(en.apiKey)).toBeNull() + expect(screen.queryByLabelText(en.count)).toBeNull() }) - it('reports whether a key is configured without ever showing one', () => { - renderWebSearch({ apiKeyConfigured: true }) - - expect(screen.getByText(en.apiKeySet)).toBeTruthy() - expect(screen.getByLabelText(en.apiKey)).toHaveProperty('type', 'password') + it('titles the page and its two sections by the provider each one picks', () => { + renderPage() + expect(en.title).toBe('Web search and fetch') + expect(zh.title).toBe('网页搜索与获取') + expect([en.searchSection, en.fetchSection]).toEqual(['Web search provider', 'Web fetch provider']) + expect([zh.searchSection, zh.fetchSection]).toEqual(['网页搜索提供方', '网页获取提供方']) + expect(screen.getByRole('heading', { name: en.searchSection })).toBeTruthy() + expect(screen.getByRole('heading', { name: en.fetchSection })).toBeTruthy() + expect(screen.getByRole('region', { name: en.searchSection })).toBeTruthy() + expect(screen.getByRole('region', { name: en.fetchSection })).toBeTruthy() }) - it('keeps the key control usable while the settings document is read-only', () => { - const actions = renderWebSearch({ writable: false }) - - const key = screen.getByLabelText(en.apiKey) - expect(key).toHaveProperty('disabled', false) - expect(screen.getByLabelText(en.baseUrl)).toHaveProperty('disabled', true) - expect(screen.getByRole('switch', { name: en.useSearchApi })).toHaveProperty('disabled', true) - - fireEvent.change(key, { target: { value: 'ds-secret' } }) - - expect(actions.edit).toHaveBeenCalledWith('apiKey', 'ds-secret') + it('names each provider menu by its section title and the selected provider, with no label of its own', () => { + renderPage() + expect(searchMenu().getAttribute('aria-labelledby')).toBe('plugin-config-web-search-section plugin-config-web-search-provider-value') + expect(screen.getByRole('button', { name: `${en.searchSection} ${en.providerDeepSeek}` })).toBe(searchMenu()) + expect(screen.getByRole('button', { name: `${en.fetchSection} ${en.providerLocal}` })).toBe(fetchMenu()) + for (const title of [en.searchSection, en.fetchSection]) expect(screen.getAllByText(title)).toHaveLength(1) + expect(document.querySelectorAll('label[for="plugin-config-web-search-provider"], label[for="plugin-config-web-fetch-provider"]')).toHaveLength(0) }) - it('disables the key control when the reference itself is not writable', () => { - // A key coming from the process environment: the settings document is - // writable, the credential is not. - renderWebSearch({ apiKeyConfigured: true, apiKeyWritable: false }) - - expect(screen.getByLabelText(en.apiKey)).toHaveProperty('disabled', true) - expect(screen.getByLabelText(en.baseUrl)).toHaveProperty('disabled', false) + it('shows the Search API result count in a panel, and gives local fetch no panel', () => { + renderPage() + expect(searchMenu().textContent).toBe(en.providerDeepSeek) + expect(fetchMenu().textContent).toBe(en.providerLocal) + expect(region(en.searchSection).getByText(DESCRIPTIONS['deepseek-search-api']!.zh)).toBeTruthy() + expect(region(en.fetchSection).getByText('本机获取说明')).toBeTruthy() + expect(region(en.searchSection).getByLabelText(en.count)).toHaveProperty('value', '10') + for (const label of [en.credential, en.baseUrl]) expect(screen.queryByLabelText(label)).toBeNull() + expect(region(en.fetchSection).queryByRole('textbox')).toBeNull() }) - it('stages the endpoint, the search budget, and their resets', () => { - const actions = renderWebSearch({ - baseURL: { ...endpoints.messages, ...field('https://search.test/v1', { overridden: true }) }, - maxUses: field('3', { overridden: true }), - }) - - fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://other.test' } }) - fireEvent.change(screen.getByLabelText(en.maxUses), { target: { value: '4' } }) - const resets = screen.getAllByRole('button', { name: en.reset }) - expect(resets).toHaveLength(2) - for (const reset of resets) fireEvent.click(reset) - - expect(actions.edit.mock.calls).toEqual([ - ['baseURL', 'https://other.test'], - ['maxUses', '4'], - ]) - expect(actions.resetField.mock.calls).toEqual([['baseURL'], ['maxUses']]) - }) - it.each([ - [false, en.baseUrl, ''], - [true, en.searchApiBaseUrl, 'https://api.deepseek.com/v1/tools'], - ])('shows the selected API endpoint with the Search API %s and offers no reset before an override', (searchApi, label, value) => { - const actions = renderWebSearch({ useSearchApi: searchApi }) - const input = screen.getByLabelText(label) - expect(input).toHaveProperty('value', value) - expect(screen.queryByText(en.overridden)).toBeNull() - expect(screen.queryByRole('button', { name: en.reset })).toBeNull() - fireEvent.change(input, { target: { value: 'https://custom.test' } }) - expect(actions.edit).toHaveBeenCalledWith('baseURL', 'https://custom.test') + it('shows the DeepSeek Fetch API description under its provider menu', () => { + renderPage(apis()) + expect(fetchMenu().textContent).toBe(en.providerDeepSeekFetch) + expect(region(en.fetchSection).getByText(DESCRIPTIONS['deepseek-fetch-api']!.zh)).toBeTruthy() + expect(screen.getAllByRole('button').filter(button => button.hasAttribute('aria-expanded')).map(button => button.id)) + .toEqual(['plugin-config-web-search-provider', 'plugin-config-web-fetch-provider']) + // The Fetch section holds only its title and provider menu block. + expect(screen.getByRole('region', { name: en.fetchSection }).children).toHaveLength(2) + expect(region(en.fetchSection).queryByRole('textbox')).toBeNull() }) - it('defaults the feature off and stages enabling it', () => { - const actions = renderWebSearch() - const toggle = screen.getByRole('switch', { name: en.useSearchApi }) - expect(toggle.getAttribute('aria-checked')).toBe('false') - expect(screen.getByLabelText(en.maxUses)).toBeTruthy() - expect(screen.getByText(en.baseUrlHint)).toBeTruthy() - expect(screen.queryByText(en.searchApiBaseUrlHint)).toBeNull() - expect(zh.useSearchApi).toBe('启用新版 Web 搜索 API') - fireEvent.click(toggle) - expect(actions.setSearchApi).toHaveBeenCalledWith(true) - expect(actions.save).not.toHaveBeenCalled() + it('edits and resets the Search API result count', () => { + const { actions } = renderPage({ ...apis(), count: field('20', { overridden: true }) }) + const search = region(en.searchSection) + expect(search.getByLabelText(en.count)).toHaveProperty('value', '20') + fireEvent.change(search.getByLabelText(en.count), { target: { value: '30' } }) + fireEvent.click(search.getByRole('button', { name: en.reset })) + expect(actions.edit.mock.calls).toEqual([['count', '30']]) + expect(actions.resetField.mock.calls).toEqual([['count']]) }) - it('links the switch hint to the localized search and fetch guide', () => { - renderWebSearch() + it('shows the DeepSeek search guide after the Search API description, and the Fetch API description', () => { + renderPage(apis()) + expect(fetchMenu().textContent).toBe(en.providerDeepSeekFetch) const guide = screen.getByRole('link', { name: en.searchGuide }) expect(guide).toHaveProperty('href', en.searchGuideUrl) expect(guide.getAttribute('target')).toBe('_blank') expect(guide.getAttribute('rel')).toBe('noopener noreferrer') - expect(guide.parentElement?.textContent).toBe(`${en.useSearchApiHint}${en.searchGuide}`) - expect(`${zh.useSearchApiHint}${zh.searchGuide}`).toBe('默认关闭,搜索通过 DeepSeek Messages 完成。新版 Web 搜索 API 功能与计费详见网页搜索与抓取指南') - expect(zh.searchGuideUrl).toBe('https://api-docs.deepseek.com/zh-cn/guides/search') + // The description comes first; the guide link is the page's own line after it. + expect(paragraphs(en.searchSection).slice(0, 2)).toEqual([DESCRIPTIONS['deepseek-search-api']!.zh, en.searchGuide]) }) - it('omits the switch while the Host serves no Search and Fetch API section', () => { - renderWebSearch({ apiSwitchAvailable: false }) - expect(screen.queryByRole('switch', { name: en.useSearchApi })).toBeNull() - expect(screen.queryByRole('link', { name: en.searchGuide })).toBeNull() - expect(screen.getByLabelText(en.maxUses)).toBeTruthy() + it('shows no settings while neither provider takes them', () => { + renderPage(plugins()) + expect(screen.queryByText(en.credential)).toBeNull() + expect(screen.queryByRole('textbox')).toBeNull() }) - it.each([{ invalid: true }, { failed: true }, { saving: true }])('keeps invalid or unsaved failures visible while the switch is locked: %j', (state) => { - renderWebSearch(state) - expect(screen.getByRole('switch', { name: en.useSearchApi })).toHaveProperty('disabled', true) - expect(screen.getByLabelText(en.maxUses)).toBeTruthy() + it('selects a provider from the menu and leaves the current one alone', () => { + const { actions } = renderPage() + fireEvent.click(searchMenu()) + expect(searchMenu().getAttribute('aria-expanded')).toBe('true') + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([en.providerDeepSeek, en.providerLegacy]) + fireEvent.click(screen.getByRole('menuitem', { name: en.providerDeepSeek })) + expect(screen.queryByRole('menuitem')).toBeNull() + fireEvent.click(fetchMenu()) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([en.providerLocal, en.providerDeepSeekFetch]) + fireEvent.click(screen.getByRole('menuitem', { name: en.providerLocal })) + expect(actions.selectProvider).not.toHaveBeenCalled() + fireEvent.click(fetchMenu()) + fireEvent.click(screen.getByRole('menuitem', { name: en.providerDeepSeekFetch })) + expect(actions.selectProvider).toHaveBeenLastCalledWith('fetchProvider', 'deepseek-fetch-api') + expect(actions.save).not.toHaveBeenCalled() + }) + + it('shows each selected provider’s registered description as plain text, with no action of its own', () => { + renderPage() + expect(region(en.fetchSection).getByText('本机获取说明').tagName).toBe('P') + expect(region(en.fetchSection).queryAllByRole('button').map(button => button.id)).toEqual(['plugin-config-web-fetch-provider']) + expect(resolveText).toHaveBeenCalledWith(DESCRIPTIONS.http) + }) + + it('shows a plugin provider’s description before the pointer to its own settings, and nothing for a provider without one', () => { + const base = plugins() + renderPage({ + ...base, + search: { ...base.search, options: [{ id: 'exa', title: 'Exa', description: { en: 'Exa search.', zh: 'Exa 搜索' }, missing: false }] }, + fetch: { ...base.fetch, selected: 'jina', kind: 'plugin', options: [{ id: 'jina', description: { en: 'Jina.', zh: '' }, missing: false }] }, + }) + expect(paragraphs(en.searchSection)).toEqual(['Exa 搜索', en.pluginProviderHint]) + // An empty translation shows no paragraph. + expect(paragraphs(en.fetchSection)).toEqual([en.pluginProviderHint]) + }) + + it('closes an open menu without selecting on Escape', () => { + const { actions } = renderPage() + fireEvent.click(searchMenu()) + fireEvent.keyDown(screen.getAllByRole('menuitem')[0]!, { key: 'Escape' }) + expect(screen.queryByRole('menuitem')).toBeNull() + expect(actions.selectProvider).not.toHaveBeenCalled() + }) + + it('names plugin providers by title or id, and marks a selection the Host does not register', () => { + const base = shipped() + renderPage({ + ...base, + search: { + selected: 'perplexity', kind: 'none', auto: false, + options: [...SEARCH_OPTIONS, { id: 'exa', title: 'Exa', missing: false }, { id: 'perplexity', missing: true }], + }, + fetch: { + selected: 'jina', kind: 'plugin', auto: false, + options: [...FETCH_OPTIONS, { id: 'jina', missing: false }], + }, + }) + expect(searchMenu().textContent).toBe('perplexity (not loaded)') + expect(zh.providerMissing).toBe('{id}(未加载)') + expect(fetchMenu().textContent).toBe('jina') + expect(screen.getAllByText(en.pluginProviderHint)).toHaveLength(1) + expect(screen.queryByText(en.credential)).toBeNull() + fireEvent.click(searchMenu()) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)) + .toEqual([en.providerDeepSeek, en.providerLegacy, 'Exa', 'perplexity (not loaded)']) + }) + + it('explains a plugin Search provider and an unselected Fetch provider', () => { + renderPage(plugins()) + expect(searchMenu().textContent).toBe('Exa') + expect(fetchMenu().textContent).toBe(en.providerAuto) + expect(screen.getByText(en.pluginProviderHint)).toBeTruthy() + }) + + it('offers automatic selection where the deployment composes no provider', () => { + const base = shipped() + const { actions } = renderPage({ + ...base, + search: { ...base.search, auto: true }, + fetch: { ...base.fetch, selected: '', kind: 'none', auto: true }, + }) + expect(fetchMenu().textContent).toBe(en.providerAuto) + fireEvent.click(searchMenu()) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([en.providerAuto, en.providerDeepSeek, en.providerLegacy]) + fireEvent.click(screen.getByRole('menuitem', { name: en.providerAuto })) + expect(actions.selectProvider).toHaveBeenCalledWith('searchProvider', '') + fireEvent.click(fetchMenu()) + fireEvent.click(screen.getByRole('menuitem', { name: en.providerAuto })) + expect(actions.selectProvider).toHaveBeenCalledTimes(1) + }) + + it('reports a failed provider list read in each section and retries it', () => { + const { actions } = renderPage({ ...shipped(), providersFailed: true }) + for (const name of [en.searchSection, en.fetchSection]) { + expect(region(name).getByRole('status').textContent).toBe(`${en.providersFailed}${en.retry}`) + fireEvent.click(region(name).getByRole('button', { name: en.retry })) + } + expect(zh.providersFailed).toBe('无法读取提供方列表,此菜单可能不完整') + expect(actions.retryProviders).toHaveBeenCalledTimes(2) + expect(searchMenu().textContent).toBe(en.providerDeepSeek) + }) + + it('shows no provider list notice while the list read succeeds', () => { + renderPage() + expect(screen.queryByRole('status')).toBeNull() + expect(screen.queryByRole('button', { name: en.retry })).toBeNull() + }) + + it('keeps the legacy key control usable while the settings document is read-only', () => { + const { actions } = renderPage({ ...legacy(), writable: false }) + const key = screen.getByLabelText(en.credential) + expect(key).toHaveProperty('disabled', false) + expect(screen.getByLabelText(en.baseUrl)).toHaveProperty('disabled', true) + expect(searchMenu()).toHaveProperty('disabled', true) + fireEvent.change(key, { target: { value: 'ds-secret' } }) + expect(actions.edit).toHaveBeenCalledWith('legacyApiKey', 'ds-secret') + }) + + it('disables the legacy key control when the reference itself is not writable', () => { + renderPage(legacy({ configured: true, writable: false })) + expect(screen.getByLabelText(en.credential)).toHaveProperty('disabled', true) + expect(screen.getByText(en.apiKeySet)).toBeTruthy() + expect(searchMenu()).toHaveProperty('disabled', false) + }) + + it('disables the legacy key control while a save is in flight', () => { + const { store } = renderPage(legacy()) + expect(screen.getByLabelText(en.credential)).toHaveProperty('disabled', false) + act(() => { store.set({ ...legacy(), saving: true }) }) + expect(screen.getByLabelText(en.credential)).toHaveProperty('disabled', true) + }) + + it.each([{ invalid: true }, { failed: true }])('locks every menu while an error must stay visible: %j', (state) => { + renderPage({ ...apis(), ...state, selectionBlocked: true }) + expect(searchMenu()).toHaveProperty('disabled', true) + expect(fetchMenu()).toHaveProperty('disabled', true) + fireEvent.click(searchMenu()) + expect(screen.queryByRole('menuitem')).toBeNull() + expect(searchMenu().getAttribute('aria-expanded')).toBe('false') + }) + + it('closes an open menu and reports it collapsed when the menus lock', () => { + const { store } = renderPage() + fireEvent.click(searchMenu()) + expect(screen.getAllByRole('menuitem')).toHaveLength(2) + act(() => { store.set({ ...shipped(), invalid: true, selectionBlocked: true }) }) + expect(screen.queryByRole('menuitem')).toBeNull() + expect(searchMenu().getAttribute('aria-expanded')).toBe('false') }) it('explains the supported result count and blocks saving an invalid draft', () => { - renderWebSearch({ useSearchApi: true, count: field('51', { invalid: true }), invalid: true, dirty: true }) + renderPage({ + ...apis(), count: field('51', { invalid: true }), invalid: true, dirty: true, selectionBlocked: true, + }) expect(screen.getByText(en.invalidCount)).toBeTruthy() expect(screen.getByLabelText(en.count).getAttribute('aria-invalid')).toBe('true') expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) }) - - it('edits the Search API endpoint and result count in Search API mode', () => { - const actions = renderWebSearch({ useSearchApi: true, count: field('20', { overridden: true }) }) - expect(screen.queryByLabelText(en.maxUses)).toBeNull() - expect(screen.getByLabelText(en.count)).toHaveProperty('value', '20') - expect(screen.getByText(en.countHint)).toBeTruthy() - expect(screen.getByText(en.searchApiBaseUrlHint)).toBeTruthy() - expect(screen.queryByText(en.baseUrlHint)).toBeNull() - expect(screen.getByLabelText(en.searchApiBaseUrl)).toHaveProperty('value', 'https://api.deepseek.com/v1/tools') - fireEvent.change(screen.getByLabelText(en.searchApiBaseUrl), { target: { value: 'https://other.test' } }) - fireEvent.change(screen.getByLabelText(en.count), { target: { value: '30' } }) - fireEvent.click(screen.getByRole('button', { name: en.reset })) - fireEvent.click(screen.getByRole('switch', { name: en.useSearchApi })) - expect(actions.edit.mock.calls).toEqual([['baseURL', 'https://other.test'], ['count', '30']]) - expect(actions.resetField.mock.calls).toEqual([['count']]) - expect(actions.setSearchApi).toHaveBeenCalledWith(false) - }) }) diff --git a/packages/client/ui-settings-web-search/tests/controller.client.spec.ts b/packages/client/ui-settings-web-search/tests/controller.client.spec.ts index 3b3c773c02..4b02df5458 100644 --- a/packages/client/ui-settings-web-search/tests/controller.client.spec.ts +++ b/packages/client/ui-settings-web-search/tests/controller.client.spec.ts @@ -3,7 +3,7 @@ import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteError, stubConfigForm, type StubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' import { WebSearchCardController, - type WebApiSettings, type WebProviderSelection, type WebSearchSettings, + type WebApiSettings, type WebLegacySettings, type WebProviderSelection, } from '../src/client/web-search-card-controller.ts' /** Make the stub behave like a Host that accepts every write. */ @@ -33,52 +33,98 @@ function ctxWith(namespaces: object) { return { remote: namespaces } as never } -function credentialsApi(configured: boolean) { - const describe = vi.fn((refs: readonly string[]) => Promise.resolve({ - ok: true as const, - value: Object.fromEntries(refs.map(ref => [ref, { configured, writable: true }])), - })) - const set = vi.fn(() => Promise.resolve({ ok: true as const, value: undefined })) - return { ctx: ctxWith({ credentials: { describe, set } }), describe, set } +/** Providers the shipped composition registers, in registration order. */ +const SHIPPED_PROVIDERS = { + search: [{ id: 'deepseek-search-api' }, { id: 'deepseek-official' }], + fetch: [{ id: 'http' }, { id: 'deepseek-fetch-api' }], } -/** Shipped composition: Messages search with local HTTP fetch. */ -const SHIPPED_SELECTION = { searchProvider: 'deepseek-official', fetchProvider: 'http' } +type ProviderEntry = { id: string; title?: string; description?: { en: string; [locale: string]: string } } +type ProviderList = { search: ProviderEntry[]; fetch: ProviderEntry[] } +type ProviderResponse = { ok: true; value: ProviderList } | { ok: false; error: RemoteError } + +/** The legacy key's default reference. */ +const LEGACY_KEY = 'DEEPSEEK_API_KEY' + +function remoteApi(configured: boolean | Record) { + const answer = (ref: string) => typeof configured === 'boolean' ? configured : configured[ref] ?? false + const describe = vi.fn((refs: readonly string[]) => Promise.resolve({ + ok: true as const, + value: Object.fromEntries(refs.map(ref => [ref, { configured: answer(ref), writable: true }])), + })) + const set = vi.fn(() => Promise.resolve({ ok: true as const, value: undefined })) + const listProviders = vi.fn((): Promise => Promise.resolve({ ok: true, value: SHIPPED_PROVIDERS })) + return { ctx: ctxWith({ credentials: { describe, set }, web: { listProviders } }), describe, set, listProviders } +} + +/** The shipped composition's selection: the DeepSeek Search API and local HTTP fetch. */ +const SHIPPED_SELECTION = { searchProvider: 'deepseek-search-api', fetchProvider: 'http' } + +/** Legacy Messages search with local HTTP fetch, the selection that shows the key control. */ +const LEGACY_SELECTION = { searchProvider: 'deepseek-official', fetchProvider: 'http' } /** * Mount the controller over three stub scopes. - * @param options - whether each Host accepts writes, the credential answer, and the selection's composed layer. + * @param options - whether each Host accepts writes, the credential answer, and a replacement context. * @returns the scopes, the controller, its face, and a state reader. */ -function bench(options: { accept?: boolean; configured?: boolean; ctx?: never } = {}) { +function bench(options: { accept?: boolean; configured?: boolean | Record; ctx?: never } = {}) { const web = stubConfigForm() - const messages = stubConfigForm() const api = stubConfigForm() + const legacy = stubConfigForm() if (options.accept === true) { acceptWrites(web) - acceptWrites(messages) acceptWrites(api) + acceptWrites(legacy) } - const credentials = credentialsApi(options.configured ?? true) - const controller = new WebSearchCardController( - { web: web.scope, messages: messages.scope, api: api.scope }, options.ctx ?? credentials.ctx, - ) + const remote = remoteApi(options.configured ?? true) + const controller = new WebSearchCardController({ web: web.scope, api: api.scope, legacy: legacy.scope }, options.ctx ?? remote.ctx) const face = controller.inject() - return { web, messages, api, credentials, controller, face, state: () => face.hooks.webSearchCard.getSnapshot() } + return { + web, api, legacy, credentials: remote, controller, face, state: () => face.hooks.webSearchCard.getSnapshot(), + } } -/** Serve all three sections, with the shipped selection unless one is given. */ /** Selection layers a scenario overrides. */ type SelectionLayers = { value?: WebProviderSelection; base?: WebProviderSelection; user?: WebProviderSelection } -function serve(b: ReturnType, selection: SelectionLayers = {}): void { +/** The API section the shipped composition resolves: the schema defaults. */ +const API_DEFAULTS: WebApiSettings = { count: 10 } + +/** + * Serve all three sections, with the shipped selection unless one is given, + * the API section composed as `composed`, and the legacy section as `legacy`, + * each with no user layer. + */ +function serve( + b: ReturnType, selection: SelectionLayers = {}, composed: WebApiSettings = API_DEFAULTS, + legacy: WebLegacySettings = { maxUses: 5 }, +): void { b.web.publish({ status: 'ready', writable: true, value: SHIPPED_SELECTION, base: SHIPPED_SELECTION, user: {}, ...selection }) - b.messages.publish({ status: 'ready', writable: true, value: { maxUses: 5 }, base: {}, user: {} }) - b.api.publish({ status: 'ready', writable: true, value: { count: 10 }, base: { count: 10 }, user: {} }) + b.api.publish({ status: 'ready', writable: true, value: composed, base: composed, user: {} }) + b.legacy.publish({ status: 'ready', writable: true, value: legacy, base: legacy, user: {} }) } -describe('WebSearchCardController credential', () => { - it('reads once per settings publication wave after the eager read', async () => { +/** Both DeepSeek API providers selected. */ +const API_SELECTION = { searchProvider: 'deepseek-search-api', fetchProvider: 'deepseek-fetch-api' } + +/** The shipped providers plus the plugin providers `exa` (Search) and `jina` (Fetch). */ +const WITH_PLUGINS = { + search: [...SHIPPED_PROVIDERS.search, { id: 'exa' }], + fetch: [...SHIPPED_PROVIDERS.fetch, { id: 'jina' }], +} + +/** Mount a Host that accepts writes, with legacy search selected and the legacy section carrying `legacy`. */ +async function keyBench(legacy: WebLegacySettings = {}) { + const b = bench({ accept: true }) + serve(b, { value: LEGACY_SELECTION }, API_DEFAULTS, legacy) + await vi.waitFor(() => { expect(b.state().search.options).toHaveLength(2) }) + await Promise.resolve() + return b +} + +describe('WebSearchCardController legacy key', () => { + it('reads the key reference once per settings publication wave after the eager read', async () => { const b = bench() try { expect(b.credentials.describe).toHaveBeenCalledTimes(1) @@ -88,18 +134,17 @@ describe('WebSearchCardController credential', () => { serve(b) await Promise.resolve() expect(b.credentials.describe).toHaveBeenCalledTimes(3) - for (const args of b.credentials.describe.mock.calls) expect(args).toEqual([['DEEPSEEK_API_KEY']]) + for (const args of b.credentials.describe.mock.calls) expect(args).toEqual([[LEGACY_KEY]]) } finally { b.controller.dispose() } }) - it('reads once when a settings publication wave renames the credential reference', async () => { - const b = bench() + it('reads once when a settings publication wave renames the reference', async () => { + const b = await keyBench() try { - serve(b) - await vi.waitFor(() => { expect(b.state().apiKeyConfigured).toBe(true) }) + await vi.waitFor(() => { expect(b.state().legacyKey?.configured).toBe(true) }) const count = b.credentials.describe.mock.calls.length - b.messages.publish({ value: { apiKeyEnv: 'RENAMED_KEY' } }) - expect(b.state().apiKeyConfigured).toBe(false) + b.legacy.publish({ value: { apiKeyEnv: 'RENAMED_KEY' } }) + expect(b.state().legacyKey?.configured).toBe(false) await Promise.resolve() expect(b.credentials.describe).toHaveBeenCalledTimes(count + 1) expect(b.credentials.describe).toHaveBeenLastCalledWith(['RENAMED_KEY']) @@ -113,21 +158,21 @@ describe('WebSearchCardController credential', () => { try { await Promise.resolve() b.credentials.describe.mockImplementationOnce(() => pending.promise) - serve(b) + serve(b, { value: LEGACY_SELECTION }) await Promise.resolve() const count = b.credentials.describe.mock.calls.length b.credentials.describe.mockImplementationOnce(() => updated.promise) - b.controller.refreshCredential('DEEPSEEK_API_KEY') + b.controller.refreshCredential(LEGACY_KEY) expect(b.credentials.describe).toHaveBeenCalledTimes(count + 1) if (order === 'before') { - pending.resolve({ ok: true, value: { DEEPSEEK_API_KEY: { configured: false, writable: true } } }) + pending.resolve({ ok: true, value: { [LEGACY_KEY]: { configured: false, writable: true } } }) await Promise.resolve() } - updated.resolve({ ok: true, value: { DEEPSEEK_API_KEY: { configured: true, writable: true } } }) - await vi.waitFor(() => { expect(b.state().apiKeyConfigured).toBe(true) }) - pending.resolve({ ok: true, value: { DEEPSEEK_API_KEY: { configured: false, writable: true } } }) + updated.resolve({ ok: true, value: { [LEGACY_KEY]: { configured: true, writable: true } } }) + await vi.waitFor(() => { expect(b.state().legacyKey?.configured).toBe(true) }) + pending.resolve({ ok: true, value: { [LEGACY_KEY]: { configured: false, writable: true } } }) await Promise.resolve() - expect(b.state().apiKeyConfigured).toBe(true) + expect(b.state().legacyKey?.configured).toBe(true) } finally { b.controller.dispose() pending.resolve({ ok: true, value: {} }) @@ -136,89 +181,101 @@ describe('WebSearchCardController credential', () => { } }) - it('discards queued reads and late credential answers after disposal', async () => { + it('discards queued reads and late answers after disposal', async () => { const b = bench({ configured: false }) const pending = Promise.withResolvers>>() + const providers = Promise.withResolvers() const publish = vi.fn() const off = b.face.hooks.webSearchCard.subscribe(publish) try { + await Promise.resolve() + serve(b, { value: LEGACY_SELECTION }) await Promise.resolve() b.credentials.describe.mockImplementationOnce(() => pending.promise) - b.controller.refreshCredential('DEEPSEEK_API_KEY') - serve(b) + b.controller.refreshCredential(LEGACY_KEY) + b.credentials.listProviders.mockImplementationOnce(() => providers.promise) + b.controller.refreshProviders() + serve(b, { value: LEGACY_SELECTION }) b.controller.dispose() const count = b.credentials.describe.mock.calls.length + const listed = b.credentials.listProviders.mock.calls.length publish.mockClear() - pending.resolve({ ok: true, value: { DEEPSEEK_API_KEY: { configured: true, writable: true } } }) + pending.resolve({ ok: true, value: { [LEGACY_KEY]: { configured: true, writable: true } } }) + providers.resolve({ ok: true, value: { search: [], fetch: [] } }) + await Promise.resolve() await Promise.resolve() expect(b.credentials.describe).toHaveBeenCalledTimes(count) expect(publish).not.toHaveBeenCalled() - expect(b.state().apiKeyConfigured).toBe(false) - b.controller.refreshCredential('DEEPSEEK_API_KEY') + expect(b.state().legacyKey?.configured).toBe(false) + b.controller.refreshCredential(LEGACY_KEY) + b.controller.refreshProviders() serve(b) await Promise.resolve() expect(b.credentials.describe).toHaveBeenCalledTimes(count) + expect(b.credentials.listProviders).toHaveBeenCalledTimes(listed) } finally { off() b.controller.dispose() pending.resolve({ ok: true, value: {} }) - await pending.promise + providers.resolve({ ok: true, value: { search: [], fetch: [] } }) + await Promise.all([pending.promise, providers.promise]) } }) - it('reads the credential state for the reference the selected section names', async () => { + it('shows the key control only while legacy search is selected', async () => { const b = bench() - await vi.waitFor(() => { expect(b.credentials.describe).toHaveBeenCalled() }) - serve(b) - await vi.waitFor(() => { expect(b.state().apiKeyConfigured).toBe(true) }) - expect(b.state()).toMatchObject({ baseURL: { text: '', overridden: false }, apiKey: { text: '', overridden: false } }) - b.controller.dispose() + try { + serve(b, { value: API_SELECTION }) + await vi.waitFor(() => { expect(b.credentials.describe).toHaveBeenCalledTimes(2) }) + expect(b.state().legacyKey).toBeUndefined() + b.face.selectProvider('searchProvider', 'deepseek-official') + await vi.waitFor(() => { expect(b.state().legacyKey).toMatchObject({ text: '', overridden: false, ref: LEGACY_KEY, configured: true }) }) + } finally { b.controller.dispose() } }) it('keeps an unknown credential reference writable and unconfigured', async () => { - const b = bench() + const b = await keyBench() try { - await vi.waitFor(() => { expect(b.state().apiKeyConfigured).toBe(true) }) + await vi.waitFor(() => { expect(b.state().legacyKey?.configured).toBe(true) }) b.credentials.describe.mockResolvedValue({ ok: true, value: {} }) - b.controller.refreshCredential('DEEPSEEK_API_KEY') - await vi.waitFor(() => { expect(b.state()).toMatchObject({ apiKeyConfigured: false, apiKeyWritable: true }) }) + b.controller.refreshCredential(LEGACY_KEY) + await vi.waitFor(() => { expect(b.state().legacyKey).toMatchObject({ configured: false, writable: true }) }) } finally { b.controller.dispose() } }) it('writes the staged key through the credentials domain, never a settings section', async () => { - const b = bench({ configured: false }) - serve(b) - b.face.edit('apiKey', ' ds-secret ') + const b = bench() + serve(b, { value: LEGACY_SELECTION }) + await vi.waitFor(() => { expect(b.state().legacyKey).toBeDefined() }) + b.face.edit('legacyApiKey', ' ds-secret ') expect(b.state().dirty).toBe(true) expect(b.credentials.set).not.toHaveBeenCalled() - b.credentials.describe.mockImplementation((refs: readonly string[]) => Promise.resolve({ - ok: true as const, value: Object.fromEntries(refs.map(ref => [ref, { configured: true, writable: true }])), - })) b.face.save() - await vi.waitFor(() => { expect(b.credentials.set).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'ds-secret') }) - for (const host of [b.web, b.messages, b.api]) expect(host.mutate).not.toHaveBeenCalled() - await vi.waitFor(() => { expect(b.state()).toMatchObject({ dirty: false, apiKeyConfigured: true }) }) + await vi.waitFor(() => { expect(b.credentials.set).toHaveBeenCalledWith(LEGACY_KEY, 'ds-secret') }) + for (const host of [b.web, b.api, b.legacy]) expect(host.mutate).not.toHaveBeenCalled() + await vi.waitFor(() => { expect(b.state()).toMatchObject({ dirty: false, legacyKey: { configured: true } }) }) b.controller.dispose() }) it('accepts its successful key readback while a newer credential read is pending', async () => { - const b = bench({ configured: false }) + const b = bench() const readback = Promise.withResolvers>>() const newer = Promise.withResolvers>>() try { - serve(b) - await vi.waitFor(() => { expect(b.credentials.describe).toHaveBeenCalledTimes(2) }) + serve(b, { value: LEGACY_SELECTION }) + await vi.waitFor(() => { expect(b.state().legacyKey).toBeDefined() }) + const count = b.credentials.describe.mock.calls.length b.credentials.describe.mockImplementationOnce(() => readback.promise) - b.face.edit('apiKey', 'ds-secret') + b.face.edit('legacyApiKey', 'ds-secret') b.face.save() - await vi.waitFor(() => { expect(b.credentials.describe).toHaveBeenCalledTimes(3) }) + await vi.waitFor(() => { expect(b.credentials.describe).toHaveBeenCalledTimes(count + 1) }) b.credentials.describe.mockImplementationOnce(() => newer.promise) - b.controller.refreshCredential('DEEPSEEK_API_KEY') - expect(b.credentials.describe).toHaveBeenCalledTimes(4) - readback.resolve({ ok: true, value: { DEEPSEEK_API_KEY: { configured: true, writable: true } } }) + b.controller.refreshCredential(LEGACY_KEY) + expect(b.credentials.describe).toHaveBeenCalledTimes(count + 2) + readback.resolve({ ok: true, value: { [LEGACY_KEY]: { configured: true, writable: true } } }) await vi.waitFor(() => { expect(b.state()).toMatchObject({ dirty: false, saving: false, failed: false }) }) - newer.resolve({ ok: true, value: { DEEPSEEK_API_KEY: { configured: true, writable: true } } }) - await vi.waitFor(() => { expect(b.state().apiKeyConfigured).toBe(true) }) + newer.resolve({ ok: true, value: { [LEGACY_KEY]: { configured: true, writable: true } } }) + await vi.waitFor(() => { expect(b.state().legacyKey?.configured).toBe(true) }) } finally { b.controller.dispose() readback.resolve({ ok: true, value: {} }) @@ -227,10 +284,9 @@ describe('WebSearchCardController credential', () => { } }) - it('keeps the stored key when the draft is left blank', () => { - const b = bench() - serve(b) - b.face.edit('apiKey', ' ') + it('keeps the stored key when the draft is left blank', async () => { + const b = await keyBench() + b.face.edit('legacyApiKey', ' ') expect(b.state().dirty).toBe(false) b.face.save() expect(b.credentials.set).not.toHaveBeenCalled() @@ -239,7 +295,7 @@ describe('WebSearchCardController credential', () => { it('re-reads only when the Host reports the watched reference changed', async () => { const b = bench({ configured: false }) - serve(b) + serve(b, { value: LEGACY_SELECTION }) await vi.waitFor(() => { expect(b.credentials.describe).toHaveBeenCalled() }) b.credentials.describe.mockClear() b.controller.refreshCredential('OTHER_KEY') @@ -247,68 +303,63 @@ describe('WebSearchCardController credential', () => { b.credentials.describe.mockImplementation((refs: readonly string[]) => Promise.resolve({ ok: true as const, value: Object.fromEntries(refs.map(ref => [ref, { configured: true, writable: true }])), })) - b.controller.refreshCredential('DEEPSEEK_API_KEY') - await vi.waitFor(() => { expect(b.state().apiKeyConfigured).toBe(true) }) + b.controller.refreshCredential(LEGACY_KEY) + await vi.waitFor(() => { expect(b.state().legacyKey?.configured).toBe(true) }) b.controller.dispose() }) - it('addresses the reference the selected API section declares', async () => { - const b = bench({ accept: true }) - serve(b) - b.messages.publish({ value: { apiKeyEnv: 'MESSAGES_KEY' } }) - b.api.publish({ value: { apiKeyEnv: 'API_KEY', count: 10 } }) - await vi.waitFor(() => { expect(b.credentials.describe).toHaveBeenLastCalledWith(['MESSAGES_KEY']) }) - b.face.setSearchApi(true) - await vi.waitFor(() => { expect(b.credentials.describe).toHaveBeenLastCalledWith(['API_KEY']) }) - b.face.edit('apiKey', 'ds-secret') + it('addresses the reference the legacy section declares', async () => { + const b = await keyBench({ apiKeyEnv: 'TEAM_KEY' }) + await vi.waitFor(() => { expect(b.credentials.describe).toHaveBeenLastCalledWith(['TEAM_KEY']) }) + b.face.edit('legacyApiKey', 'ds-secret') b.face.save() - await vi.waitFor(() => { expect(b.credentials.set).toHaveBeenCalledWith('API_KEY', 'ds-secret') }) + await vi.waitFor(() => { expect(b.credentials.set).toHaveBeenCalledWith('TEAM_KEY', 'ds-secret') }) b.controller.dispose() }) it('reports a key the Host did not store as a failed save', async () => { const b = bench({ configured: false }) - serve(b) - b.face.edit('apiKey', 'ds-secret') + serve(b, { value: LEGACY_SELECTION }) + b.face.edit('legacyApiKey', 'ds-secret') b.face.save() - await vi.waitFor(() => { expect(b.state()).toMatchObject({ failed: true, dirty: true, apiSwitchBlocked: true }) }) + await vi.waitFor(() => { expect(b.state()).toMatchObject({ failed: true, dirty: true, selectionBlocked: true }) }) b.controller.dispose() }) it('keeps the card usable when the credential read and write are refused', async () => { const refusal = () => Promise.resolve({ ok: false as const, - error: new RemoteError('credential/rejected', 'offline', { ref: 'DEEPSEEK_API_KEY' }), + error: new RemoteError('credential/rejected', 'offline', { ref: LEGACY_KEY }), }) const describe = vi.fn(refusal) const set = vi.fn(refusal) - const b = bench({ ctx: ctxWith({ credentials: { describe, set } }) }) + const listProviders = vi.fn(refusal) + const b = bench({ ctx: ctxWith({ credentials: { describe, set }, web: { listProviders } }) }) await vi.waitFor(() => { expect(describe).toHaveBeenCalled() }) - serve(b) - b.face.edit('apiKey', 'ds-secret') + serve(b, { value: LEGACY_SELECTION }) + b.face.edit('legacyApiKey', 'ds-secret') b.face.save() await vi.waitFor(() => { expect(set).toHaveBeenCalled() }) - expect(b.state()).toMatchObject({ available: true, apiKeyConfigured: false, baseURL: { text: '' } }) + expect(b.state()).toMatchObject({ available: true, search: { kind: 'legacy' }, legacyKey: { configured: false } }) b.controller.dispose() }) it('clears a changed reference immediately and drops its previous pending answer', async () => { - const b = bench() + const b = await keyBench() const previous = Promise.withResolvers>>() const renamed = Promise.withResolvers>>() try { - serve(b) - await vi.waitFor(() => { expect(b.state().apiKeyConfigured).toBe(true) }) + await vi.waitFor(() => { expect(b.state().legacyKey?.configured).toBe(true) }) b.credentials.describe.mockImplementationOnce(() => previous.promise) - b.controller.refreshCredential('DEEPSEEK_API_KEY') + b.controller.refreshCredential(LEGACY_KEY) b.credentials.describe.mockImplementation(() => renamed.promise) - b.messages.publish({ value: { apiKeyEnv: 'RENAMED_KEY' } }) - expect(b.state().apiKeyConfigured).toBe(false) - previous.resolve({ ok: true, value: { DEEPSEEK_API_KEY: { configured: true, writable: true } } }) + b.legacy.publish({ value: { apiKeyEnv: 'RENAMED_KEY' } }) + expect(b.state().legacyKey?.configured).toBe(false) + previous.resolve({ ok: true, value: { [LEGACY_KEY]: { configured: true, writable: true } } }) await Promise.resolve() - expect(b.state().apiKeyConfigured).toBe(false) + expect(b.state().legacyKey?.configured).toBe(false) renamed.resolve({ ok: true, value: { RENAMED_KEY: { configured: true, writable: false } } }) - await vi.waitFor(() => { expect(b.state()).toMatchObject({ apiKeyConfigured: true, apiKeyWritable: false }) }) + await vi.waitFor(() => { expect(b.state().legacyKey).toMatchObject({ configured: true, writable: false }) }) } finally { b.controller.dispose() previous.resolve({ ok: true, value: {} }) @@ -316,69 +367,209 @@ describe('WebSearchCardController credential', () => { await Promise.all([previous.promise, renamed.promise]) } }) + + it('drops the answer for a reference renamed while the read was in flight', async () => { + const b = await keyBench() + const pending = Promise.withResolvers>>() + try { + b.credentials.describe.mockImplementationOnce(() => pending.promise) + b.controller.refreshCredential(LEGACY_KEY) + // Renames the reference without a publication, as a scope that changes outside its subscription would. + const snapshot = b.legacy.scope.getSnapshot() + vi.spyOn(b.legacy.scope, 'getSnapshot').mockReturnValue({ ...snapshot, value: { ...snapshot.value, apiKeyEnv: 'RENAMED_KEY' } }) + b.credentials.describe.mockImplementation((refs: readonly string[]) => Promise.resolve({ + ok: true as const, value: Object.fromEntries(refs.map(ref => [ref, { configured: false, writable: true }])), + })) + pending.resolve({ ok: true, value: { [LEGACY_KEY]: { configured: true, writable: false } } }) + await vi.waitFor(() => { expect(b.credentials.describe).toHaveBeenLastCalledWith(['RENAMED_KEY']) }) + await vi.waitFor(() => { expect(b.state().legacyKey).toMatchObject({ ref: 'RENAMED_KEY', configured: false, writable: true }) }) + } finally { + b.controller.dispose() + pending.resolve({ ok: true, value: {} }) + } + }) + + it('clears a reference renamed outside a publication when the next read starts', async () => { + const b = await keyBench() + try { + await vi.waitFor(() => { expect(b.state().legacyKey?.configured).toBe(true) }) + const snapshot = b.legacy.scope.getSnapshot() + vi.spyOn(b.legacy.scope, 'getSnapshot').mockReturnValue({ ...snapshot, value: { ...snapshot.value, apiKeyEnv: 'RENAMED_KEY' } }) + b.credentials.describe.mockImplementation(() => new Promise(() => {})) + b.controller.refreshCredential(LEGACY_KEY) + expect(b.credentials.describe).toHaveBeenLastCalledWith(['RENAMED_KEY']) + expect(b.state().legacyKey?.configured).toBe(false) + } finally { b.controller.dispose() } + }) + + it('reads the reference an already-served legacy section names on its first read', async () => { + const web = stubConfigForm() + const api = stubConfigForm() + const legacy = stubConfigForm() + web.publish({ status: 'ready', writable: true, value: LEGACY_SELECTION, base: LEGACY_SELECTION, user: {} }) + legacy.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SERVED_KEY' }, base: {}, user: {} }) + const remote = remoteApi({ SERVED_KEY: true }) + const controller = new WebSearchCardController({ web: web.scope, api: api.scope, legacy: legacy.scope }, remote.ctx) + try { + expect(remote.describe).toHaveBeenCalledExactlyOnceWith(['SERVED_KEY']) + await vi.waitFor(() => { expect(controller.inject().hooks.webSearchCard.getSnapshot().legacyKey?.configured).toBe(true) }) + } finally { controller.dispose() } + }) +}) + +describe('WebSearchCardController legacy key drafts', () => { + it('drops the draft once legacy search is deselected', async () => { + const b = await keyBench() + try { + b.face.edit('legacyApiKey', 'draft') + b.face.selectProvider('searchProvider', 'deepseek-search-api') + expect(b.state()).toMatchObject({ dirty: false, legacyKey: undefined }) + b.face.selectProvider('searchProvider', 'deepseek-official') + expect(b.state()).toMatchObject({ dirty: false, legacyKey: { text: '' } }) + b.face.save() + await Promise.resolve() + expect(b.credentials.set).not.toHaveBeenCalled() + } finally { b.controller.dispose() } + }) + + it('drops the draft when the Host stops registering legacy search', async () => { + const b = await keyBench() + try { + b.face.edit('legacyApiKey', 'draft') + b.credentials.listProviders.mockResolvedValue({ ok: true, value: { search: [{ id: 'exa' }], fetch: [{ id: 'http' }] } }) + b.controller.refreshProviders() + await vi.waitFor(() => { expect(b.state()).toMatchObject({ search: { kind: 'none' }, legacyKey: undefined }) }) + expect(b.state().dirty).toBe(false) + b.credentials.listProviders.mockResolvedValue({ ok: true, value: WITH_PLUGINS }) + b.controller.refreshProviders() + await vi.waitFor(() => { expect(b.state().legacyKey).toMatchObject({ text: '' }) }) + } finally { b.controller.dispose() } + }) + + it('drops the draft when the legacy section renames its reference', async () => { + const b = await keyBench({ apiKeyEnv: 'TEAM_KEY' }) + try { + b.face.edit('legacyApiKey', 'draft') + b.legacy.publish({ value: { apiKeyEnv: 'RENAMED_KEY' } }) + expect(b.state()).toMatchObject({ dirty: false, legacyKey: { text: '', ref: 'RENAMED_KEY' } }) + b.face.save() + await Promise.resolve() + expect(b.credentials.set).not.toHaveBeenCalled() + } finally { b.controller.dispose() } + }) + + it('ignores a draft for a key control the page does not show', async () => { + const b = bench({ accept: true }) + try { + serve(b, { value: API_SELECTION }) + b.face.edit('legacyApiKey', 'hidden-draft') + expect(b.state()).toMatchObject({ dirty: false, legacyKey: undefined }) + b.face.selectProvider('searchProvider', 'deepseek-official') + expect(b.state().legacyKey).toMatchObject({ text: '' }) + } finally { b.controller.dispose() } + }) + + it('completes a planned key write under its reference when its provider disappears during the save', async () => { + const b = await keyBench({ apiKeyEnv: 'TEAM_KEY' }) + const first = Promise.withResolvers<{ ok: true; value: undefined }>() + try { + b.face.edit('legacyApiKey', 'draft') + b.credentials.set.mockImplementationOnce(() => first.promise) + b.face.save() + await vi.waitFor(() => { expect(b.credentials.set).toHaveBeenCalledTimes(1) }) + b.credentials.listProviders.mockResolvedValue({ ok: true, value: { search: [{ id: 'exa' }], fetch: [{ id: 'http' }] } }) + b.controller.refreshProviders() + await vi.waitFor(() => { expect(b.state()).toMatchObject({ search: { kind: 'none' }, legacyKey: undefined }) }) + first.resolve({ ok: true, value: undefined }) + await vi.waitFor(() => { expect(b.state().saving).toBe(false) }) + expect(b.credentials.set.mock.calls).toEqual([['TEAM_KEY', 'draft']]) + expect(b.credentials.describe).toHaveBeenLastCalledWith(['TEAM_KEY']) + expect(b.state()).toMatchObject({ failed: false, dirty: false }) + } finally { + first.resolve({ ok: true, value: undefined }) + b.controller.dispose() + } + }) +}) + +describe('WebSearchCardController deployment defaults', () => { + it('saves a typed value equal to the deployment default as an override', async () => { + const b = bench({ accept: true }) + try { + const composed = { baseURL: 'https://messages.test/v1', maxUses: 5 } + serve(b, { value: LEGACY_SELECTION }, API_DEFAULTS, composed) + b.api.publish({ value: { count: 20 }, user: { count: 20 } }) + const stored = { baseURL: 'https://custom.test/v1' } + b.legacy.publish({ value: { ...composed, ...stored }, user: stored }) + b.face.edit('messagesBaseURL', ' https://messages.test/v1 ') + b.face.edit('count', '10') + expect(b.state()).toMatchObject({ + dirty: true, count: { text: '10', overridden: true }, messagesBaseURL: { text: ' https://messages.test/v1 ', overridden: true }, + }) + b.face.save() + await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) + expect(b.api.mutate).toHaveBeenCalledExactlyOnceWith([{ op: 'set', path: ['count'], value: 10 }], undefined) + expect(b.legacy.mutate).toHaveBeenCalledExactlyOnceWith([{ op: 'set', path: ['baseURL'], value: 'https://messages.test/v1' }], undefined) + expect(b.legacy.scope.getSnapshot().user).toEqual({ baseURL: 'https://messages.test/v1' }) + expect(b.state()).toMatchObject({ count: { overridden: true }, messagesBaseURL: { overridden: true } }) + } finally { b.controller.dispose() } + }) + + it('writes nothing for a typed value equal to the composed one the user layer does not override', async () => { + const b = bench({ accept: true }) + try { + serve(b) + b.face.edit('count', '11') + expect(b.state().dirty).toBe(true) + b.face.edit('count', '10') + expect(b.state().dirty).toBe(false) + b.face.save() + await Promise.resolve() + expect(b.api.mutate).not.toHaveBeenCalled() + } finally { b.controller.dispose() } + }) + + it('marks a stored override as overridden even when it equals the deployment default', () => { + const b = bench() + try { + serve(b, { value: LEGACY_SELECTION }) + const stored = { baseURL: 'https://messages.test/v1' } + b.legacy.publish({ value: { maxUses: 5, ...stored }, base: { maxUses: 5, ...stored }, user: stored }) + expect(b.state()).toMatchObject({ dirty: false, messagesBaseURL: { text: stored.baseURL, overridden: true } }) + b.face.resetField('messagesBaseURL') + expect(b.state()).toMatchObject({ dirty: true, messagesBaseURL: { overridden: false } }) + } finally { b.controller.dispose() } + }) }) describe('WebSearchCardController sections', () => { - it('saves the Messages endpoint and search budget together', async () => { + it('stages and resets the legacy endpoint and search limit apart from the result count, clearing a blank limit', async () => { const b = bench({ accept: true }) - serve(b) - b.face.edit('baseURL', 'https://other.test') - b.face.edit('maxUses', '3') - b.face.save() - await vi.waitFor(() => { expect(b.messages.mutate).toHaveBeenCalledTimes(1) }) - expect(b.messages.mutate).toHaveBeenCalledWith([ - { op: 'set', path: ['baseURL'], value: 'https://other.test' }, - { op: 'set', path: ['maxUses'], value: 3 }, - ], undefined) - expect(b.web.mutate).not.toHaveBeenCalled() - expect(b.api.mutate).not.toHaveBeenCalled() - b.controller.dispose() - }) - - /** - * Edit the selected section's endpoint, save, reset, and save again. - * @param b - the mounted controller. - * @param host - the selected section's scope. - * @param composed - the composed endpoint the field shows without an override. - */ - async function endpointRoundTrip( - b: ReturnType, host: StubConfigForm, composed: string, - ): Promise { - expect(b.state().baseURL).toEqual({ text: composed, overridden: false, invalid: false }) - b.face.edit('baseURL', 'https://custom.test/tools') - b.face.save() - await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) - expect(host.mutate).toHaveBeenLastCalledWith([{ op: 'set', path: ['baseURL'], value: 'https://custom.test/tools' }], undefined) - expect(b.state().baseURL).toMatchObject({ text: 'https://custom.test/tools', overridden: true }) - b.face.resetField('baseURL') - expect(b.state().baseURL).toMatchObject({ text: composed, overridden: false }) - b.face.save() - await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) - expect(host.mutate).toHaveBeenLastCalledWith([{ op: 'unset', path: ['baseURL'] }], undefined) - expect(b.state().baseURL).toMatchObject({ text: composed, overridden: false }) - } - - it('edits the Messages endpoint while the switch is off', async () => { - const b = bench({ accept: true }) - serve(b) - try { await endpointRoundTrip(b, b.messages, '') } finally { b.controller.dispose() } - expect(b.api.mutate).not.toHaveBeenCalled() - }) - - it('edits the Search / Fetch API endpoint while the switch is on', async () => { - const b = bench({ accept: true }) - serve(b, { value: { searchProvider: 'deepseek-search-api' } }) - const composed = { count: 10, baseURL: 'https://api.deepseek.com/v1/tools' } - b.api.publish({ value: composed, base: composed }) - try { await endpointRoundTrip(b, b.api, composed.baseURL) } finally { b.controller.dispose() } - expect(b.messages.mutate).not.toHaveBeenCalled() + try { + serve(b, { value: LEGACY_SELECTION }) + b.api.publish({ value: { count: 20 }, user: { count: 20 } }) + b.face.resetField('count') + expect(b.state().count).toMatchObject({ text: '10', overridden: false }) + const stored = { baseURL: 'https://messages.test/v1', maxUses: 8 } + b.legacy.publish({ value: stored, user: stored }) + b.face.resetField('maxUses') + expect(b.state().maxUses).toMatchObject({ text: '5', overridden: false }) + b.face.edit('maxUses', ' ') + b.face.resetField('messagesBaseURL') + b.face.save() + await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) + expect(b.legacy.mutate).toHaveBeenCalledExactlyOnceWith([ + { op: 'unset', path: ['maxUses'] }, { op: 'unset', path: ['baseURL'] }, + ], undefined) + expect(b.api.mutate).toHaveBeenCalledExactlyOnceWith([{ op: 'unset', path: ['count'] }], undefined) + } finally { b.controller.dispose() } }) it.each(['0', '51', '1.5', 'many', 'Infinity'])('rejects an unsupported Search API result count: %s', (text) => { const b = bench() serve(b, { value: { searchProvider: 'deepseek-search-api' } }) b.face.edit('count', text) - expect(b.state()).toMatchObject({ invalid: true, apiSwitchBlocked: true, count: { text, invalid: true } }) + expect(b.state()).toMatchObject({ invalid: true, selectionBlocked: true, count: { text, invalid: true } }) b.face.save() expect(b.api.mutate).not.toHaveBeenCalled() b.controller.dispose() @@ -400,61 +591,273 @@ describe('WebSearchCardController sections', () => { }) }) -describe('WebSearchCardController API switch', () => { - it('stages both provider selections, discards them, and clears them back to the shipped defaults', async () => { +describe('WebSearchCardController providers', () => { + it('lists the official providers first in a fixed order and plugin providers after them in Host order', async () => { + const b = bench() + try { + b.credentials.listProviders.mockResolvedValue({ + ok: true, + value: { + search: [{ id: 'exa' }, { id: 'brave' }, { id: 'deepseek-search-api' }], + fetch: [{ id: 'jina' }, { id: 'deepseek-fetch-api' }, { id: 'http' }], + }, + }) + b.controller.refreshProviders() + serve(b, { value: { searchProvider: 'perplexity', fetchProvider: 'http' } }) + await vi.waitFor(() => { expect(b.state().search.options).toHaveLength(4) }) + expect(b.state().search.options).toEqual([ + { id: 'deepseek-search-api', missing: false }, + { id: 'exa', missing: false }, { id: 'brave', missing: false }, { id: 'perplexity', missing: true }, + ]) + expect(b.state().fetch.options.map(option => option.id)).toEqual(['http', 'deepseek-fetch-api', 'jina']) + } finally { b.controller.dispose() } + }) + + it('omits an official provider the Host does not register from the fixed order', async () => { + const b = bench() + try { + b.credentials.listProviders.mockResolvedValue({ ok: true, value: { search: [{ id: 'exa' }], fetch: [{ id: 'http' }] } }) + b.controller.refreshProviders() + serve(b, { value: { searchProvider: 'exa', fetchProvider: 'http' } }) + await vi.waitFor(() => { expect(b.state().search.options.map(option => option.id)).toEqual(['exa']) }) + expect(b.state().fetch.options.map(option => option.id)).toEqual(['http']) + } finally { b.controller.dispose() } + }) + + it('resolves a provider description through the locale service', () => { + const { describe, set, listProviders } = remoteApi(false) + const resolveText = vi.fn(() => 'resolved') + const b = bench({ ctx: { remote: { credentials: { describe, set }, web: { listProviders } }, locale: { resolveText } } as never }) + try { + const description = { en: 'English.', zh: '中文' } + expect(b.face.resolveText(description)).toBe('resolved') + expect(resolveText).toHaveBeenCalledExactlyOnceWith(description) + } finally { b.controller.dispose() } + }) + + it('offers plugin providers and shows a plugin selection with no settings of its own', async () => { + const b = bench() + try { + b.credentials.listProviders.mockResolvedValue({ + ok: true, value: { search: [...SHIPPED_PROVIDERS.search, { id: 'exa', title: 'Exa', description: { en: 'Exa search.', zh: 'Exa 搜索' } }], fetch: SHIPPED_PROVIDERS.fetch }, + }) + b.controller.refreshProviders() + serve(b, { value: { searchProvider: 'exa', fetchProvider: 'http' } }) + await vi.waitFor(() => { expect(b.state().search.options).toHaveLength(3) }) + expect(b.state().search).toMatchObject({ + selected: 'exa', kind: 'plugin', + options: [ + { id: 'deepseek-search-api', missing: false }, + { id: 'deepseek-official', missing: false }, + { id: 'exa', title: 'Exa', description: { en: 'Exa search.', zh: 'Exa 搜索' }, missing: false }, + ], + }) + expect(b.state().fetch).toMatchObject({ selected: 'http', kind: 'local' }) + b.credentials.listProviders.mockResolvedValue({ + ok: true, value: { search: SHIPPED_PROVIDERS.search, fetch: [...SHIPPED_PROVIDERS.fetch, { id: 'jina' }] }, + }) + b.controller.refreshProviders() + b.face.selectProvider('fetchProvider', 'jina') + await vi.waitFor(() => { expect(b.state().fetch).toMatchObject({ selected: 'jina', kind: 'plugin' }) }) + } finally { b.controller.dispose() } + }) + + it('adds a selected provider the Host does not register without changing the selection', async () => { + const b = bench() + try { + serve(b, { value: { searchProvider: 'perplexity', fetchProvider: 'jina' } }) + await vi.waitFor(() => { expect(b.credentials.listProviders).toHaveBeenCalled() }) + await vi.waitFor(() => { expect(b.state().search.options).toHaveLength(3) }) + expect(b.state().search).toMatchObject({ selected: 'perplexity', kind: 'none', options: [{}, {}, { id: 'perplexity', missing: true }] }) + expect(b.state().fetch).toMatchObject({ selected: 'jina', kind: 'none', options: [{}, {}, { id: 'jina', missing: true }] }) + expect(b.state().dirty).toBe(false) + } finally { b.controller.dispose() } + }) + + it('treats an official provider the Host does not register as not loaded', async () => { + const b = bench() + try { + b.credentials.listProviders.mockResolvedValue({ ok: true, value: { search: [{ id: 'exa' }], fetch: [] } }) + b.controller.refreshProviders() + serve(b, { value: API_SELECTION }) + await vi.waitFor(() => { expect(b.state().fetch.options).toEqual([{ id: 'deepseek-fetch-api', missing: true }]) }) + expect(b.state()).toMatchObject({ search: { kind: 'none' }, fetch: { kind: 'none' } }) + } finally { b.controller.dispose() } + }) + + it('keeps the last list when a provider read fails', async () => { + const b = bench() + try { + b.credentials.listProviders.mockResolvedValue({ ok: true, value: WITH_PLUGINS }) + serve(b, { value: { searchProvider: 'exa' } }) + await vi.waitFor(() => { expect(b.state().search.options).toHaveLength(3) }) + b.credentials.listProviders.mockResolvedValueOnce({ ok: false, error: new RemoteError('gateway/internal', 'down', {}) }) + b.controller.refreshProviders() + await vi.waitFor(() => { expect(b.credentials.listProviders).toHaveBeenCalledTimes(3) }) + await vi.waitFor(() => { expect(b.state().providersFailed).toBe(true) }) + expect(b.state().search.options.map(option => option.id)).toEqual(['deepseek-search-api', 'deepseek-official', 'exa']) + b.face.retryProviders() + await vi.waitFor(() => { expect(b.state().providersFailed).toBe(false) }) + expect(b.credentials.listProviders).toHaveBeenCalledTimes(4) + } finally { b.controller.dispose() } + }) + + it('reports a failed first read without claiming the menu is complete, and recovers on retry', async () => { + const b = bench() + try { + b.credentials.listProviders.mockResolvedValue({ ok: false, error: new RemoteError('gateway/internal', 'down', {}) }) + b.controller.refreshProviders() + serve(b, { value: { searchProvider: 'exa', fetchProvider: 'http' } }) + await vi.waitFor(() => { expect(b.state().providersFailed).toBe(true) }) + expect(b.state().search).toMatchObject({ options: [{ id: 'exa', missing: false }], kind: 'none' }) + expect(b.state().fetch).toMatchObject({ options: [{ id: 'http', missing: false }], kind: 'local' }) + b.credentials.listProviders.mockResolvedValue({ ok: true, value: WITH_PLUGINS }) + b.face.retryProviders() + await vi.waitFor(() => { expect(b.state()).toMatchObject({ providersFailed: false, search: { kind: 'plugin' } }) }) + expect(b.state().search.options).toHaveLength(3) + } finally { b.controller.dispose() } + }) + + it('ignores a failure answered for a superseded read', async () => { + const stale = Promise.withResolvers() + const b = bench() + try { + serve(b) + b.credentials.listProviders.mockImplementationOnce(() => stale.promise) + b.controller.refreshProviders() + b.face.retryProviders() + await vi.waitFor(() => { expect(b.state().fetch.options).toHaveLength(2) }) + stale.resolve({ ok: false, error: new RemoteError('gateway/internal', 'down', {}) }) + await Promise.resolve() + expect(b.state().providersFailed).toBe(false) + } finally { b.controller.dispose() } + }) + + it('renders an unread list as the selected id alone', () => { + const listProviders = vi.fn(() => new Promise(() => {})) + const describe = vi.fn(() => new Promise(() => {})) + const b = bench({ ctx: ctxWith({ credentials: { describe, set: vi.fn() }, web: { listProviders } }) }) + try { + serve(b, { value: { searchProvider: 'exa', fetchProvider: 'http' } }) + expect(b.state().search).toMatchObject({ options: [{ id: 'exa', missing: false }], kind: 'none' }) + expect(b.state().fetch).toMatchObject({ options: [{ id: 'http', missing: false }], kind: 'local' }) + b.web.publish({ value: { fetchProvider: 'jina' } }) + expect(b.state().search).toMatchObject({ selected: '', options: [], kind: 'none' }) + expect(b.state().fetch).toMatchObject({ options: [{ id: 'jina', missing: false }], kind: 'none' }) + } finally { b.controller.dispose() } + }) + + it('publishes only the latest provider read', async () => { + const stale = Promise.withResolvers() + const b = bench() + try { + serve(b) + b.credentials.listProviders.mockImplementationOnce(() => stale.promise) + b.controller.refreshProviders() + b.credentials.listProviders.mockResolvedValueOnce({ ok: true, value: { search: [{ id: 'exa' }], fetch: [] } }) + b.controller.refreshProviders() + await vi.waitFor(() => { expect(b.state().search.options.map(option => option.id)).toEqual(['exa', 'deepseek-search-api']) }) + stale.resolve({ ok: true, value: SHIPPED_PROVIDERS }) + await Promise.resolve() + expect(b.state().search.options.map(option => option.id)).toEqual(['exa', 'deepseek-search-api']) + } finally { b.controller.dispose() } + }) + + it('re-reads the providers when the Host starts serving the selection', async () => { + const b = bench() + try { + await Promise.resolve() + expect(b.credentials.listProviders).toHaveBeenCalledTimes(1) + serve(b) + expect(b.credentials.listProviders).toHaveBeenCalledTimes(2) + serve(b) + expect(b.credentials.listProviders).toHaveBeenCalledTimes(2) + b.web.publish({ status: 'unavailable' }) + b.web.publish({ status: 'ready' }) + expect(b.credentials.listProviders).toHaveBeenCalledTimes(3) + } finally { b.controller.dispose() } + }) +}) + +describe('WebSearchCardController provider selection', () => { + it('keeps legacy settings and key writes separate from Search API settings and clears back to the API default', async () => { const b = bench({ accept: true }) - serve(b) - b.messages.publish({ value: { baseURL: 'https://messages.test/v1', maxUses: 5 }, user: { baseURL: 'https://messages.test/v1' } }) - const state = b.state - expect(state()).toMatchObject({ apiSwitchAvailable: true, useSearchApi: false }) - b.face.setSearchApi(true) - expect(state()).toMatchObject({ useSearchApi: true, dirty: true, baseURL: { text: '' } }) - expect(b.web.mutate).not.toHaveBeenCalled() - b.face.discard() - expect(state().useSearchApi).toBe(false) - b.face.setSearchApi(true) + try { + serve(b) + await vi.waitFor(() => { expect(b.state().search.options.map(option => option.id)).toEqual(['deepseek-search-api', 'deepseek-official']) }) + b.face.selectProvider('searchProvider', 'deepseek-official') + expect(b.state().search).toMatchObject({ kind: 'legacy' }) + b.face.edit('messagesBaseURL', 'https://messages.test/anthropic/v1') + b.face.edit('maxUses', '8') + b.face.edit('legacyApiKey', 'legacy-secret') + b.face.save() + await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) + expect(b.legacy.mutate).toHaveBeenCalledWith([ + { op: 'set', path: ['baseURL'], value: 'https://messages.test/anthropic/v1' }, + { op: 'set', path: ['maxUses'], value: 8 }, + ], undefined) + expect(b.credentials.set).toHaveBeenCalledExactlyOnceWith(LEGACY_KEY, 'legacy-secret') + expect(b.api.mutate).not.toHaveBeenCalled() + b.face.selectProvider('searchProvider', 'deepseek-search-api') + b.face.save() + await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) + expect(b.web.mutate).toHaveBeenLastCalledWith([{ op: 'unset', path: ['searchProvider'] }], undefined) + expect(b.state().legacyKey).toBeUndefined() + b.face.selectProvider('searchProvider', 'deepseek-official') + expect(b.state().messagesBaseURL.text).toBe('https://messages.test/anthropic/v1') + b.face.edit('legacyApiKey', 'must-not-save') + b.face.selectProvider('searchProvider', 'deepseek-search-api') + b.face.selectProvider('searchProvider', 'deepseek-official') + expect(b.state().legacyKey?.text).toBe('') + b.face.edit('maxUses', '1.5') + expect(b.state().selectionBlocked).toBe(true) + } finally { b.controller.dispose() } + }) + + it('selects Search and Fetch independently and clears a selection back to the composed default', async () => { + const b = bench({ accept: true }) + try { + b.credentials.listProviders.mockResolvedValue({ ok: true, value: WITH_PLUGINS }) + serve(b) + await vi.waitFor(() => { expect(b.state().search.options).toHaveLength(3) }) + expect(b.state()).toMatchObject({ search: { selected: 'deepseek-search-api', kind: 'deepseek' }, fetch: { selected: 'http', kind: 'local' } }) + b.face.selectProvider('searchProvider', 'exa') + expect(b.state()).toMatchObject({ dirty: true, search: { selected: 'exa', kind: 'plugin' }, fetch: { selected: 'http' } }) + expect(b.web.mutate).not.toHaveBeenCalled() + b.face.discard() + expect(b.state().search.selected).toBe('deepseek-search-api') + b.face.selectProvider('searchProvider', 'exa') + b.face.save() + await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) + expect(b.web.mutate).toHaveBeenLastCalledWith([{ op: 'set', path: ['searchProvider'], value: 'exa' }], undefined) + b.face.selectProvider('fetchProvider', 'deepseek-fetch-api') + b.face.save() + await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) + expect(b.web.mutate).toHaveBeenLastCalledWith([{ op: 'set', path: ['fetchProvider'], value: 'deepseek-fetch-api' }], undefined) + b.face.selectProvider('searchProvider', 'deepseek-search-api') + b.face.selectProvider('fetchProvider', 'http') + b.face.save() + await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) + expect(b.web.mutate).toHaveBeenLastCalledWith([ + { op: 'unset', path: ['searchProvider'] }, + { op: 'unset', path: ['fetchProvider'] }, + ], undefined) + expect(b.api.mutate).not.toHaveBeenCalled() + } finally { b.controller.dispose() } + }) + + it('writes explicit selections when the deployment composes the APIs', async () => { + const b = bench({ accept: true }) + serve(b, { value: API_SELECTION, base: API_SELECTION }) + expect(b.state()).toMatchObject({ search: { kind: 'deepseek' }, fetch: { kind: 'deepseek' } }) + b.face.selectProvider('fetchProvider', 'http') b.face.save() - await vi.waitFor(() => { expect(state().dirty).toBe(false) }) - expect(b.web.mutate).toHaveBeenLastCalledWith([ - { op: 'set', path: ['searchProvider'], value: 'deepseek-search-api' }, - { op: 'set', path: ['fetchProvider'], value: 'deepseek-fetch-api' }, - ], undefined) - expect(state().useSearchApi).toBe(true) - b.face.setSearchApi(false) - b.face.save() - await vi.waitFor(() => { expect(state().dirty).toBe(false) }) - expect(b.web.mutate).toHaveBeenLastCalledWith([ - { op: 'unset', path: ['searchProvider'] }, - { op: 'unset', path: ['fetchProvider'] }, - ], undefined) - expect(state()).toMatchObject({ useSearchApi: false, baseURL: { text: 'https://messages.test/v1' } }) - expect(b.messages.mutate).not.toHaveBeenCalled() + await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) + expect(b.web.mutate).toHaveBeenLastCalledWith([{ op: 'set', path: ['fetchProvider'], value: 'http' }], undefined) b.controller.dispose() }) - it('writes explicit Messages selections when the deployment composes the APIs', async () => { - const api = { searchProvider: 'deepseek-search-api', fetchProvider: 'deepseek-fetch-api' } - const b = bench({ accept: true }) - serve(b, { value: api, base: api }) - expect(b.state().useSearchApi).toBe(true) - b.face.setSearchApi(false) - b.face.save() - await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) - expect(b.web.mutate).toHaveBeenLastCalledWith([ - { op: 'set', path: ['searchProvider'], value: 'deepseek-official' }, - { op: 'set', path: ['fetchProvider'], value: 'http' }, - ], undefined) - b.face.setSearchApi(true) - b.face.save() - await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) - expect(b.web.mutate).toHaveBeenLastCalledWith([ - { op: 'unset', path: ['searchProvider'] }, - { op: 'unset', path: ['fetchProvider'] }, - ], undefined) - b.controller.dispose() - }) - - it('saves the selected API section before the selection that starts using it', async () => { + it('saves the API section before the selection that starts using it', async () => { const b = bench({ accept: true }) serve(b) const order: string[] = [] @@ -462,62 +865,74 @@ describe('WebSearchCardController API switch', () => { const accept = host.mutate.getMockImplementation()! host.mutate.mockImplementation((ops, revision) => { order.push(name); return accept(ops, revision) }) } - b.face.setSearchApi(true) - b.face.edit('baseURL', 'https://search-api.test/v1/tools') + b.face.selectProvider('fetchProvider', 'deepseek-fetch-api') + b.face.edit('count', '20') b.face.save() await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) expect(order).toEqual(['api', 'web']) b.controller.dispose() }) - it('keeps the selection staged when a provider section refuses its save', async () => { + it('keeps the selection staged when the API section refuses its save', async () => { const b = bench({ accept: true }) serve(b) b.api.mutate.mockImplementation(() => Promise.resolve(false)) - b.face.setSearchApi(true) + b.face.selectProvider('fetchProvider', 'deepseek-fetch-api') b.face.edit('count', '20') b.face.save() await vi.waitFor(() => { expect(b.state().failed).toBe(true) }) expect(b.web.mutate).not.toHaveBeenCalled() - expect(b.state()).toMatchObject({ useSearchApi: true, dirty: true, apiSwitchBlocked: true }) + expect(b.state()).toMatchObject({ fetch: { selected: 'deepseek-fetch-api' }, dirty: true, selectionBlocked: true }) b.controller.dispose() }) - it('locks the API switch while an invalid edit is staged', () => { + it('blocks provider changes while an invalid edit is staged', () => { const b = bench() serve(b) - expect(b.state().apiSwitchBlocked).toBe(false) - b.face.edit('maxUses', 'many') - expect(b.state()).toMatchObject({ invalid: true, apiSwitchBlocked: true }) + expect(b.state().selectionBlocked).toBe(false) + b.face.edit('count', 'many') + expect(b.state()).toMatchObject({ invalid: true, selectionBlocked: true }) b.face.save() - expect(b.messages.mutate).not.toHaveBeenCalled() + expect(b.api.mutate).not.toHaveBeenCalled() b.face.discard() - expect(b.state().apiSwitchBlocked).toBe(false) + expect(b.state().selectionBlocked).toBe(false) b.controller.dispose() }) - it.each(['web', 'api'] as const)('hides the switch and shows Messages settings while %s is not served', (missing) => { + it('hides provider controls while the API section is not served', () => { const b = bench() - serve(b, { value: { searchProvider: 'deepseek-search-api' } }) - b[missing].publish({ status: 'unavailable' }) - expect(b.state()).toMatchObject({ available: true, apiSwitchAvailable: false, useSearchApi: false }) + serve(b, { value: API_SELECTION }) + b.api.publish({ status: 'unavailable' }) + expect(b.state()).toMatchObject({ available: true, search: { kind: 'none' }, fetch: { kind: 'none' } }) b.controller.dispose() }) - it('saves nothing before the Messages section is served or while a save runs', async () => { + it('saves nothing before the selection is served or while a save runs', async () => { const b = bench({ accept: true }) b.face.save() expect(b.state().available).toBe(false) serve(b) let release: (accepted: boolean) => void = () => {} - b.messages.mutate.mockImplementation(() => new Promise((resolve) => { release = resolve })) - b.face.edit('maxUses', '3') + b.api.mutate.mockImplementation(() => new Promise((resolve) => { release = resolve })) + b.face.edit('count', '3') b.face.save() await vi.waitFor(() => { expect(b.state().saving).toBe(true) }) b.face.save() release(true) await vi.waitFor(() => { expect(b.state().saving).toBe(false) }) - expect(b.messages.mutate).toHaveBeenCalledTimes(1) + expect(b.api.mutate).toHaveBeenCalledTimes(1) + b.controller.dispose() + }) + + it('skips the API section while the Host does not serve it', async () => { + const b = bench({ accept: true }) + serve(b) + b.face.edit('count', '3') + b.api.publish({ status: 'unavailable' }) + b.face.selectProvider('fetchProvider', 'deepseek-fetch-api') + b.face.save() + await vi.waitFor(() => { expect(b.web.mutate).toHaveBeenCalled() }) + expect(b.api.mutate).not.toHaveBeenCalled() b.controller.dispose() }) @@ -525,6 +940,30 @@ describe('WebSearchCardController API switch', () => { const b = bench() serve(b) b.controller.dispose() - for (const host of [b.web, b.messages, b.api]) expect(host.listenerCount()).toBe(0) + for (const host of [b.web, b.api]) expect(host.listenerCount()).toBe(0) + }) +}) + +describe('WebSearchCardController automatic selection', () => { + it('offers automatic selection only where the deployment composes no provider, and returns to it', async () => { + const b = bench({ accept: true }) + try { + serve(b, { value: { fetchProvider: 'http' }, base: { fetchProvider: 'http' } }) + expect(b.state()).toMatchObject({ search: { selected: '', auto: true }, fetch: { selected: 'http', auto: false } }) + b.face.selectProvider('searchProvider', 'deepseek-search-api') + b.face.selectProvider('searchProvider', '') + expect(b.state()).toMatchObject({ dirty: false, search: { selected: '' } }) + b.face.selectProvider('searchProvider', 'deepseek-search-api') + b.face.save() + await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) + expect(b.web.mutate).toHaveBeenLastCalledWith([{ op: 'set', path: ['searchProvider'], value: 'deepseek-search-api' }], undefined) + expect(b.state().search).toMatchObject({ selected: 'deepseek-search-api', auto: true }) + b.face.selectProvider('searchProvider', '') + expect(b.state()).toMatchObject({ dirty: true, search: { selected: '', kind: 'none' } }) + b.face.save() + await vi.waitFor(() => { expect(b.state().dirty).toBe(false) }) + expect(b.web.mutate).toHaveBeenLastCalledWith([{ op: 'unset', path: ['searchProvider'] }], undefined) + expect(b.state().search).toMatchObject({ selected: '', auto: true }) + } finally { b.controller.dispose() } }) }) diff --git a/packages/client/ui-settings-web-search/tests/styles.client.spec.ts b/packages/client/ui-settings-web-search/tests/styles.client.spec.ts new file mode 100644 index 0000000000..0b86d8f4ef --- /dev/null +++ b/packages/client/ui-settings-web-search/tests/styles.client.spec.ts @@ -0,0 +1,36 @@ +/** The page stylesheet reads only theme tokens ui-theme defines, so no value silently falls back. */ + +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const read = (path: string) => readFileSync(new URL(path, import.meta.url), 'utf8') +const page = read('../src/client/WebSearchCard.module.css') +/** The global sheets that define `--dsw-*` tokens: the design palette and the base scale. */ +const theme = ['design-platform.css', 'base.css'].map(name => read(`../../ui-theme/src/styles/${name}`)).join('\n') + +describe('WebSearchCard stylesheet', () => { + it('references only defined theme tokens', () => { + const used = [...new Set([...page.matchAll(/var\((--dsw-[\w-]+)/g)].map(match => match[1]!))] + expect(used.length).toBeGreaterThan(0) + expect(used.filter(token => !theme.includes(`${token}:`))).toEqual([]) + }) + + it('separates the sections by spacing, not hairlines', () => { + expect(page.match(/\.section \+ \.section \{[^}]*\}/)?.[0]).not.toContain('border') + }) + + it('separates the provider menu block from the panel below it by spacing, not a hairline', () => { + const field = page.match(/\.field \{[^}]*\}/)?.[0] + expect(field).toContain('padding: 8px 0 12px;') + expect(field).not.toContain('border') + expect(page).not.toMatch(/\.field\s*\+/) + }) + + it('sets the settings below the provider menu on a gray panel', () => { + expect(page).toMatch(/\.panel \{[^}]*background: var\(--dsw-alias-bg-module-platform\);/) + }) + + it('colors the provider list notice with the error state token', () => { + expect(page).toMatch(/\.notice \{[^}]*color: var\(--dsw-alias-state-error-primary\);/) + }) +}) diff --git a/packages/client/ui-settings-web-search/tsconfig.json b/packages/client/ui-settings-web-search/tsconfig.json index fc1dab7fb9..24c99184f0 100644 --- a/packages/client/ui-settings-web-search/tsconfig.json +++ b/packages/client/ui-settings-web-search/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../ui-slots" + }, + { + "path": "../../web/web" } ] } diff --git a/packages/experimental/liveshare/src/endpoint-classification.ts b/packages/experimental/liveshare/src/endpoint-classification.ts index 3c2fb1bfbd..3129efa2ae 100644 --- a/packages/experimental/liveshare/src/endpoint-classification.ts +++ b/packages/experimental/liveshare/src/endpoint-classification.ts @@ -211,6 +211,7 @@ export const VIEWER_ENDPOINT_CLASSES = { 'userQuestions/answer': 'owner-only', // owner answers a pending question 'userQuestions/attachWait': 'owner-only', // owner answers a pending question 'llm/discoverModels': 'owner-only', // owner discovers models + 'web/listProviders': 'owner-only', // owner web search and fetch settings 'agentPresets/list': 'owner-only', // owner agent presets 'agentPresets/read': 'owner-only', // owner agent presets 'agentPresets/select': 'owner-only', // owner agent presets diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 48ffeeb70f..b1c1ebf8ce 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4233,20 +4233,26 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'web', summary: 'The web access service.', - description: 'The web access service. Registered as `ctx.web` (one instance per context).\n\nSelection semantics (resolved at execution time, never order-dependent):\n\n- A configured id that is registered and `available()` → that provider.\n- A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`.\n- A configured id registered but unavailable → `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`.\n- No id configured, exactly one registered usable provider → that provider.\n- No id configured, multiple usable providers → `WEB_PROVIDER_AMBIGUOUS`.\n- No id configured, no usable provider → `WEB_PROVIDER_UNAVAILABLE`.', + description: 'The web access service. Registered as `ctx.web` (one instance per context) and exposed to Clients as the `web` Remote namespace (`listProviders`).\n\nSelection semantics (resolved at execution time, never order-dependent):\n\n- A configured id that is registered and `available()` → that provider.\n- A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`.\n- A configured id registered but unavailable → `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`.\n- No id configured, exactly one registered usable provider → that provider.\n- No id configured, multiple usable providers → `WEB_PROVIDER_AMBIGUOUS`.\n- No id configured, no usable provider → `WEB_PROVIDER_UNAVAILABLE`.', methods: [ { signature: 'registerSearchProvider(provider: WebSearchProvider): () => void', - description: 'Register a search provider. Throws WebError `WEB_DUPLICATE_PROVIDER` if its id is already registered for search. Returns a disposer; disposed with the calling fiber.', + description: 'Register a search provider. `listProviders()` lists it until the registration is disposed, which happens with the calling fiber or through the returned disposer; registration and disposal each emit `web/providers-changed`. Throws WebError `WEB_DUPLICATE_PROVIDER` if its id is already registered for search.', parameters: [{ name: 'provider', description: 'the provider; its `id` is the registry key.' }], returns: 'the disposer that unregisters the provider.', }, { signature: 'registerFetchProvider(provider: WebFetchProvider): () => void', - description: 'Register a fetch provider. Throws WebError `WEB_DUPLICATE_PROVIDER` if its id is already registered for fetch. Returns a disposer; disposed with the calling fiber.', + description: 'Register a fetch provider, with the listing, disposal, and event behavior of WebRuntime.registerSearchProvider. Throws WebError `WEB_DUPLICATE_PROVIDER` if its id is already registered for fetch.', parameters: [{ name: 'provider', description: 'the provider; its `id` is the registry key.' }], returns: 'the disposer that unregisters the provider.', }, + { + signature: '@Remote listProviders(): WebProviderList', + description: 'List every registered search and fetch provider for provider pickers. Availability does not filter the lists.', + parameters: [], + returns: 'detached `{ id, title, description }` entries per capability, in registration order.', + }, { signature: 'async search(request: WebSearchRequest, signal?: AbortSignal): Promise', description: 'Run one search through the selected provider. Resolves the provider at call time with the selection rules above; throws WebError when the capability cannot run. The seam enforces `request.maxResults` on the result: if the provider over-returns, `sources[]` is truncated and `truncated` set.', @@ -5271,6 +5277,14 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'Ask composed answerers for structured user input. Return an answer to claim the request or call `next()` to delegate. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.', parameters: [{ name: 'request', description: 'pending user-question request.' }], }, + { + name: 'web/providers-changed', + mode: 'emit', + signature: '\'web/providers-changed\'(): void', + summary: 'The set of registered search or fetch providers changed: one provider registered, or its registration was disposed.', + description: 'The set of registered search or fetch providers changed: one provider registered, or its registration was disposed. Payload-free; consumers re-read `ctx.web.listProviders()` (Remote `web/listProviders`) for the new list. Fires after each committed registry mutation. Observer failures are contained and cannot veto the mutation.', + parameters: [], + }, { name: 'webserver/index-inject', mode: 'emit', @@ -9853,7 +9867,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'WebFetchProvider', - declaration: 'export interface WebFetchProvider {\n readonly id: string;\n available(): boolean;\n ready?(llmProvider: string | undefined): Promise;\n fetch(request: WebFetchRequest, signal?: AbortSignal): Promise;\n}', + declaration: 'export interface WebFetchProvider {\n readonly id: string;\n readonly title?: string;\n readonly description?: LocalizedText;\n available(): boolean;\n ready?(llmProvider: string | undefined): Promise;\n fetch(request: WebFetchRequest, signal?: AbortSignal): Promise;\n}', }, { name: 'WebFetchRequest', @@ -9899,6 +9913,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'WebhookSourceId', declaration: 'export type WebhookSourceId = Branded<\'WebhookSourceId\'>;', }, + { + name: 'WebProviderInfo', + declaration: 'export interface WebProviderInfo {\n readonly id: string;\n readonly title?: string;\n readonly description?: LocalizedText;\n}', + }, + { + name: 'WebProviderList', + declaration: 'export interface WebProviderList {\n readonly search: readonly WebProviderInfo[];\n readonly fetch: readonly WebProviderInfo[];\n}', + }, { name: 'WebResource', declaration: 'export type WebResource = (request: Request, peer?: PeerScope) => Promise;', @@ -9921,7 +9943,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'WebSearchProvider', - declaration: 'export interface WebSearchProvider {\n readonly id: string;\n available(): boolean;\n ready?(llmProvider: string | undefined): Promise;\n search(request: WebSearchRequest, signal?: AbortSignal): Promise;\n}', + declaration: 'export interface WebSearchProvider {\n readonly id: string;\n readonly title?: string;\n readonly description?: LocalizedText;\n available(): boolean;\n ready?(llmProvider: string | undefined): Promise;\n search(request: WebSearchRequest, signal?: AbortSignal): Promise;\n}', }, { name: 'WebSearchRequest', diff --git a/packages/llm/llm-deepseek-account/README.i18n.yaml b/packages/llm/llm-deepseek-account/README.i18n.yaml index 04673dac59..ade47b1cf3 100644 --- a/packages/llm/llm-deepseek-account/README.i18n.yaml +++ b/packages/llm/llm-deepseek-account/README.i18n.yaml @@ -18,8 +18,8 @@ en: b1a9453e08198023 zh: 630e7f566ad4d9db /deepseek-ai-dsh-llm-deepseek-account/understand-the-implementation: - en: 534b344a98142d91 - zh: 1291b721c8fead84 + en: 06629c312d0cb860 + zh: f33b2773b0ab1d25 /deepseek-ai-dsh-llm-deepseek-account/further-exploration: en: 30a69b9c66d89580 zh: fcc026a6373c1bee diff --git a/packages/llm/llm-deepseek-account/README.md b/packages/llm/llm-deepseek-account/README.md index 3825e64786..d6397a8c5f 100644 --- a/packages/llm/llm-deepseek-account/README.md +++ b/packages/llm/llm-deepseek-account/README.md @@ -63,7 +63,7 @@ The settings namespace is the Cordis entry id, or the plugin name without an ent ## Understand the implementation -Registrations and listeners dispose with the plugin, and disposal aborts an in-flight listing request. Shared Host wiring supplies attachments, request extensions, anonymous identity, and atomic retry-policy updates; this plugin registers only its own route and uses the transport's `connect` and `admit` hooks for the catalog and the gate. The listing request, its cache, and catalog resolution come from the shared transport, which the API-key route also uses; the cache lives in memory and is keyed by endpoint and a SHA-256 of the token. +Account-token lookup and invalidation use the [shared credential helpers](../llm-deepseek/src/credential.ts); this plugin retains HTTP error classification. Registrations and listeners dispose with the plugin, and disposal aborts an in-flight listing request. Shared Host wiring supplies attachments, request extensions, anonymous identity, and atomic retry-policy updates; this plugin registers only its own route and uses the transport's `connect` and `admit` hooks for the catalog and the gate. The listing request, its cache, and catalog resolution come from the shared transport, which the API-key route also uses; the cache lives in memory and is keyed by endpoint and a SHA-256 of the token. A failed request the shared transport classifies as `QUOTA` is rewritten to `ACCOUNT_QUOTA` before it leaves this provider, covering an HTTP 402 response and an in-band SSE error alike. The shared transport and the `deepseek-official` route keep `QUOTA`, so the account route's top-up action never appears for an API-key or third-party failure. diff --git a/packages/llm/llm-deepseek-account/README.zh.md b/packages/llm/llm-deepseek-account/README.zh.md index 7f6304c09a..a2407afbda 100644 --- a/packages/llm/llm-deepseek-account/README.zh.md +++ b/packages/llm/llm-deepseek-account/README.zh.md @@ -63,7 +63,7 @@ kind: "package-reference" ## 理解实现 -注册和监听随插件生命周期释放,释放时会中止进行中的列表请求。共享 Host 绑定提供附件访问、请求扩展、匿名标识和重试策略的原子更新;本包只注册自己的路由,并通过传输层的 `connect` 与 `admit` 钩子接入目录和门控。列表请求、缓存和目录解析来自共享传输包,API key 路由也使用它们;缓存只在内存中,以端点和 token 的 SHA-256 为键。 +账号 token 读取与作废使用[共享凭据函数](../llm-deepseek/src/credential.ts);本插件保留 HTTP 错误分类。注册和监听随插件生命周期释放,释放时会中止进行中的列表请求。共享 Host 绑定提供附件访问、请求扩展、匿名标识和重试策略的原子更新;本包只注册自己的路由,并通过传输层的 `connect` 与 `admit` 钩子接入目录和门控。列表请求、缓存和目录解析来自共享传输包,API key 路由也使用它们;缓存只在内存中,以端点和 token 的 SHA-256 为键。 共享传输判定为 `QUOTA` 的失败请求离开本提供方前会被改写为 `ACCOUNT_QUOTA`,HTTP 402 响应与流内 SSE 错误都适用。共享传输与 `deepseek-official` 路由保留 `QUOTA`,因此账号路由的充值操作不会出现在 API Key 或第三方失败上。 diff --git a/packages/llm/llm-deepseek-account/src/index.ts b/packages/llm/llm-deepseek-account/src/index.ts index 633b4efb57..bda73bceda 100644 --- a/packages/llm/llm-deepseek-account/src/index.ts +++ b/packages/llm/llm-deepseek-account/src/index.ts @@ -6,12 +6,12 @@ import type {} from '@deepseek-ai/dsh-deepseek-account' import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' import { catalogModelInfo, createModelListingCache, refuseListedModel, registerDeepSeekProvider, requestModelListing, resolveAdapterOptions, - resolveListedCatalog, + resolveListedCatalog, rejectDeepSeekAccountToken, resolveDeepSeekAccountToken, } from '@deepseek-ai/dsh-llm-deepseek' import type { DeepSeekRequestAuth, ListedCatalog, ListingView, ModelListing, ResolvedDeepSeekOptions } from '@deepseek-ai/dsh-llm-deepseek' import { Config, plainAccountOptions, resolveAccountOptions } from './config.ts' -export { Config } from './config.ts' +export { Config, plainAccountOptions, resolveAccountOptions } from './config.ts' export const name = 'llm-deepseek-account' export const inject = ['llm'] @@ -31,9 +31,8 @@ export function apply(ctx: Context, config: Config): void { const settings = () => resolveAccountOptions(plainAccountOptions(config), environment) settings() const supported = resolveAdapterOptions({}).models - const account = () => ctx.get('deepseekAccount') const resolveAuth = async (connection: ResolvedDeepSeekOptions): Promise => { - const token = await account()?.resolveToken(connection.baseURL) + const token = await resolveDeepSeekAccountToken(ctx, connection.baseURL) if (token === undefined) throw new LlmError('Sign in to DeepSeek to use the account provider. The request destination must allow account authentication.', 'ACCOUNT_SIGN_IN_REQUIRED') return { headers: { [TOKEN_HEADER]: token }, @@ -44,15 +43,14 @@ export function apply(ctx: Context, config: Config): void { } if (error.failure.status !== 401) return error const rejected = new LlmError(error.message, 'ACCOUNT_TOKEN_INVALID', { ...error.failure, cause: error }) - try { await account()?.rejectToken(token) } - catch (_credentialRemovalFailed) { /* Storage failure cannot replace the inference failure. */ } + await rejectDeepSeekAccountToken(ctx, token) return rejected }, } } const catalog = createModelListingCache(ctx, name, PROVIDER, { settings: () => settings().catalog, - credential: async url => account()?.resolveToken(url), + credential: url => resolveDeepSeekAccountToken(ctx, url), load: (url, token, signal) => requestModelListing(url, { [TOKEN_HEADER]: token }, signal), }) let loggedListing: ModelListing | undefined diff --git a/packages/llm/llm-deepseek-api-key/README.i18n.yaml b/packages/llm/llm-deepseek-api-key/README.i18n.yaml index 841d797e05..dfb3bd13e8 100644 --- a/packages/llm/llm-deepseek-api-key/README.i18n.yaml +++ b/packages/llm/llm-deepseek-api-key/README.i18n.yaml @@ -18,8 +18,8 @@ en: 007427bed9bd469b zh: 5089b5781746357f /deepseek-ai-dsh-llm-deepseek-api-key/understand-the-implementation: - en: d614c1ea3a9eb382 - zh: 5ca070349bb22d56 + en: 1b9584855817eddb + zh: feb2c883ca355d31 /deepseek-ai-dsh-llm-deepseek-api-key/further-exploration: en: 347659458510940c zh: ccfb03e18b356a39 diff --git a/packages/llm/llm-deepseek-api-key/README.md b/packages/llm/llm-deepseek-api-key/README.md index ee8e1da812..aa6665824c 100644 --- a/packages/llm/llm-deepseek-api-key/README.md +++ b/packages/llm/llm-deepseek-api-key/README.md @@ -55,7 +55,7 @@ The settings namespace is the Cordis entry id, or the plugin name without an ent ## Understand the implementation -Registrations and listeners dispose with the plugin, and disposal aborts an in-flight listing request. Shared Host wiring supplies attachments, request extensions, anonymous identity, and atomic retry-policy updates; this plugin registers only its own route and uses the transport's `connect` and `admit` hooks for the listed catalog and the gate. The listing request, its cache, and catalog resolution come from the shared transport; the cache lives in memory and is keyed by endpoint and a SHA-256 of the key, so another key loads its own listing. A `loader/volatile-update` listener discards the cached listing whenever the route starts or stops following it. +API-key lookup calls `resolveDeepSeekApiKey` from the [shared credential module](../llm-deepseek/src/credential.ts). Registrations and listeners dispose with the plugin, and disposal aborts an in-flight listing request. Shared Host wiring supplies attachments, request extensions, anonymous identity, and atomic retry-policy updates; this plugin registers only its own route and uses the transport's `connect` and `admit` hooks for the listed catalog and the gate. The listing request, its cache, and catalog resolution come from the shared transport; the cache lives in memory and is keyed by endpoint and a SHA-256 of the key, so another key loads its own listing. A `loader/volatile-update` listener discards the cached listing whenever the route starts or stops following it. ## Further Exploration diff --git a/packages/llm/llm-deepseek-api-key/README.zh.md b/packages/llm/llm-deepseek-api-key/README.zh.md index ae609ed179..33ca8db1fa 100644 --- a/packages/llm/llm-deepseek-api-key/README.zh.md +++ b/packages/llm/llm-deepseek-api-key/README.zh.md @@ -55,7 +55,7 @@ kind: "package-reference" ## 理解实现 -注册和监听随插件生命周期释放,释放时会中止进行中的列表请求。共享 Host 绑定提供附件访问、请求扩展、匿名标识和重试策略的原子更新;本包只注册自己的路由,并通过传输层的 `connect` 与 `admit` 钩子接入列表目录和门控。列表请求、缓存和目录解析来自共享传输包;缓存只在内存中,以端点和 API key 的 SHA-256 为键,因此换一个 key 会加载它自己的列表。一个 `loader/volatile-update` 监听器在路由开始或停止使用列表时丢弃缓存的列表。 +API key 读取调用[共享凭据模块](../llm-deepseek/src/credential.ts)的 `resolveDeepSeekApiKey`。注册和监听随插件生命周期释放,释放时会中止进行中的列表请求。共享 Host 绑定提供附件访问、请求扩展、匿名标识和重试策略的原子更新;本包只注册自己的路由,并通过传输层的 `connect` 与 `admit` 钩子接入列表目录和门控。列表请求、缓存和目录解析来自共享传输包;缓存只在内存中,以端点和 API key 的 SHA-256 为键,因此换一个 key 会加载它自己的列表。一个 `loader/volatile-update` 监听器在路由开始或停止使用列表时丢弃缓存的列表。 ## 进一步探索 diff --git a/packages/llm/llm-deepseek-api-key/src/index.ts b/packages/llm/llm-deepseek-api-key/src/index.ts index 70f9468328..ac0f4753c5 100644 --- a/packages/llm/llm-deepseek-api-key/src/index.ts +++ b/packages/llm/llm-deepseek-api-key/src/index.ts @@ -1,11 +1,12 @@ /** API-key authentication, credential-gated discovery, and the official model listing for the official DeepSeek route. */ import type { Context } from '@deepseek-ai/cordis' -import { APP_IDENTITY, assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm' +import { APP_IDENTITY, LlmError } from '@deepseek-ai/dsh-llm' import type { LlmModelBadge } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/cordis-plugin-loader' import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' import { catalogModelInfo, createModelListingCache, refuseListedModel, registerDeepSeekProvider, requestModelListing, resolveListedCatalog, + resolveDeepSeekApiKey, } from '@deepseek-ai/dsh-llm-deepseek' import type { CatalogRefusal, DeepSeekCatalogModel, ListingView, ModelListing } from '@deepseek-ai/dsh-llm-deepseek' import { Config, plainOptions, resolveAdapterOptions } from './config.ts' @@ -35,14 +36,8 @@ export function apply(ctx: Context, config: Config): void { options() const resolveApiKey = async (connection: ResolvedDeepSeekOptions): Promise => { const ref = connection.apiKeyEnv - const credentials = ctx.get('credentials') - if (credentials !== undefined) { - const hit = await credentials.resolve(ref) - if (hit !== undefined) return assertUsableApiKey(hit.value, 'llm-deepseek', ref) - } else { - const ambient = launchEnvironmentOf(ctx).get(ref) - if (ambient !== undefined && ambient.value.length > 0) return assertUsableApiKey(ambient.value, 'llm-deepseek', ref) - } + const key = await resolveDeepSeekApiKey(ctx, ref) + if (key !== undefined) return key throw new LlmError( `llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials` + ` service (the web Models page writes it), or export ${ref} in the launching environment`, diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 2143878501..2c756e7492 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -24,11 +24,11 @@ en: 6a15f2e0efec4c37 zh: 7fb5f1e4a92b01ad /deepseek-ai-dsh-llm-deepseek/use-this-package/endpoint-and-wire-format: - en: 522e74b770e9d4ca - zh: a8b261b41677bfe7 + en: 559f2a1b85de8d42 + zh: 08af2386b3e9ecbd /deepseek-ai-dsh-llm-deepseek/use-this-package/account-credentials: - en: 3853ba1a1ffbb46c - zh: 97203e80ad933e59 + en: 941f9e4b138a7f6e + zh: b868228c57fbcc87 /deepseek-ai-dsh-llm-deepseek/use-this-package/streaming-with-thinking-and-images: en: 81bf213499f015ba zh: e9107ef322213e56 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 396248805e..85439d5875 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -84,12 +84,15 @@ The official root is `https://api.deepseek.com/anthropic`. An explicit `baseURL` Messages sends text, thinking, tool calls, and tool results as content blocks, reasoning effort as `output_config.effort`, and images as Files references or inline base64. Models declaring `systemPromptUpdate: in-history` retain the initial top-level system and send new system snapshots after their corresponding user/tool-result turn; undeclared models use the latest snapshot as the top-level system. Replay metadata preserves the model and thinking signatures. Invalid replay metadata emits a warning and omits signatures while retaining text and tool history. Model entries may declare `toolUpdate: addition-only` or `in-history`; the default `deepseek-flash` entry declares `addition-only`. Projected developer tool updates become system-role `tool_addition` and `tool_removal` blocks referencing declared names, and deferred declarations carry `defer_loading`. Requests containing those blocks send the `mid-conversation-tool-changes-2026-07-01` beta header. Messages omits empty assistant text blocks and skips assistant messages left without content, including saved history from other protocols. The stored Session content remains intact. + ### Account credentials `deepseek-official` resolves only its configured API-key reference. `deepseek-account` resolves only the stored grant from the [account provider](../../credentials/deepseek-account-platform/README.md), whose allowed `inferenceOrigin` defaults to `https://api.deepseek.com`. Neither route falls back to the other. Signing out removes the account grant and preserves API keys. Messages and Files requests send account tokens as `x-dsh-auth-token` without a Bearer prefix; API keys use `x-api-key`. Neither credential mode follows redirects. The account provider owns HTTP 401 classification and credential invalidation; the transport passes failures to its callback. +The [shared credential helpers](src/credential.ts) serve model routes and web providers without choosing request headers. `resolveDeepSeekApiKey(ctx, ref)` returns a normalized, validated key or `undefined`; an installed credentials service is authoritative, and only its absence enables launch-environment lookup. Store and key-format failures propagate. `deepSeekApiKeyConfigured(ctx, ref)` checks presence without reading stored key values. Account helpers delegate allowed-destination resolution and exact-token invalidation to the account service; invalidation contains storage failures so the original request error remains visible. + ### Streaming with thinking and images An image-capable route chooses each durable reference's request target and resolves it into a deterministic request version. Omitting `imagePixelBudget` sizes the target on the published vision token grid of 14px patches, 3:1 downsampling, and at most 1024 tokens per image, so a square image keeps up to 1302×1302 pixels and a 16:9 image is sent as 1708×961 for the provider's 1708×966 grid; a positive integer replaces the grid with a total-pixel budget, and `low` uses 512×512 total pixels. Every request image is capped at 4096 pixels per side, the provider limit for requests carrying 15 or more images, and `imageMaxBytes` defaults to 2 MiB. Alpha images use WebP effort 0 and opaque images use JPEG on the 85/75/60 quality ladder, keeping the smallest output when every candidate exceeds the target. Every retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current filesystem maps the attachment provider's host object, that text also carries a read-only execution-world path and the extension for a writable copy. Text-only and unlisted routes receive stable attachment placeholders while durable history keeps the image references. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index f11c8c3e13..e7f5974fb5 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -84,12 +84,15 @@ kind: "package-reference" Messages 以内容块发送文本、思考、工具调用和工具结果,以 `output_config.effort` 发送推理强度,并以 Files 引用或内联 base64 发送图片。声明 `systemPromptUpdate: in-history` 的模型保留初始顶层 system,在对应 user/tool-result 轮次之后发送新的 system 快照;未声明能力时,使用最新快照作为顶层 system。回放元数据保留模型与思考签名。无效的回放元数据产生警告并省略签名,不丢弃文本或工具历史。 模型条目可声明 `toolUpdate: addition-only` 或 `in-history`;默认 `deepseek-flash` 条目声明 `addition-only`。投影后的 developer 工具更新转换为 system 角色的 `tool_addition` 和 `tool_removal` 块,引用已声明名称,延迟声明携带 `defer_loading`。包含这些块的请求发送 `mid-conversation-tool-changes-2026-07-01` beta 请求头。 Messages 会省略 assistant 的空文本块,并跳过转换后没有内容的 assistant 消息,包括其他协议保存的历史。已存储的 Session 内容保持原样。 + ### 账号凭据 `deepseek-official` 仅解析配置的 API Key 引用。`deepseek-account` 仅解析[账号提供者](../../credentials/deepseek-account-platform/README.zh.md)保存的授权,其允许的 `inferenceOrigin` 默认为 `https://api.deepseek.com`。两条路由均不回退到另一凭证。退出登录删除账号授权,保留 API Key。 Messages 和 Files 请求通过 `x-dsh-auth-token` 发送账号 token,不加 Bearer 前缀;API Key 使用 `x-api-key`。两种凭据模式均拒绝重定向。账号 provider 负责 HTTP 401 分类和凭据失效处理;传输层将错误交给其回调。 +[共享凭据函数](src/credential.ts)为模型路由和 Web 提供方获取凭据,不决定请求头。`resolveDeepSeekApiKey(ctx, ref)` 返回规范化并校验后的密钥或 `undefined`;已安装的凭据服务具有权威性,仅在该服务缺席时读取启动环境。存储与密钥格式错误向上传播。`deepSeekApiKeyConfigured(ctx, ref)` 检查是否存在,不读取已存密钥值。账号函数将目标地址许可检查和精确 token 作废交给账号服务;作废时隔离存储失败,保留原请求错误。 + ### 带 thinking 与图片的流式调用 支持图片的路由为每个持久引用选定请求目标,再把它解析为确定性请求版本。省略 `imagePixelBudget` 时按官方公布的视觉 token 网格定目标,即 14 px patch、3:1 降采样、单图最多 1024 token,因此正方形图片最多保留 1302×1302 像素,16:9 图片以 1708×961 发送、对应提供方 1708×966 的网格;正整数会用总像素预算取代网格,`low` 使用总计 512×512 像素。每张请求图片单边最多 4096 像素,这是提供方对包含 15 张及以上图片的请求的限制;`imageMaxBytes` 默认为 2 MiB。带 alpha 的图片使用 effort 0 的 WebP,不透明图片使用 JPEG,并采用 85/75/60 质量阶梯;全部候选都超过目标时保留最小输出。每张保留图片前都有文本,注明完整附件 id 与实际请求尺寸。当前文件系统可以映射附件提供方的宿主对象时,该文本还携带只读执行世界路径与可写副本使用的扩展名。纯文本与未列出路由接收稳定附件占位符,而持久历史继续保留图片引用。 diff --git a/packages/llm/llm-deepseek/src/credential.ts b/packages/llm/llm-deepseek/src/credential.ts new file mode 100644 index 0000000000..0edcbb1107 --- /dev/null +++ b/packages/llm/llm-deepseek/src/credential.ts @@ -0,0 +1,58 @@ +/** Credential values shared by DeepSeek model and web providers; callers own protocol headers and missing-key errors. */ +import type { Context } from '@deepseek-ai/cordis' +import type { CredentialRef } from '@deepseek-ai/dsh-credentials' +import type {} from '@deepseek-ai/dsh-deepseek-account' +import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' +import { assertUsableApiKey } from '@deepseek-ai/dsh-llm' + +/** + * Read and validate one API key. An installed credentials service is authoritative, including absence; + * only deployments without that service read the launch environment. Store and format failures propagate. + * @param ctx - context providing the credential and launch-environment services. + * @param ref - credential reference to resolve. + * @returns the validated key, or undefined when no key is configured. + */ +export async function resolveDeepSeekApiKey(ctx: Context, ref: CredentialRef): Promise { + const credentials = ctx.get('credentials') + if (credentials !== undefined) { + const hit = await credentials.resolve(ref) + return hit === undefined ? undefined : assertUsableApiKey(hit.value, 'llm-deepseek', ref) + } + const ambient = launchEnvironmentOf(ctx).get(ref) + return ambient === undefined || ambient.value.length === 0 ? undefined : assertUsableApiKey(ambient.value, 'llm-deepseek', ref) +} + +/** + * Check key presence without resolving a credential-store value or validating its format. Store failures propagate. + * @param ctx - context providing the credential and launch-environment services. + * @param ref - credential reference to inspect. + * @returns whether the authoritative credential source reports a configured key. + */ +export async function deepSeekApiKeyConfigured(ctx: Context, ref: CredentialRef): Promise { + const credentials = ctx.get('credentials') + return credentials === undefined + ? (launchEnvironmentOf(ctx).get(ref)?.value.length ?? 0) > 0 + : (await credentials.describe(ref)).configured +} + +/** + * Read an account token only for a destination allowed by the account service. Credential-store failures propagate. + * @param ctx - context providing the optional account service. + * @param endpoint - destination that would receive the token. + * @returns the token, or undefined without an allowed signed-in account. + */ +export async function resolveDeepSeekAccountToken(ctx: Context, endpoint: string): Promise { + return await ctx.get('deepseekAccount')?.resolveToken(endpoint) +} + +/** + * Invalidate the account token rejected by a request, without clearing a newer sign-in. Removal failure is contained + * so callers can report the original request failure; the account service owns in-memory invalidation and persistence. + * @param ctx - context providing the optional account service. + * @param token - exact token sent by the rejected request. + * @returns settlement after the invalidation attempt. + */ +export async function rejectDeepSeekAccountToken(ctx: Context, token: string): Promise { + try { await ctx.get('deepseekAccount')?.rejectToken(token) } + catch (_credentialRemovalFailed) { /* Storage failure cannot replace the request failure. */ } +} diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 1c7ab36255..c3ca109a93 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -1,4 +1,5 @@ /** DeepSeek Messages transport, request configuration, and model capabilities. */ +export { deepSeekApiKeyConfigured, rejectDeepSeekAccountToken, resolveDeepSeekAccountToken, resolveDeepSeekApiKey } from './credential.ts' export { catalogModelFields, deepSeekConfigFields, Config, isBuiltInCatalog, plainOptions, resolveAdapterOptions, PUBLIC_BASE_URL } from './config.ts' export type { Options, ResolvedDeepSeekOptions } from './config.ts' export { diff --git a/packages/llm/llm-deepseek/tests/credential.spec.ts b/packages/llm/llm-deepseek/tests/credential.spec.ts new file mode 100644 index 0000000000..6054a07c4f --- /dev/null +++ b/packages/llm/llm-deepseek/tests/credential.spec.ts @@ -0,0 +1,71 @@ +/** Shared credential resolution and metadata-only readiness across DeepSeek consumers. */ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment' +import { deepSeekApiKeyConfigured, rejectDeepSeekAccountToken, resolveDeepSeekAccountToken, resolveDeepSeekApiKey } from '../src/credential.ts' + +const ref = credentialRef('TEST_DEEPSEEK_KEY') + +function context(value?: string): Context { + const ctx = new Context() + ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot([ + { source: 'process', values: value === undefined ? {} : { [ref]: value } }, + ])) + return ctx +} + +describe('shared DeepSeek credentials', () => { + it.each([undefined, '', ' valid-key '])('reads the launch environment only without credentials: %j', async (value) => { + const ctx = context(value) + try { + await expect(resolveDeepSeekApiKey(ctx, ref)).resolves.toBe(value ? value.trim() : undefined) + await expect(deepSeekApiKeyConfigured(ctx, ref)).resolves.toBe(Boolean(value)) + } finally { await ctx.fiber.dispose() } + }) + + it('treats the installed service as authoritative and reads no value for configured', async () => { + const ctx = context('ambient-key') + const resolve = vi.fn(async () => undefined) + const describe = vi.fn(async () => ({ configured: false })) + ctx.provide('credentials', { resolve, describe } as never) + try { + await expect(deepSeekApiKeyConfigured(ctx, ref)).resolves.toBe(false) + expect(resolve).not.toHaveBeenCalled() + await expect(resolveDeepSeekApiKey(ctx, ref)).resolves.toBeUndefined() + expect(resolve).toHaveBeenCalledExactlyOnceWith(ref) + } finally { await ctx.fiber.dispose() } + }) + + it.each(['bad key', '非法密钥', 'bad\nkey', ' '])('rejects malformed environment keys without exposing the value: %j', async (value) => { + const ctx = context(value) + try { + await expect(resolveDeepSeekApiKey(ctx, ref)).rejects.toMatchObject({ code: 'INVALID_CREDENTIAL' }) + } finally { await ctx.fiber.dispose() } + }) + + it('validates and normalizes each stored key without caching it', async () => { + const ctx = context('ambient-key') + const resolve = vi.fn().mockResolvedValueOnce({ value: ' stored-key ' }).mockResolvedValueOnce({ value: 'bad key' }) + ctx.provide('credentials', { resolve } as never) + try { + await expect(resolveDeepSeekApiKey(ctx, ref)).resolves.toBe('stored-key') + await expect(resolveDeepSeekApiKey(ctx, ref)).rejects.toMatchObject({ code: 'INVALID_CREDENTIAL' }) + } finally { await ctx.fiber.dispose() } + }) + + it('delegates account destination checks and contains token-removal failures', async () => { + const ctx = context() + try { + await expect(resolveDeepSeekAccountToken(ctx, 'https://allowed.test')).resolves.toBeUndefined() + await expect(rejectDeepSeekAccountToken(ctx, 'old-token')).resolves.toBeUndefined() + const resolveToken = vi.fn(async () => 'account-token') + const rejectToken = vi.fn(async () => { throw new Error('storage failed') }) + ctx.provide('deepseekAccount', { resolveToken, rejectToken } as never) + await expect(resolveDeepSeekAccountToken(ctx, 'https://allowed.test')).resolves.toBe('account-token') + expect(resolveToken).toHaveBeenCalledExactlyOnceWith('https://allowed.test') + await expect(rejectDeepSeekAccountToken(ctx, 'old-token')).resolves.toBeUndefined() + expect(rejectToken).toHaveBeenCalledExactlyOnceWith('old-token') + } finally { await ctx.fiber.dispose() } + }) +}) diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index d7b5af77a3..84c4ffe7a9 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -8,6 +8,8 @@ "src" ], "references": [ + { "path": "../../credentials/credentials" }, + { "path": "../../credentials/deepseek-account" }, { "path": "../../../vendor/cosmokit" }, diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md index 028cba2769..cafaac2477 100644 --- a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md +++ b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md @@ -108,7 +108,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo | `@deepseek-ai/dsh-client-ui-settings-session-log` | no | General settings control for Session-log upload with DeepSeek API requests | | `@deepseek-ai/dsh-client-ui-settings-shell` | no | Settings page of the shell executor on the dsh web client's Plugins page: the command timeout and the per-stream output cap of the shell namespace | | `@deepseek-ai/dsh-client-ui-settings-subagent` | no | Settings page of Subagent delegation on the dsh web client's Plugins page: recursion depth, parallel capacity, and the models agents may choose for subagents | -| `@deepseek-ai/dsh-client-ui-settings-web-search` | no | Settings page of the DeepSeek web-search provider on the dsh web client's Plugins page: its API key, endpoint, and per-request search budget | +| `@deepseek-ai/dsh-client-ui-settings-web-search` | no | Settings page for web search and fetch on the dsh web client's Plugins page: the Search and Fetch provider menus, the DeepSeek Search API result count, and legacy Messages search settings | | `@deepseek-ai/dsh-client-ui-shortcuts` | no | Keyboard shortcut reference, recording, and local preference editing | | `@deepseek-ai/dsh-client-ui-sidebar` | no | Sidebar plugin: session multi-level tree, search, grouping, state dots | | `@deepseek-ai/dsh-client-ui-sidebar-browser` | no | Sandboxed Web browser tabs for the right Sidebar | @@ -523,8 +523,6 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo | `@deepseek-ai/dsh-web-deepseek-api` | yes | DeepSeek Search and Fetch API providers for the DeepSeek Harness web capability seam (ctx.web) | | `@deepseek-ai/dsh-web-fetch-http` | yes | Anonymous HTTP(S) fetch provider for the DeepSeek Harness web capability seam (ctx.web) | | `@deepseek-ai/dsh-web-search-deepseek` | yes | DeepSeek-backed search provider (native web_search via the Anthropic-compatible API) for the DeepSeek Harness web capability seam (ctx.web) | -| `@deepseek-ai/dsh-web-search-exa` | yes | Exa-backed search provider for the DeepSeek Harness web capability seam (ctx.web) | -| `@deepseek-ai/dsh-web-search-perplexity` | yes | Perplexity-backed search provider for the DeepSeek Harness web capability seam (ctx.web) | ## workflow diff --git a/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts index 0debc5a62a..abe5859af1 100644 --- a/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts +++ b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts @@ -30,8 +30,10 @@ export const remoteDefaultResponses: RemoteTable = { 'dynamicCordisRunner/syncInspectManifest': ok(null), // ui-cordis inventory at apply and on `connection/reset`. 'dynamicCordisRunner/inventory': ok([]), - // ui-settings-plugins web-search card `readCredential()` when the settings mirror first publishes. + // ui-settings-web-search page `readCredential()` when the settings mirror first publishes. 'credentials/describe': ok({}), + // ui-settings-web-search page provider options at apply and on `connection/reset`. + 'web/listProviders': ok({ search: [], fetch: [] }), // ui-permission-presets `PermissionCatalogDirectory` on its first read for a connection generation. 'permissionPresets/catalog': ok({ options: [] }), // ui-settings-account refreshes details after a stored-grant snapshot. diff --git a/packages/web/README.i18n.yaml b/packages/web/README.i18n.yaml index 7b3d25dd63..2211f4fec6 100644 --- a/packages/web/README.i18n.yaml +++ b/packages/web/README.i18n.yaml @@ -9,14 +9,14 @@ en: 5709ff524bf366c8 zh: b2114dc15899b7ae /web-web-access-capability-family/summary: - en: 8aca8d0b653b1de5 - zh: fa2644d304189950 + en: 03ec479416c2f8c7 + zh: 80bc997dc8d71d27 /web-web-access-capability-family/table-of-contents: en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 /web-web-access-capability-family/packages: - en: 89e39c77421cf5b6 - zh: 0aedc38d2fb8be39 + en: 7006eab9b1b78e82 + zh: 5961f81ec1f370a3 /web-web-access-capability-family/related-documentation: en: 38eff8a93331b70b zh: d60ad34847eb2e29 diff --git a/packages/web/README.md b/packages/web/README.md index 6ef5602106..b84d6eedd8 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The `web/` packages let models search the public web and fetch HTTP(S) pages through the `web_search` and `web_fetch` tools. Deployments can choose Exa, Perplexity, or DeepSeek for search and anonymous HTTP(S) or DeepSeek for fetch; availability and resource limits depend on the configured provider. Use this family for search and page retrieval, not interactive browsing, content extraction, or per-URL policy enforcement. Models receive consistent tool behavior, cancellation, and error reporting when providers change. +The `web/` packages let models search the public web and fetch HTTP(S) pages through the `web_search` and `web_fetch` tools. Deployments can choose DeepSeek for search and anonymous HTTP(S) or DeepSeek for fetch, or mount another provider plugin; availability and resource limits depend on the configured provider. Use this family for search and page retrieval, not interactive browsing, content extraction, or per-URL policy enforcement. Models receive consistent tool behavior, cancellation, and error reporting when providers change. ## Table of Contents @@ -22,13 +22,11 @@ The `web/` packages let models search the public web and fetch HTTP(S) pages thr ## Packages -Six packages play the web roles; the subsystem reference owns the exhaustive vocabulary and contracts. +Five packages play the web roles; the subsystem reference owns the exhaustive vocabulary and contracts. | Package | Role | ctx key | |---|---|---| | [`web/`](web/README.md) | Search/fetch service: search and fetch URLs through interchangeable backends, one selection and error policy | `ctx.web` | -| [`web-search-exa/`](web-search-exa/README.md) | Searches the web through Exa | registers on `ctx.web` | -| [`web-search-perplexity/`](web-search-perplexity/README.md) | Searches the web through Perplexity | registers on `ctx.web` | | [`web-search-deepseek/`](web-search-deepseek/README.md) | Searches through DeepSeek Messages | registers on `ctx.web` | | [`web-deepseek-api/`](web-deepseek-api/README.md) | Searches and fetches pages through the DeepSeek Search and Fetch APIs | registers on `ctx.web` | | [`web-fetch-http/`](web-fetch-http/README.md) | Fetches HTTP(S) pages anonymously | registers on `ctx.web` | diff --git a/packages/web/README.zh.md b/packages/web/README.zh.md index 7c207cfc01..6986fc8795 100644 --- a/packages/web/README.zh.md +++ b/packages/web/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -`web/` 包让模型通过 `web_search` 与 `web_fetch` 工具搜索公共 web 和抓取 HTTP(S) 页面。部署可为搜索选择 Exa、Perplexity 或 DeepSeek,并通过匿名 HTTP(S) 或 DeepSeek 抓取页面;可用性与资源上限取决于配置的提供方。该家族用于搜索和页面检索,不用于交互式浏览、内容提取或逐 URL 策略执行。提供方变化时,模型仍能获得一致的工具行为、取消与错误报告。 +`web/` 包让模型通过 `web_search` 与 `web_fetch` 工具搜索公共 web 和抓取 HTTP(S) 页面。部署可为搜索选择 DeepSeek,并通过匿名 HTTP(S) 或 DeepSeek 抓取页面,也可挂载其他提供方插件;可用性与资源上限取决于配置的提供方。该家族用于搜索和页面检索,不用于交互式浏览、内容提取或逐 URL 策略执行。提供方变化时,模型仍能获得一致的工具行为、取消与错误报告。 ## 目录 @@ -22,13 +22,11 @@ kind: "package-group" ## 包 -六个包分别承担 web 角色;完整词汇与约定以子系统参考文档为准。 +五个包分别承担 web 角色;完整词汇与约定以子系统参考文档为准。 | 包 | 职责 | ctx 键 | |---|---|---| | [`web/`](web/README.zh.md) | 搜索与抓取服务:通过可互换的后端搜索与抓取 URL,统一选择与错误策略 | `ctx.web` | -| [`web-search-exa/`](web-search-exa/README.zh.md) | 通过 Exa 搜索 web | 注册到 `ctx.web` | -| [`web-search-perplexity/`](web-search-perplexity/README.zh.md) | 通过 Perplexity 搜索 web | 注册到 `ctx.web` | | [`web-search-deepseek/`](web-search-deepseek/README.zh.md) | 通过 DeepSeek Messages 搜索网页 | 注册到 `ctx.web` | | [`web-deepseek-api/`](web-deepseek-api/README.zh.md) | 通过 DeepSeek Search 与 Fetch API 搜索和抓取网页 | 注册到 `ctx.web` | | [`web-fetch-http/`](web-fetch-http/README.zh.md) | 匿名抓取 HTTP(S) 页面 | 注册到 `ctx.web` | diff --git a/packages/web/tool-web/README.md b/packages/web/tool-web/README.md index 7f1cfc4078..ca70b008f2 100644 --- a/packages/web/tool-web/README.md +++ b/packages/web/tool-web/README.md @@ -37,7 +37,7 @@ Load the web service, at least one backend, and this package; both tools registe ```yaml - name: '@deepseek-ai/dsh-web' -- name: '@deepseek-ai/dsh-web-search-exa' +- name: '@deepseek-ai/dsh-web-deepseek-api' - name: '@deepseek-ai/dsh-tool-web' ``` diff --git a/packages/web/tool-web/README.zh.md b/packages/web/tool-web/README.zh.md index e9b9978816..d4e11acf6b 100644 --- a/packages/web/tool-web/README.zh.md +++ b/packages/web/tool-web/README.zh.md @@ -37,7 +37,7 @@ kind: "package-reference" ```yaml - name: '@deepseek-ai/dsh-web' -- name: '@deepseek-ai/dsh-web-search-exa' +- name: '@deepseek-ai/dsh-web-deepseek-api' - name: '@deepseek-ai/dsh-tool-web' ``` diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index a00e90a14c..ef1c885fa2 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -55,10 +55,11 @@ "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-web": "workspace:*", + "@deepseek-ai/dsh-web-deepseek-api": "workspace:*", "@deepseek-ai/dsh-web-fetch-http": "workspace:*", - "@deepseek-ai/dsh-web-search-exa": "workspace:*", "@types/turndown": "^5.0.6", "@deepseek-ai/dsh-scope": "workspace:*", - "@deepseek-ai/dsh-token-meter": "workspace:*" + "@deepseek-ai/dsh-token-meter": "workspace:*", + "@deepseek-ai/dsh-launch-environment": "workspace:*" } } diff --git a/packages/web/tool-web/tests/integration.spec.ts b/packages/web/tool-web/tests/integration.spec.ts index 32757e90a7..c21300fcac 100644 --- a/packages/web/tool-web/tests/integration.spec.ts +++ b/packages/web/tool-web/tests/integration.spec.ts @@ -1,9 +1,9 @@ /** * Integration: the real fetch backend (`dsh-web-fetch-http`) + a real search provider - * (`dsh-web-search-exa`) + the real seam (`dsh-web`) + the model tool (`dsh-tool-web`) + the + * (`dsh-web-deepseek-api`) + the real seam (`dsh-web`) + the model tool (`dsh-tool-web`) + the * tool-call timeout policy (`dsh-tool-call-timeout-policy`), exercised through `executeToolFixture()` — - * nothing bypasses the tool registry. Fetch verifies world effects against loopback HTTP; search uses the real Exa provider - * with only its network boundary stubbed. + * nothing bypasses the tool registry. Fetch verifies world effects against loopback HTTP; search uses the real + * DeepSeek Search API provider with only its network boundary stubbed. */ import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' @@ -13,6 +13,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } import { AddressInfo } from 'node:net' import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' +import { DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { createScope } from '@deepseek-ai/dsh-scope' import { SessionId } from '@deepseek-ai/dsh-session' @@ -20,11 +21,12 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { type ToolExecutionResult } from '@deepseek-ai/dsh-tools' import WebRuntime from '@deepseek-ai/dsh-web' import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-http' -import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa' +import * as WebDeepSeekApi from '@deepseek-ai/dsh-web-deepseek-api' import * as ToolWeb from '@deepseek-ai/dsh-tool-web' import * as TimeoutPolicy from '@deepseek-ai/dsh-tool-call-timeout-policy' const testToolSignal = new AbortController().signal +const SEARCH_API_BASE = 'https://api.deepseek.test/v1/tools' type Handler = (req: IncomingMessage, res: ServerResponse) => void @@ -46,9 +48,13 @@ beforeEach(async () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(NativeToolExecutor) - await ctx.plugin(WebRuntime, { searchProvider: WebSearchExa.EXA_PROVIDER_ID, fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID }) + await ctx.plugin(WebRuntime, { + searchProvider: WebDeepSeekApi.DEEPSEEK_SEARCH_API_PROVIDER_ID, + fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID, + }) await ctx.plugin(WebFetchLocal, {}) - await ctx.plugin(WebSearchExa, { apiKey: 'exa-key', baseURL: 'https://api.exa.test' }) + ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot([{ source: 'process', values: { DEEPSEEK_API_KEY: 'search-key' } }])) + await ctx.plugin(WebDeepSeekApi, { searchBaseURL: SEARCH_API_BASE, sendOriginalQuestion: false }) // The shipped deployment shape: the tool-call budget is declared by tool-web // config (default 30s, attached as ToolDefinition.timeoutMs) and enforced by // the zero-config timeout-policy plugin, set above the provider backstop so the @@ -106,15 +112,21 @@ describe('web_fetch integration over the real backend', () => { }) }) -describe('web_search integration over the real Exa provider', () => { +describe('web_search integration over the real DeepSeek Search API provider', () => { it('runs web_search end-to-end and formats the provider result', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response( - JSON.stringify({ results: [{ url: 'https://result.test', title: 'Result', highlights: ['a highlight'] }] }), + const fetchMock = vi.fn(async (_endpoint: string, _init: { headers: HeadersInit; body: string }) => new Response( + JSON.stringify({ results: [{ url: 'https://result.test', title: 'Result', snippet: 'a snippet', date: null }] }), { status: 200, headers: { 'content-type': 'application/json' } }, - ))) + )) + vi.stubGlobal('fetch', fetchMock) const out = await call('web_search', { queries: ['deepseek-official'] }) expect(out.isError).toBe(false) expect(out.content.map(b => b.type === 'text' ? b.text : '').join('')).toContain('[Result](https://result.test)') + expect(fetchMock).toHaveBeenCalledOnce() + const [endpoint, init] = fetchMock.mock.calls[0]! + expect(endpoint).toBe(`${SEARCH_API_BASE}/search`) + expect(new Headers(init.headers).get('authorization')).toBe('Bearer search-key') + expect(JSON.parse(init.body)).toMatchObject({ query: 'deepseek-official' }) }) }) diff --git a/packages/web/web-deepseek-api/README.i18n.yaml b/packages/web/web-deepseek-api/README.i18n.yaml index ea1938d466..1ea0256ae7 100644 --- a/packages/web/web-deepseek-api/README.i18n.yaml +++ b/packages/web/web-deepseek-api/README.i18n.yaml @@ -9,23 +9,23 @@ en: a56113c9530cb046 zh: bb07334e63fd9204 /deepseek-ai-dsh-web-deepseek-api/summary: - en: 0519addcba3db93f - zh: 7c8bb421d7c89a1e + en: 3bdd325c44dfe654 + zh: 559d8765cb5a63a8 /deepseek-ai-dsh-web-deepseek-api/table-of-contents: en: 76368b6b1da79c80 zh: 1e8f931a56b7d34d /deepseek-ai-dsh-web-deepseek-api/use-this-package: - en: 4b2edb0444f1b456 - zh: 57fa9c23caf9973a + en: 2c3cb86ec147f316 + zh: c1d0f3b787d50403 /deepseek-ai-dsh-web-deepseek-api/use-this-package/authentication: - en: 3037d291178a5b62 - zh: 27ed3abccd9d5cf3 + en: 9d63ce3df7224270 + zh: 748066410c7133af /deepseek-ai-dsh-web-deepseek-api/use-this-package/results-and-failures: - en: ed83053d3e5a7e57 - zh: 4c82d231fb6847d7 + en: 7dbb34d960c1344c + zh: 51a1459415857ab5 /deepseek-ai-dsh-web-deepseek-api/understand-the-implementation: - en: 0372c46bac133870 - zh: 0c803f4fb04a7ee4 + en: 0ef6417e1e784c65 + zh: 8c1fc4e955e2ed52 /deepseek-ai-dsh-web-deepseek-api/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 @@ -54,8 +54,8 @@ en: 14f9af39f3be4fac zh: 3c7139fba0f17862 /deepseek-ai-dsh-web-deepseek-api/known-limitations-and-deferred-work: - en: b9cf7d9b31b70d4c - zh: fefb089a751e7feb + en: 689b26b5f3af8861 + zh: 02cc62856a83cf48 /deepseek-ai-dsh-web-deepseek-api/known-limitations-and-deferred-work/dev-note: en: 0a1253c6f7b5e8b9 zh: 9f56528562c7c914 diff --git a/packages/web/web-deepseek-api/README.md b/packages/web/web-deepseek-api/README.md index 1ce8ac224a..b83e03201f 100644 --- a/packages/web/web-deepseek-api/README.md +++ b/packages/web/web-deepseek-api/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Search and fetch the web through the DeepSeek Search and Fetch APIs: one direct request per operation, without an auxiliary model request. The plugin registers `deepseek-search-api` and `deepseek-fetch-api`, which share one credential and one API base, and authenticates with an API key or the configured account integration. Search returns titles, URLs, available snippets and summaries, and available publication dates; Fetch returns page Markdown. Messages search stays in [dsh-web-search-deepseek](../web-search-deepseek/README.md); the model-facing tools live in `dsh-tool-web`. +Search and fetch the web through the DeepSeek Search and Fetch APIs: one direct request per operation, without an auxiliary model request. The plugin registers `deepseek-search-api` and `deepseek-fetch-api`; each capability follows the DeepSeek credential of the conversation: the DeepSeek account sign-in in a conversation on the DeepSeek account model provider, otherwise the DeepSeek API key. YAML can give either capability its own base URL and key instead. Search returns titles, URLs, and available snippets, summaries, and publication dates; Fetch returns page Markdown. The model-facing tools live in `dsh-tool-web`; [legacy Messages search](../web-search-deepseek/README.md) remains a separate provider. ## Table of Contents @@ -24,7 +24,7 @@ Search and fetch the web through the DeepSeek Search and Fetch APIs: one direct ## Use this package -Mount the plugin alongside `dsh-web` and select its providers. Shipped profiles mount it while selecting Messages search and local HTTP fetch. Turning on **Use the new Web Search API** in **Plugins > Built-in plugin settings > Web search** and saving selects both providers of this package; turning it off restores the shipped selection. A saved selection applies to the next operation. +Mount the plugin alongside `dsh-web` and select its providers. Shipped profiles mount it and select its Search API (`deepseek-search-api`) for search and local HTTP fetch for fetch. In **Plugins > Built-in plugin settings > Web search and fetch**, choosing **DeepSeek Search API** in the **Search** menu selects `deepseek-search-api` and choosing **DeepSeek Fetch API** in the **Fetch** menu selects `deepseek-fetch-api`; each menu is saved independently, and choosing another entry restores another provider. A saved selection applies to the next operation. ```yaml - name: '@deepseek-ai/dsh-web' @@ -33,34 +33,42 @@ Mount the plugin alongside `dsh-web` and select its providers. Shipped profiles fetchProvider: deepseek-fetch-api - name: '@deepseek-ai/dsh-web-deepseek-api' config: - apiKeyEnv: DEEPSEEK_API_KEY + searchBaseURL: https://search.example.test/api/v1/tools + searchApiKeyEnv: DEEPSEEK_SEARCH_API_KEY ``` | Field | Default | Meaning | |---|---|---| -| `apiKey` | omitted | Literal API key; prefer a credential reference | -| `apiKeyEnv` | `DEEPSEEK_API_KEY` | Credential reference resolved for each operation | -| `baseURL` | `https://api.deepseek.com/v1/tools` | Search and Fetch API base including its deployment path prefix; append `/search` or `/fetch` | | `count` | `10` | Search API results per search, an integer from 1 to 50; a smaller `maxResults` on the request wins | | `searchRequestsPerSecond` | `5` | Most Search requests this plugin instance sends in any one-second window, matching the service's account-wide 5 QPS limit; later searches wait, and the wait counts against the caller's timeout | | `sendOriginalQuestion` | `true` | Send the triggering human prompt, cut to 128 characters, as the Search API's `ori_question` | +| `searchBaseURL` | omitted | Search API base including its path prefix; `/search` is appended. Set alone, `searchApiKeyEnv` defaults to `DEEPSEEK_API_KEY` ([authentication](#authentication)) | +| `searchApiKeyEnv` | omitted | Search credential reference. Set alone, `searchBaseURL` defaults to `https://api.deepseek.com/v1/tools` | +| `fetchBaseURL` | omitted | Fetch API base including its path prefix; `/fetch` is appended, with the same defaults | +| `fetchApiKeyEnv` | omitted | Fetch credential reference, with the same defaults | -Fill `baseURL` with the complete API base, including its path prefix and excluding the operation name. The default reaches `https://api.deepseek.com/v1/tools/search` and `https://api.deepseek.com/v1/tools/fetch`; a deployment with another prefix, such as `/api/v1/tools`, needs its complete base. +A capability with neither field follows the conversation and reaches `/v1/tools/search` and `/v1/tools/fetch` under the origin of the selected DeepSeek model route's resolved Messages base URL, so the official `https://api.deepseek.com/anthropic`, or a `DEEPSEEK_BASE_URL` override, becomes `https://api.deepseek.com/v1/tools`, or the override's origin with `/v1/tools`. Fill an own base URL with the complete API base, including its path prefix and excluding the operation name; a deployment with another prefix, such as `/api/v1/tools`, needs its complete base. An empty field counts as unset. The own fields are YAML-only; the settings page selects providers and shows no credential controls for these providers. Stored `baseURL`, `apiKeyEnv`, and `apiKey` fields of earlier settings load without error and have no effect. Run the real API tests with `DEEPSEEK_API_KEY` and `DEEPSEEK_SEARCH_API_TEST_BASE_URL`; both are required, and the latter includes the API path prefix. ### Authentication -A Session using the `deepseek-account` route asks the account service for a token authorized for the resolved endpoint and sends it as `x-dsh-auth-token`; the public Search and Fetch routes accepted it on 2026-10-16. Otherwise a non-empty literal `apiKey` wins over `apiKeyEnv`; the reference resolves through `ctx.credentials`, or the launch environment when that service is absent. API keys use `Authorization: Bearer`. Credential resolution and endpoint selection share one settings snapshot. +Search and Fetch each resolve their endpoint and credential from one settings snapshot per operation, so a saved setting, a model-provider change, a sign-in, or a stored key applies to the next operation. The conversation's route is the `provider` of the initiating Session's latest request context; `ready()` uses the route of the model request being prepared. -Each provider's `ready()` answers whether an operation for a model request has a credential, on any model route: the request's route is `deepseek-account` and `ctx.deepseekAccount` resolves a token for the API base, or a literal `apiKey` is set or `ctx.credentials.describe` reports the `apiKeyEnv` reference configured (without that service, the launch environment must carry it). A failed account-token or credential-store read answers false. While these providers are selected, [`dsh-tool-web`](../tool-web/README.md#search-readiness) withholds `web_search` and `web_fetch` with their prompt guidance from each request for which they answer false. Only presence is checked: a wrong key counts as ready and fails each call. +- **Own settings.** A capability with a non-empty `*BaseURL` or `*ApiKeyEnv` sends only the key under its `*ApiKeyEnv` (`DEEPSEEK_API_KEY` when unset) as `Authorization: Bearer` to its `*BaseURL` (`https://api.deepseek.com/v1/tools` when unset) on every route, and never the account token. Without the key, the operation fails with a diagnostic that names the reference and the `*ApiKeyEnv` field. +- **Account conversation.** On the `deepseek-account` route, with an active, valid Loader entry of `@deepseek-ai/dsh-llm-deepseek-account` at the entry root that the model provider directory lists for `deepseek-account`, the operation sends only the token that `ctx.deepseekAccount` releases for the dispatch endpoint under that route's origin, as `x-dsh-auth-token`; the public Search and Fetch routes accepted it on 2026-10-16. Without a sign-in, the account service, or a non-empty token, or without that entry, it sends the API key below instead, to the API-key endpoint. A failed account-token read fails the operation without trying the key. An active entry whose settings the route's own resolver rejects fails the operation before any credential is read. +- **Any other conversation**, including one on `deepseek-official`, on another provider, or outside a Session, sends the key under the `apiKeyEnv` of the active `@deepseek-ai/dsh-llm-deepseek-api-key` entry the directory lists for `deepseek-official` (`DEEPSEEK_API_KEY` by default) as `Authorization: Bearer` to that route's origin, resolving its options as the route does. Without that entry, or with an inactive one, it sends `DEEPSEEK_API_KEY` to `https://api.deepseek.com/v1/tools`. An active entry whose settings the route's own resolver rejects fails each operation that would send the key, before the key is read; the operation never substitutes the official endpoint or `DEEPSEEK_API_KEY`. Without the key, the operation fails with a diagnostic that names the Models page, the reference, and the account sign-in. + +References use the [shared DeepSeek credential helpers](../../llm/llm-deepseek/README.md#account-credentials): `ctx.credentials` is authoritative, with launch-environment lookup only when that service is absent. Route and own keys receive the same whitespace normalization and format validation as model requests; malformed keys fail before dispatch. + +Each provider's `ready()` answers whether its next operation for that route has a credential, in the order the operation resolves it: on the account route, a non-empty account token, then a key that `ctx.credentials.describe` reports configured (without that service, the launch environment must carry it). A failed account-token or credential-store read, or a needed route entry whose settings do not resolve, answers false. While these providers are selected, [`dsh-tool-web`](../tool-web/README.md#search-readiness) withholds `web_search` and `web_fetch` with their prompt guidance from each request for which they answer false. Only presence is checked: a wrong key counts as ready and fails each call. ### Results and failures The Search API receives `query`, `count` (the configured count, or the request's smaller `maxResults`), `need_content: false`, and, unless `sendOriginalQuestion` is off, `ori_question`: the text of the initiating Session's latest human prompt with whitespace collapsed and cut to 128 characters. A search outside a Session or without such a prompt omits it. The provider maps `results[].snippet` as the display excerpt and `results[].summary` as the model-facing excerpt, and maps nullable `date` to optional `publishedAt`; `dsh-tool-web` caps each summary. An empty results array is successful. Fetch sends `url` and returns the original Markdown as a text body with HTTP status 200, because the API answers with an error status when the target yields no usable content. Its URL is the requested URL: the API reports no final redirected URL. -A blank or unparseable `baseURL` makes both providers unavailable, and a `count` outside 1 to 50 makes Search unavailable. Missing credentials fail with `WEB_PROVIDER_CREDENTIAL_MISSING`; cancellation uses `WEB_ABORTED`; transport, HTTP, and malformed response failures use `WEB_PROVIDER_ERROR`. Redirects are rejected before contacting their target. Non-success Fetch API statuses are retrieval failures, not target-page statuses. Account-authenticated HTTP 401 responses provide sign-in guidance; other dispatch failures identify the endpoint and the `baseURL` settings field. No automatic retry or provider fallback occurs. +An unparseable own base URL makes that capability's provider unavailable; and a `count` outside 1 to 50 makes Search unavailable. A missing credential fails with `WEB_PROVIDER_CREDENTIAL_MISSING`; a needed model route whose settings do not resolve fails with `WEB_PROVIDER_MISCONFIGURED`, naming the resolver failure, the Models page, and the capability's `*BaseURL` and `*ApiKeyEnv`; cancellation uses `WEB_ABORTED`; transport, HTTP, and malformed response failures use `WEB_PROVIDER_ERROR`. Redirects are rejected before contacting their target. Non-success Fetch API statuses are retrieval failures, not target-page statuses. Account-authenticated HTTP 401 responses invalidate only the token sent by that request and provide sign-in guidance; a newer sign-in is preserved, and credential-removal failure does not replace the HTTP error. API-key failures and HTTP 403 never invalidate an account token; other dispatch failures identify the endpoint and say where it came from: for a route endpoint they guide the user to that provider's base URL on the Models page or to the capability's `*BaseURL` and `*ApiKeyEnv`, and for own settings to correcting or removing those fields. No automatic retry or provider fallback occurs. ----- @@ -70,7 +78,7 @@ A blank or unparseable `baseURL` makes both providers unavailable, and a `count`
Implementation internals — click to expand -The [plugin](src/index.ts) reads its settings once per operation and registers both providers. The [Search provider](src/search.ts) validates response JSON before returning service results; the [Fetch provider](src/fetch.ts) validates the Markdown body. The [transport](src/transport.ts) owns credential resolution, cancellation, redirect rejection, and failure guidance; a recording hook runs before dispatch and prevents it by throwing. Search and Fetch API calls emit no auxiliary request event. Tool calls and results retain their normal Session logging. +The [plugin](src/index.ts) reads its settings once per operation, registers both providers, and turns one capability's settings snapshot, the conversation's route, and the active model route entries into its endpoint and credential. The [Search provider](src/search.ts) validates response JSON before returning service results; the [Fetch provider](src/fetch.ts) validates the Markdown body. The [transport](src/transport.ts) owns credential resolution, the choice between the account endpoint and the API-key endpoint, cancellation, redirect rejection, and failure guidance; a recording hook runs before dispatch and prevents it by throwing. Search and Fetch API calls emit no auxiliary request event. The [Messages search provider](../web-search-deepseek/README.md) owns its `web/deepseek-search-llm-request` Session event declaration and recording. Tool calls and results retain their normal Session logging. See the [web subsystem](../../../docs/subsystems/web.md) for service types and [tool-web](../tool-web/README.md) for bounded model output. @@ -118,7 +126,7 @@ A returning or removed web tool changes the declared tools as [`dsh-tool-web`](. - API query and URL constraints are enforced by the remote service. An unresolvable Fetch target returned HTTP 500 rather than the documented 404 on 2026-10-16, and its failure carries the same endpoint guidance as an endpoint failure. Fetch exposes neither the target status nor the final redirect URL, so results report HTTP 200 and the requested URL; the provider discards the API's page title. - `available()` cannot query asynchronous credential stores; `ready()` does, so model steps withhold the tools when the store lacks the key. A direct `ctx.web.search()` or `ctx.web.fetch()` call can still fail with a missing-credential error. - Search pacing limits send times within one plugin instance. Other processes and clients on the same account share the account-wide 5 QPS limit without coordination, and network jitter can still bunch arrivals; an HTTP 429 fails that search, and `dsh-tool-web` then fails the whole multi-query call. Fetch is not paced, so parallel `web_fetch` calls can exceed the Fetch limit. -- When the official route chats through a gateway, operations still send the key to the configured API base and fail unless it accepts the key. +- Route endpoints and keys follow only the Loader entries of `@deepseek-ai/dsh-llm-deepseek-api-key` and `@deepseek-ai/dsh-llm-deepseek-account` at the entry root; another package registering the same provider id, such as a test replay adapter, counts as no route, so its conversations use `DEEPSEEK_API_KEY` and the official base. ### Dev Note diff --git a/packages/web/web-deepseek-api/README.zh.md b/packages/web/web-deepseek-api/README.zh.md index 7445c77f0f..7f292c5b0f 100644 --- a/packages/web/web-deepseek-api/README.zh.md +++ b/packages/web/web-deepseek-api/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -通过 DeepSeek Search 与 Fetch API 搜索和抓取网页:每次操作只发起一次直接请求,无需辅助模型请求。插件注册 `deepseek-search-api` 和 `deepseek-fetch-api`,两者共用一份凭据和一个 API 基础地址,并使用 API key(API 密钥)或已配置的账号集成鉴权。Search 返回标题、URL、可用的 snippet 与 summary 以及可用的发布时间;Fetch 返回页面 Markdown。Messages 搜索仍由 [dsh-web-search-deepseek](../web-search-deepseek/README.zh.md) 提供;面向模型的工具由 `dsh-tool-web` 提供。 +通过 DeepSeek Search 与 Fetch API 搜索和抓取网页:每次操作只发起一次直接请求,无需辅助模型请求。插件注册 `deepseek-search-api` 和 `deepseek-fetch-api`;每个能力跟随会话的 DeepSeek 凭据:在 DeepSeek 账号模型提供方上的会话使用 DeepSeek 账号登录,其他情况使用 DeepSeek API 密钥。也可以在 YAML 中为任一能力单独配置基础地址与密钥。Search 返回标题、URL,以及可用的 snippet、summary 与发布时间;Fetch 返回页面 Markdown。面向模型的工具由 `dsh-tool-web` 提供;[旧版 Messages 搜索](../web-search-deepseek/README.zh.md)仍是独立提供方。 ## 目录 @@ -24,7 +24,7 @@ kind: "package-reference" ## 使用此包 -将插件与 `dsh-web` 一起挂载,并选择它的提供者。内置配置挂载此插件,同时选择 Messages 搜索和本地 HTTP 抓取。在**插件 > 内置插件配置 > 网页搜索**中开启**启用新版 Web 搜索 API**并保存,会同时选择此包的两个提供者;关闭后恢复内置选择。保存的选择从下一次操作开始生效。 +将插件与 `dsh-web` 一起挂载,并选择它的提供方。内置配置挂载此插件,为搜索选择它的 Search API(`deepseek-search-api`),为抓取选择本地 HTTP 抓取。在**插件 > 内置插件配置 > 网页搜索与获取**中,在**搜索**下拉框选择 **DeepSeek Search API** 会选中 `deepseek-search-api`,在**获取**下拉框选择 **DeepSeek Fetch API** 会选中 `deepseek-fetch-api`;两个下拉框分别保存,选择其他条目即改用其他提供方。保存的选择从下一次操作开始生效。 ```yaml - name: '@deepseek-ai/dsh-web' @@ -33,34 +33,42 @@ kind: "package-reference" fetchProvider: deepseek-fetch-api - name: '@deepseek-ai/dsh-web-deepseek-api' config: - apiKeyEnv: DEEPSEEK_API_KEY + searchBaseURL: https://search.example.test/api/v1/tools + searchApiKeyEnv: DEEPSEEK_SEARCH_API_KEY ``` | 字段 | 默认值 | 含义 | |---|---|---| -| `apiKey` | 省略 | 字面 API 密钥;建议使用凭据引用 | -| `apiKeyEnv` | `DEEPSEEK_API_KEY` | 每次操作解析的凭据引用 | -| `baseURL` | `https://api.deepseek.com/v1/tools` | 包含部署路径前缀的 Search 与 Fetch API 基础地址;追加 `/search` 或 `/fetch` | | `count` | `10` | Search API 每次搜索的结果数,为 1 至 50 的整数;请求中更小的 `maxResults` 优先 | | `searchRequestsPerSecond` | `5` | 本插件实例在任意一秒窗口内最多发送的 Search 请求数,与服务的账号级 5 QPS 限制一致;后续搜索排队等待,等待时间计入调用方的超时 | | `sendOriginalQuestion` | `true` | 将触发搜索的用户提示截取至 128 个字符,作为 Search API 的 `ori_question` 发送 | +| `searchBaseURL` | 省略 | Search API 基础地址,包含路径前缀;追加 `/search`。只设置此项时,`searchApiKeyEnv` 默认为 `DEEPSEEK_API_KEY`([鉴权](#authentication)) | +| `searchApiKeyEnv` | 省略 | Search 凭据引用。只设置此项时,`searchBaseURL` 默认为 `https://api.deepseek.com/v1/tools` | +| `fetchBaseURL` | 省略 | Fetch API 基础地址,包含路径前缀;追加 `/fetch`,默认值规则相同 | +| `fetchApiKeyEnv` | 省略 | Fetch 凭据引用,默认值规则相同 | -`baseURL` 填写完整的 API 基础地址,包含路径前缀,但不包含操作名。默认值请求 `https://api.deepseek.com/v1/tools/search` 和 `https://api.deepseek.com/v1/tools/fetch`;使用其他前缀(例如 `/api/v1/tools`)的部署需要填写其完整基础地址。 +两个字段都未设置的能力跟随会话,请求所选 DeepSeek 模型路由解析后的 Messages 基础地址的 origin 下的 `/v1/tools/search` 与 `/v1/tools/fetch`,因此官方 `https://api.deepseek.com/anthropic` 或 `DEEPSEEK_BASE_URL` 覆盖值分别得到 `https://api.deepseek.com/v1/tools`,或覆盖值的 origin 加 `/v1/tools`。单独配置的基础地址填写完整的 API 基础地址,包含路径前缀,但不包含操作名;使用其他前缀(例如 `/api/v1/tools`)的部署需要填写其完整基础地址。空字段视为未设置。这些单独配置的字段只能在 YAML 中设置;设置页只选择提供方,不显示这两个提供方的凭据控件。早期设置中保存的 `baseURL`、`apiKeyEnv` 与 `apiKey` 字段可以正常加载,但不再生效。 真实 API 测试要求同时设置 `DEEPSEEK_API_KEY` 和 `DEEPSEEK_SEARCH_API_TEST_BASE_URL`,后者包含 API 路径前缀。 ### 鉴权 -使用 `deepseek-account` 路由的会话向账号服务请求已授权用于解析后端点的令牌,并通过 `x-dsh-auth-token` 发送;2026-10-16 公网 Search 与 Fetch 路由已接受该令牌。其他情况下,非空的字面 `apiKey` 优先于 `apiKeyEnv`;凭据引用通过 `ctx.credentials` 解析,该服务缺省时使用启动环境。API 密钥使用 `Authorization: Bearer`。凭据解析和端点选择使用同一份设置快照。 +Search 与 Fetch 每次操作各自从同一份设置快照解析端点和凭据,因此保存的设置、模型提供方变更、登录或存入的密钥从下一次操作开始生效。会话的路由是发起会话最近一次请求上下文的 `provider`;`ready()` 使用正在准备的模型请求的路由。 -每个提供者的 `ready()` 回答模型请求将发起的操作是否有凭据,不论模型路由:该请求的路由是 `deepseek-account` 且 `ctx.deepseekAccount` 为 API 基础地址解析出令牌,或者设置了字面 `apiKey`,或 `ctx.credentials.describe` 报告 `apiKeyEnv` 引用已配置(没有该服务时,启动环境必须包含它)。账号令牌或凭据存储读取失败时回答 false。这两个提供者被选中时,[`dsh-tool-web`](../tool-web/README.zh.md#search-readiness) 会从它们回答 false 的每个请求中撤下 `web_search` 与 `web_fetch` 及其提示词指引。只检查是否存在:错误的密钥仍算就绪,并使每次调用失败。 +- **单独配置。** 设置了非空 `*BaseURL` 或 `*ApiKeyEnv` 的能力在任何路由上都只以 `Authorization: Bearer` 把其 `*ApiKeyEnv`(未设置时为 `DEEPSEEK_API_KEY`)下的密钥发往其 `*BaseURL`(未设置时为 `https://api.deepseek.com/v1/tools`),从不发送账号令牌。没有该密钥时,操作失败,诊断信息点名该引用和 `*ApiKeyEnv` 字段。 +- **账号会话。** 在 `deepseek-account` 路由上,若模型提供方目录为 `deepseek-account` 列出了入口根部活动且有效的 `@deepseek-ai/dsh-llm-deepseek-account` Loader 条目,操作只发送 `ctx.deepseekAccount` 为该路由 origin 下的请求端点给出的令牌,通过 `x-dsh-auth-token` 发送;2026-10-16 公网 Search 与 Fetch 路由已接受该令牌。没有登录、账号服务或非空令牌,或没有该条目时,改为把下文的 API 密钥发往 API 密钥端点。账号令牌读取失败时操作失败,不再尝试密钥。条目活动但其设置被该路由自身的解析器拒绝时,操作在读取任何凭据前失败。 +- **其他会话**,包括 `deepseek-official` 上的会话、其他提供方上的会话以及会话之外的操作,以 `Authorization: Bearer` 把目录为 `deepseek-official` 列出的活动 `@deepseek-ai/dsh-llm-deepseek-api-key` 条目的 `apiKeyEnv`(默认 `DEEPSEEK_API_KEY`)下的密钥发往该路由的 origin,并按该路由的方式解析其选项。没有该条目或条目未激活时,把 `DEEPSEEK_API_KEY` 发往 `https://api.deepseek.com/v1/tools`。条目活动但其设置被该路由自身的解析器拒绝时,每个需要发送该密钥的操作都在读取密钥前失败,绝不改用官方端点或 `DEEPSEEK_API_KEY`。没有密钥时,操作失败,诊断信息点名模型页面、该引用和账号登录。 + +引用使用[共享 DeepSeek 凭据函数](../../llm/llm-deepseek/README.zh.md#account-credentials):`ctx.credentials` 具有权威性,仅在该服务缺席时读取启动环境。路由密钥与单独配置的密钥均接受与模型请求相同的首尾空白规范化和格式校验;格式错误在发送请求前失败。 + +每个提供者的 `ready()` 按操作的解析顺序回答其在该路由上的下一次操作是否有凭据:在账号路由上先看非空账号令牌,再看 `ctx.credentials.describe` 报告已配置的密钥(没有该服务时,启动环境必须包含它)。账号令牌或凭据存储读取失败,或所需路由条目的设置无法解析时,回答 false。这两个提供者被选中时,[`dsh-tool-web`](../tool-web/README.zh.md#search-readiness) 会从它们回答 false 的每个请求中撤下 `web_search` 与 `web_fetch` 及其提示词指引。只检查是否存在:错误的密钥仍算就绪,并使每次调用失败。 ### 结果与失败 Search API 接收 `query`、`count`(配置的结果数,或请求中更小的 `maxResults`)、`need_content: false`,以及在 `sendOriginalQuestion` 未关闭时的 `ori_question`:发起会话中最近一条用户提示的文本,空白折叠后截取至 128 个字符。在会话之外或没有此类提示的搜索不发送该字段。提供者将 `results[].snippet` 映射为展示用摘录,将 `results[].summary` 映射为面向模型的摘录,并将可为 null 的 `date` 映射为可选的 `publishedAt`;`dsh-tool-web` 限制每条摘要的长度。空结果数组表示成功。Fetch 发送 `url`,以文本正文返回原始 Markdown,并报告 HTTP 状态 200,因为目标没有可用内容时 API 会返回错误状态。返回 URL 为请求 URL:API 不报告重定向后的最终 URL。 -`baseURL` 为空白或无法解析时,两个提供者都不可用;`count` 不在 1 至 50 之间时,Search 不可用。凭据缺失以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败;取消使用 `WEB_ABORTED`;传输、HTTP 和响应格式错误使用 `WEB_PROVIDER_ERROR`。重定向在联系目标前即被拒绝。Fetch API 的非成功状态表示抓取失败,不代表目标页面的状态。账号鉴权的 HTTP 401 响应提供重新登录指引;其他请求失败指出端点及 `baseURL` 设置字段。不会自动重试或回退至其他提供者。 +单独配置的基础地址无法解析时,该能力的提供者不可用;`count` 不在 1 至 50 之间时,Search 不可用。凭据缺失以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败;所需模型路由的设置无法解析时以 `WEB_PROVIDER_MISCONFIGURED` 失败,并点名解析器的失败原因、模型页面以及该能力的 `*BaseURL` 与 `*ApiKeyEnv`;取消使用 `WEB_ABORTED`;传输、HTTP 和响应格式错误使用 `WEB_PROVIDER_ERROR`。重定向在联系目标前即被拒绝。Fetch API 的非成功状态表示抓取失败,不代表目标页面的状态。账号鉴权的 HTTP 401 响应只作废该请求实际发送的 token,并提供重新登录指引;更新的登录不受影响,凭据删除失败不会替换原 HTTP 错误。API 密钥失败与 HTTP 403 从不作废账号 token;其他请求失败指出端点及其来源:路由端点指导用户在模型页面检查该提供方的基础地址,或设置该能力的 `*BaseURL` 与 `*ApiKeyEnv`;单独配置指导用户修改或删除这两个字段。不会自动重试或回退至其他提供者。 ----- @@ -70,7 +78,7 @@ Search API 接收 `query`、`count`(配置的结果数,或请求中更小的
实现细节 — 点击展开 -[插件](src/index.ts)在每次操作时读取一次设置,并注册两个提供者。[Search 提供者](src/search.ts)在返回服务结果前校验响应 JSON;[Fetch 提供者](src/fetch.ts)校验 Markdown 正文。[传输层](src/transport.ts)负责凭据解析、取消、重定向拒绝和失败指引;记录钩子在发送前运行,抛出异常即阻止发送。Search 与 Fetch API 调用不产生辅助请求事件。工具调用和结果仍按常规记录到会话。 +[插件](src/index.ts)在每次操作时读取一次设置,注册两个提供者,并把一个能力的设置快照、会话的路由和活动的模型路由条目转换为其端点和凭据。[Search 提供者](src/search.ts)在返回服务结果前校验响应 JSON;[Fetch 提供者](src/fetch.ts)校验 Markdown 正文。[传输层](src/transport.ts)负责凭据解析、在账号端点与 API 密钥端点之间选择、取消、重定向拒绝和失败指引;记录钩子在发送前运行,抛出异常即阻止发送。Search 与 Fetch API 调用不产生辅助请求事件。[Messages 搜索提供方](../web-search-deepseek/README.zh.md)负责声明和记录自身的 `web/deepseek-search-llm-request` 会话事件。工具调用和结果仍按常规记录到会话。 服务类型见 [Web 子系统](../../../docs/subsystems/web.zh.md),受限的模型输出见 [tool-web](../tool-web/README.zh.md)。 @@ -118,7 +126,7 @@ Search API 接收 `query`、`count`(配置的结果数,或请求中更小的 - 查询和 URL 约束由远端服务执行。2026-10-16 抓取无法解析的目标域名时返回 HTTP 500,而非文档所述的 404,其失败信息与端点失败附带相同的端点指引。Fetch 既不暴露目标状态,也不暴露最终重定向 URL,因此结果报告 HTTP 200 和请求 URL;提供者丢弃 API 返回的页面标题。 - `available()` 无法查询异步凭据存储;`ready()` 可以,因此凭据存储缺少密钥时,模型步骤会撤下这两个工具。直接调用 `ctx.web.search()` 或 `ctx.web.fetch()` 仍可能因凭据缺失失败。 - Search 限速只约束单个插件实例内的发送时间。同一账号下的其他进程和客户端共享账号级 5 QPS 限制,彼此不协调,网络抖动也可能使请求集中到达;HTTP 429 会使该次搜索失败,随后 `dsh-tool-web` 会使整个多查询调用失败。Fetch 不限速,因此并行的 `web_fetch` 调用可能超过 Fetch 限制。 -- 官方路由经网关对话时,操作仍把密钥发往配置的 API 基础地址,除非该地址接受这个密钥,否则调用失败。 +- 路由端点与密钥只认入口根部的 `@deepseek-ai/dsh-llm-deepseek-api-key` 与 `@deepseek-ai/dsh-llm-deepseek-account` Loader 条目;以相同提供方 id 注册的其他包(例如测试回放适配器)视为没有路由,其会话使用 `DEEPSEEK_API_KEY` 与官方基础地址。 ### 开发备注 diff --git a/packages/web/web-deepseek-api/package.json b/packages/web/web-deepseek-api/package.json index ebaf9e867e..942ee5ddcc 100644 --- a/packages/web/web-deepseek-api/package.json +++ b/packages/web/web-deepseek-api/package.json @@ -33,9 +33,14 @@ "@deepseek-ai/dsh-credentials": "workspace:*", "@deepseek-ai/dsh-deepseek-account": "workspace:*", "@deepseek-ai/dsh-launch-environment": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-web": "workspace:*", - "@deepseek-ai/cordis": "workspace:~" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-account": "workspace:*" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~" @@ -46,11 +51,17 @@ "@deepseek-ai/dsh-credentials-local": "workspace:*", "@deepseek-ai/dsh-deepseek-account": "workspace:*", "@deepseek-ai/dsh-launch-environment": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-web": "workspace:*", "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-http-proxy": "workspace:*" + "@deepseek-ai/dsh-http-proxy": "workspace:*", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek-account": "workspace:*", + "@deepseek-ai/dsh-app-boot": "workspace:*" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-deepseek-account": { diff --git a/packages/web/web-deepseek-api/src/fetch.ts b/packages/web/web-deepseek-api/src/fetch.ts index e92b1e6678..354b4a1f9d 100644 --- a/packages/web/web-deepseek-api/src/fetch.ts +++ b/packages/web/web-deepseek-api/src/fetch.ts @@ -4,19 +4,22 @@ * @module @deepseek-ai/dsh-web-deepseek-api/fetch */ import type { WebFetchProvider, WebFetchRequest, WebFetchResult } from '@deepseek-ai/dsh-web' -import { DEEPSEEK_WEB_API_GUIDANCE } from './search.ts' import { dispatchDeepSeekWeb, hasDeepSeekWebCredential, invalidResponse, isRecord, - type DeepSeekWebAuth, + type DeepSeekWebAuth, type DeepSeekWebGuidance, } from './transport.ts' /** Stable id the Fetch API provider registers under in the fetch registry. */ export const DEEPSEEK_FETCH_API_PROVIDER_ID = 'deepseek-fetch-api' /** Authentication and endpoint settings snapshotted for one fetch. */ -export interface DeepSeekFetchApiOptions extends DeepSeekWebAuth { - /** API base including its path prefix; `/fetch` is appended. */ +export interface DeepSeekFetchApiOptions { + /** API base the API key is sent to, including its path prefix; `/fetch` is appended. An account token goes to its own base. */ baseURL: string + /** Authentication sources of the fetch. */ + auth: DeepSeekWebAuth + /** Settings that endpoint failures of the fetch name. */ + guidance: DeepSeekWebGuidance } /** @@ -26,25 +29,37 @@ export interface DeepSeekFetchApiOptions extends DeepSeekWebAuth { */ export class DeepSeekFetchApiProvider implements WebFetchProvider { readonly id = DEEPSEEK_FETCH_API_PROVIDER_ID + /** Picker description: what this provider suits and what it costs or cannot do. */ + readonly description: NonNullable = { + en: 'Fetches public URLs on DeepSeek servers and returns Markdown. Uses the DeepSeek account of the current conversation, or a DeepSeek API key, may incur API charges, and cannot reach intranet or loopback addresses.', + zh: '由 DeepSeek 服务端抓取公网页面并返回 Markdown。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费,且无法访问内网和本机', + ja: 'DeepSeek のサーバーで公開 URL を取得し、Markdown を返します。現在の会話の DeepSeek アカウント、または DeepSeek API キーを使用し、API 料金が発生する場合があり、イントラネットやループバックのアドレスにはアクセスできません。', + es: 'Obtiene URL públicas en los servidores de DeepSeek y devuelve Markdown. Usa la cuenta de DeepSeek de la conversación actual o una clave de API de DeepSeek, puede generar cargos de API y no puede acceder a direcciones de la intranet ni de bucle invertido.', + fr: "Récupère les URL publiques sur les serveurs DeepSeek et renvoie du Markdown. Utilise le compte DeepSeek de la conversation en cours ou une clé API DeepSeek, peut entraîner des frais d'API et ne peut pas atteindre les adresses intranet ou de bouclage.", + de: 'Ruft öffentliche URLs auf DeepSeek-Servern ab und gibt Markdown zurück. Verwendet das DeepSeek-Konto der aktuellen Unterhaltung oder einen DeepSeek-API-Schlüssel, kann API-Kosten verursachen und erreicht keine Intranet- oder Loopback-Adressen.', + ru: 'Загружает публичные URL на серверах DeepSeek и возвращает Markdown. Использует учётную запись DeepSeek текущего разговора или ключ API DeepSeek, возможна оплата API; адреса интрасети и loopback недоступны.', + pt: 'Obtém URLs públicas nos servidores da DeepSeek e retorna Markdown. Usa a conta DeepSeek da conversa atual ou uma chave de API da DeepSeek, pode gerar cobranças de API e não alcança endereços da intranet nem de loopback.', + } /** @param resolveOptions - the options for the NEXT fetch, read once at each fetch's entry. */ constructor(private readonly resolveOptions: () => DeepSeekFetchApiOptions) {} available(): boolean { const options = this.resolveOptions() - return hasDeepSeekWebCredential(options) && URL.canParse(options.baseURL) + return hasDeepSeekWebCredential(options.auth) && URL.canParse(options.baseURL) } fetch(request: WebFetchRequest, signal?: AbortSignal): Promise { const options = this.resolveOptions() - return dispatchDeepSeekWeb(options, { - endpoint: `${options.baseURL.replace(/\/+$/u, '')}/fetch`, + return dispatchDeepSeekWeb(options.auth, { + baseURL: options.baseURL, + path: '/fetch', body: { url: request.url }, decode: (payload): WebFetchResult => { if (!isRecord(payload) || typeof payload.content !== 'string') throw invalidResponse() return { url: request.url, statusCode: 200, body: { kind: 'text', content: payload.content }, truncated: false } }, - guidance: { subject: 'Fetch', ...DEEPSEEK_WEB_API_GUIDANCE }, + guidance: options.guidance, }, signal) } } diff --git a/packages/web/web-deepseek-api/src/index.ts b/packages/web/web-deepseek-api/src/index.ts index fb71b0437d..e0ce59b902 100644 --- a/packages/web/web-deepseek-api/src/index.ts +++ b/packages/web/web-deepseek-api/src/index.ts @@ -1,24 +1,39 @@ /** * Register the DeepSeek Search and Fetch APIs in `ctx.web` as `deepseek-search-api` * and `deepseek-fetch-api`. Each operation is one direct API request without an - * auxiliary model request; both providers read this section's credential and API - * base. `web.searchProvider` and `web.fetchProvider` select them. A Session on the - * DeepSeek account route authenticates with the account token when the account - * service allows the endpoint; every other request uses the configured key. Both - * providers report themselves not ready for a model request whose operation would - * have no credential. + * auxiliary model request. `web.searchProvider` and `web.fetchProvider` select them. Each capability follows the + * DeepSeek credential of the conversation: a Session whose latest model request used the `deepseek-account` route sends + * the account token to that route's origin, and every other operation sends the API key of the DeepSeek API-key route + * to that route's origin, or `DEEPSEEK_API_KEY` to the official base without that route. An operation that needs a + * mounted route whose settings do not resolve fails instead of changing its endpoint or credential. A capability with + * its own `*BaseURL` or `*ApiKeyEnv` uses only that endpoint and key instead + * ([rationale](../../../../.agents/notes/implemented/architecture/2026-10-29-web-provider-selection.md#credential-resolution)). + * Both providers report themselves not ready when their next operation would have no credential. * @module @deepseek-ai/dsh-web-deepseek-api */ -import type { Context, Volatile } from '@deepseek-ai/cordis' +import { FiberState, type Context, type Volatile } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/cordis-plugin-loader' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent' +import { credentialRef, type CredentialRef } from '@deepseek-ai/dsh-credentials' +import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' +import type {} from '@deepseek-ai/dsh-llm' +import { + deepSeekApiKeyConfigured, rejectDeepSeekAccountToken, resolveDeepSeekAccountToken, resolveDeepSeekApiKey, +} from '@deepseek-ai/dsh-llm-deepseek' +import { + plainAccountOptions, resolveAccountOptions, type Config as AccountRouteConfig, +} from '@deepseek-ai/dsh-llm-deepseek-account' +import { + plainOptions as plainOfficialOptions, resolveAdapterOptions as resolveOfficialOptions, type Config as OfficialRouteConfig, +} from '@deepseek-ai/dsh-llm-deepseek-api-key' import type { Session } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-web' import { DeepSeekFetchApiProvider } from './fetch.ts' import { DEEPSEEK_DEFAULT_COUNT, DEEPSEEK_SEARCH_REQUESTS_PER_SECOND, DEEPSEEK_WEB_API_BASE_URL, DeepSeekSearchApiProvider, } from './search.ts' -import { deepSeekWebAuth, deepSeekWebCredentialReady } from './transport.ts' +import type { DeepSeekWebAuth, DeepSeekWebGuidance } from './transport.ts' export { DEEPSEEK_FETCH_API_PROVIDER_ID, DeepSeekFetchApiProvider } from './fetch.ts' export type { DeepSeekFetchApiOptions } from './fetch.ts' @@ -34,14 +49,13 @@ export const name = 'web-deepseek-api' /** The web seam both providers register into. */ export const inject = ['web'] -/** Settings read by the next search or fetch without re-registering either provider. */ +/** + * Settings read by the next search or fetch without re-registering either provider. A non-empty `*BaseURL` or + * `*ApiKeyEnv` gives that capability its own endpoint and key; with neither, it follows the conversation's DeepSeek + * credential. Stored fields of earlier settings (`baseURL`, `apiKeyEnv`, `apiKey`) are not schema fields: they load + * without error and have no effect. + */ export interface Config { - /** Literal DeepSeek API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ - apiKey: Volatile - /** Credential reference resolved for each operation; defaults to `DEEPSEEK_API_KEY`. */ - apiKeyEnv: Volatile - /** API base including its path prefix; `/search` and `/fetch` are appended. */ - baseURL: Volatile /** Search API results per search, from 1 through 50; a smaller request bound wins. Defaults to 10. */ count: Volatile /** @@ -52,17 +66,79 @@ export interface Config { searchRequestsPerSecond: Volatile /** Send the triggering user question, bounded to 128 characters, as `ori_question`. Defaults to true. */ sendOriginalQuestion: Volatile + /** + * Own Search API base including its path prefix; `/search` is appended. With only {@link searchApiKeyEnv} set, the + * official base applies. + */ + searchBaseURL: Volatile + /** Own Search API credential reference. With only {@link searchBaseURL} set, `DEEPSEEK_API_KEY` applies. */ + searchApiKeyEnv: Volatile + /** + * Own Fetch API base including its path prefix; `/fetch` is appended. With only {@link fetchApiKeyEnv} set, the + * official base applies. + */ + fetchBaseURL: Volatile + /** Own Fetch API credential reference. With only {@link fetchBaseURL} set, `DEEPSEEK_API_KEY` applies. */ + fetchApiKeyEnv: Volatile } export const Config = z.object({ - apiKey: z.string().role('secret').volatile(), - apiKeyEnv: z.string().role('credential-ref').default('DEEPSEEK_API_KEY').volatile(), - baseURL: z.string().default(DEEPSEEK_WEB_API_BASE_URL).volatile(), count: z.number().step(1).min(1).max(50).default(DEEPSEEK_DEFAULT_COUNT).volatile(), searchRequestsPerSecond: z.number().step(1).min(1).default(DEEPSEEK_SEARCH_REQUESTS_PER_SECOND).volatile(), sendOriginalQuestion: z.boolean().default(true).volatile(), + searchBaseURL: z.string().volatile(), + searchApiKeyEnv: z.string().role('credential-ref').volatile(), + fetchBaseURL: z.string().volatile(), + fetchApiKeyEnv: z.string().role('credential-ref').volatile(), }) +/** A capability that resolves its endpoint and credential independently of the other. */ +type Capability = 'search' | 'fetch' + +/** Endpoint and credential resolved for one operation or readiness check of one capability. */ +type Plan = { capability: Capability } & ( + /** The capability's own endpoint and key; never the account token. */ + | { kind: 'custom'; baseURL: string; key: CredentialRef } + /** + * The conversation's DeepSeek credential: the account token on {@link account} when one is released, otherwise the + * key. {@link invalid} names a mounted route whose settings do not resolve; the operation fails when it would use it. + */ + | { + kind: 'session' + baseURL: string + key: CredentialRef + account?: { baseURL: string } + invalid?: { route: keyof typeof ROUTE_PACKAGES; reason: string } + } +) + +/** A mounted, active model route whose settings its own resolver rejects. */ +interface InvalidRoute { + /** Resolver failure message. */ + invalid: string +} + +/** Path prefix of the Search and Fetch APIs under a DeepSeek origin. */ +const TOOLS_PATH = '/v1/tools' + +/** Credential reference of the official DeepSeek API key. */ +const DEEPSEEK_API_KEY = credentialRef('DEEPSEEK_API_KEY') + +/** Provider id of the DeepSeek account route. */ +const ACCOUNT_PROVIDER = 'deepseek-account' + +/** Loader package that registers each model route. */ +const ROUTE_PACKAGES = { + 'deepseek-official': '@deepseek-ai/dsh-llm-deepseek-api-key', + 'deepseek-account': '@deepseek-ai/dsh-llm-deepseek-account', +} as const + +/** Display and configuration names of each capability. */ +const CAPABILITY = { + search: { subject: 'Search', prefix: 'search' }, + fetch: { subject: 'Fetch', prefix: 'fetch' }, +} as const satisfies Record + /** * Read the text of the latest human prompt, the question that triggered the * current turn. Injected user-role context carries another source kind. @@ -76,31 +152,223 @@ function latestUserQuestion(session: Session | undefined): string | undefined { } /** - * Register the Search API and Fetch API providers over one live settings section. Each provider's - * `ready()` answers whether an operation for a model request on the given route would have a - * credential to send. + * Register the Search API and Fetch API providers over one live settings section. Each operation reads one settings + * snapshot and the route of the initiating Session's latest model request; each `ready()` call reads one settings + * snapshot and the route of the model request being prepared. Both resolve the same plan. * @param ctx - plugin context owning both registrations. * @param config - live settings, read once per operation. */ export function apply(ctx: Context, config: Config): void { - const keyConfig = () => ({ apiKey: config.apiKey.get(), apiKeyEnv: config.apiKeyEnv.get() }) - const auth = () => deepSeekWebAuth(ctx, keyConfig()) - const ready = (llmProvider: string | undefined) => - deepSeekWebCredentialReady(ctx, { ...keyConfig(), baseURL: config.baseURL.get() }, llmProvider) + const own = (capability: Capability) => capability === 'search' + ? { baseURL: config.searchBaseURL.get(), ref: config.searchApiKeyEnv.get() } + : { baseURL: config.fetchBaseURL.get(), ref: config.fetchApiKeyEnv.get() } + const plan = (capability: Capability, llmProvider: string | undefined) => + resolvePlan(ctx, capability, own(capability), llmProvider) + const target = (capability: Capability) => { + const resolved = plan(capability, ctx.get('agents')?.currentInitiator()?.session.requestContext()?.provider) + return { baseURL: resolved.baseURL, auth: planAuth(ctx, resolved), guidance: planGuidance(resolved) } + } + const ready = (capability: Capability) => (llmProvider: string | undefined): Promise => + planReady(ctx, plan(capability, llmProvider)) const searchApi = new DeepSeekSearchApiProvider(() => ({ - ...auth(), - baseURL: config.baseURL.get(), + ...target('search'), count: config.count.get(), requestsPerSecond: config.searchRequestsPerSecond.get(), ...config.sendOriginalQuestion.get() ? { originalQuestion: () => latestUserQuestion(ctx.get('agents')?.currentInitiator()?.session), } : {}, })) - const fetchApi = new DeepSeekFetchApiProvider(() => ({ ...auth(), baseURL: config.baseURL.get() })) + const fetchApi = new DeepSeekFetchApiProvider(() => target('fetch')) ctx.web.registerSearchProvider({ - id: searchApi.id, available: () => searchApi.available(), ready, search: (request, signal) => searchApi.search(request, signal), + id: searchApi.id, + description: searchApi.description, + available: () => searchApi.available(), + ready: ready('search'), + search: (request, signal) => searchApi.search(request, signal), }) ctx.web.registerFetchProvider({ - id: fetchApi.id, available: () => fetchApi.available(), ready, fetch: (request, signal) => fetchApi.fetch(request, signal), + id: fetchApi.id, + description: fetchApi.description, + available: () => fetchApi.available(), + ready: ready('fetch'), + fetch: (request, signal) => fetchApi.fetch(request, signal), }) } + +/** + * Resolve the endpoint and credential of one capability without reading credential values. Route endpoints use the + * origin of the route's resolved Messages base URL with `/v1/tools`. + * @param ctx - context with the model and loader services. + * @param capability - the capability being resolved. + * @param own - the capability's own base URL and credential reference; empty values count as unset. + * @param llmProvider - provider route of the model request the operation serves. + * @returns the plan. + */ +function resolvePlan( + ctx: Context, capability: Capability, own: { baseURL: string | undefined; ref: string | undefined }, llmProvider: string | undefined, +): Plan { + const ownBaseURL = nonEmpty(own.baseURL), ownRef = nonEmpty(own.ref) + if (ownBaseURL !== undefined || ownRef !== undefined) { + return { + capability, kind: 'custom', baseURL: ownBaseURL ?? DEEPSEEK_WEB_API_BASE_URL, + key: ownRef === undefined ? DEEPSEEK_API_KEY : credentialRef(ownRef), + } + } + const official = officialRoute(ctx) + const account = llmProvider === ACCOUNT_PROVIDER ? accountRoute(ctx) : undefined + // An invalid route keeps the official base only as an unused placeholder: the transport fails before it would send. + const officialValid = official !== undefined && !('invalid' in official) ? official : undefined + const invalid: { route: keyof typeof ROUTE_PACKAGES; reason: string } | undefined = + account !== undefined && 'invalid' in account ? { route: ACCOUNT_PROVIDER, reason: account.invalid } + : official !== undefined && 'invalid' in official ? { route: 'deepseek-official', reason: official.invalid } + : undefined + return { + capability, kind: 'session', + baseURL: officialValid === undefined ? DEEPSEEK_WEB_API_BASE_URL : toolsBase(officialValid.baseURL), + key: officialValid?.apiKeyEnv ?? DEEPSEEK_API_KEY, + ...account === undefined || 'invalid' in account ? {} : { account: { baseURL: toolsBase(account.baseURL) } }, + ...invalid === undefined ? {} : { invalid }, + } +} + +/** + * Build the authentication sources of one planned operation without reading secret values. + * @param ctx - context with the account, credential, and launch-environment services. + * @param plan - the operation's plan. + * @returns the sources the transport resolves. + */ +function planAuth(ctx: Context, plan: Plan): DeepSeekWebAuth { + return { + ...plan.kind === 'session' && plan.account !== undefined ? { + account: { + baseURL: plan.account.baseURL, + resolveToken: endpoint => resolveDeepSeekAccountToken(ctx, endpoint), + rejectToken: token => rejectDeepSeekAccountToken(ctx, token), + }, + } : {}, + resolveApiKey: () => resolveDeepSeekApiKey(ctx, plan.key), + apiKeyEnv: plan.key, + missingKeyMessage: missingKeyMessage(plan), + ...plan.kind === 'session' && plan.invalid !== undefined ? { + invalidRoute: { + stage: plan.invalid.route === ACCOUNT_PROVIDER ? 'account' : 'api-key', + message: invalidRouteMessage(plan.capability, plan.invalid.route, plan.invalid.reason), + }, + } : {}, + } +} + +/** + * Whether an operation planned as `plan` has a credential, checked in the order {@link planAuth} resolves them. A + * failed read, or a needed route whose settings do not resolve, answers false, because the operation fails on the same + * step. Only presence is checked: the key's value + * is not read and the endpoint is not contacted, so a wrong key still counts as configured. + * @param ctx - context with the account, credential, and launch-environment services. + * @param plan - the operation's plan. + * @returns whether the operation has a credential to send. + */ +async function planReady(ctx: Context, plan: Plan): Promise { + if (plan.kind === 'session' && plan.invalid?.route === ACCOUNT_PROVIDER) return false + if (plan.kind === 'session' && plan.account !== undefined) { + let token: string | undefined + try { token = await resolveDeepSeekAccountToken(ctx, plan.account.baseURL) } + catch (_unreadableGrant) { return false /* The operation fails on this read before it tries the API key. */ } + if (token !== undefined && token.length > 0) return true + } + if (plan.kind === 'session' && plan.invalid !== undefined) return false + try { return await deepSeekApiKeyConfigured(ctx, plan.key) } + catch (_unreadableStore) { return false /* A store that cannot be read supplies no key to the operation either. */ } +} + +/** Settings named by endpoint failures of one planned operation. */ +function planGuidance(plan: Plan): DeepSeekWebGuidance { + const { subject, prefix } = CAPABILITY[plan.capability] + return { + subject, plugin: name, custom: plan.kind === 'custom', baseURLField: `${prefix}BaseURL`, apiKeyEnvField: `${prefix}ApiKeyEnv`, + } +} + +/** Model-facing diagnostic for an operation that has no credential to send. */ +function missingKeyMessage(plan: Plan): string { + const { subject, prefix } = CAPABILITY[plan.capability] + const ref = JSON.stringify(plan.key) + if (plan.kind === 'custom') { + return `DeepSeek ${subject} API sends the API key under ${ref} to its own endpoint, and no key is configured for ${ref}.` + + ` Guide the user to export ${ref} in the launching environment, or to correct ${name}.${prefix}ApiKeyEnv.` + } + return `DeepSeek ${subject} API uses the DeepSeek API key, and no key is configured for ${ref}. Guide the user to add the` + + ` DeepSeek API key on the Models page or export ${ref} in the launching environment, or to sign in to DeepSeek and` + + ' chat on the DeepSeek account model provider.' +} + +/** + * Model-facing diagnostic for an operation that would use a mounted model route whose settings do not resolve. + * @param capability - the capability that stopped. + * @param route - the route whose settings failed. + * @param reason - the route resolver's failure message. + * @returns the diagnostic naming the route and both recoveries. + */ +function invalidRouteMessage(capability: Capability, route: keyof typeof ROUTE_PACKAGES, reason: string): string { + const { subject, prefix } = CAPABILITY[capability] + const provider = route === ACCOUNT_PROVIDER ? 'DeepSeek account model provider' : 'DeepSeek API-key model provider' + const credential = route === ACCOUNT_PROVIDER ? 'the DeepSeek account sign-in' : 'that provider\'s API key' + return `DeepSeek ${subject} API sends ${credential} to the origin of the ${provider}, and that provider's settings do not` + + ` resolve: ${reason}. ${subject} did not send the request. Guide the user to correct that provider's settings on the` + + ` Models page or in the deployment configuration, or to set ${name}.${prefix}BaseURL and ${name}.${prefix}ApiKeyEnv` + + ` so ${subject} uses its own endpoint.` +} + +/** + * Read the active DeepSeek API-key model route. + * @returns its Messages base URL and credential reference, the resolver failure when its settings do not resolve, or + * undefined when none is mounted and active. + */ +function officialRoute(ctx: Context): { baseURL: string; apiKeyEnv: CredentialRef } | InvalidRoute | undefined { + const config = routeConfig(ctx, 'deepseek-official') as OfficialRouteConfig | undefined + if (config === undefined) return undefined + try { + const resolved = resolveOfficialOptions(plainOfficialOptions(config), launchEnvironmentOf(ctx)) + return { baseURL: resolved.baseURL, apiKeyEnv: resolved.apiKeyEnv } + } catch (error: unknown) { return { invalid: errorMessage(error) } } +} + +/** + * Read the active DeepSeek account model route. + * @returns its Messages base URL, the resolver failure when its settings do not resolve, or undefined when none is + * mounted and active. + */ +function accountRoute(ctx: Context): { baseURL: string } | InvalidRoute | undefined { + const config = routeConfig(ctx, 'deepseek-account') as AccountRouteConfig | undefined + if (config === undefined) return undefined + try { return { baseURL: resolveAccountOptions(plainAccountOptions(config), launchEnvironmentOf(ctx)).protocol.baseURL } } + catch (error: unknown) { return { invalid: errorMessage(error) } } +} + +/** The message of a thrown value. */ +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** + * Find the effective config of the active loader entry that registers `provider`, as the model provider directory + * names it. A route registered by another package, nested settings, or an inactive entry counts as absent. + */ +function routeConfig(ctx: Context, provider: keyof typeof ROUTE_PACKAGES): unknown { + const llm = ctx.get('llm'), loader = ctx.get('loader') + if (llm === undefined || loader === undefined || !llm.listProviders().some(row => row.id === provider)) return undefined + const directory = llm.listConfigurableProviders().find(row => row.provider === provider) + if (directory === undefined || directory.settingsPath.length !== 0) return undefined + const entry = [...loader.entries()].find(row => row.options.id === directory.settingsNs && row.options.name === ROUTE_PACKAGES[provider]) + if (entry?.fiber?.state !== FiberState.ACTIVE) return undefined + return entry.fiber.effectiveConfig +} + +/** The Search and Fetch API base under the origin of a Messages base URL. */ +function toolsBase(messagesBaseURL: string): string { + return `${new URL(messagesBaseURL).origin}${TOOLS_PATH}` +} + +/** A string with content, or undefined for an absent or empty value. */ +function nonEmpty(value: string | undefined): string | undefined { + return value !== undefined && value.length > 0 ? value : undefined +} diff --git a/packages/web/web-deepseek-api/src/search.ts b/packages/web/web-deepseek-api/src/search.ts index 3a6d8b1833..0e244fc195 100644 --- a/packages/web/web-deepseek-api/src/search.ts +++ b/packages/web/web-deepseek-api/src/search.ts @@ -24,15 +24,14 @@ export const DEEPSEEK_SEARCH_REQUESTS_PER_SECOND = 5 /** Longest `ori_question` the Search API accepts, in characters. */ const ORIGINAL_QUESTION_MAX_CHARS = 128 -/** Settings named by endpoint failures of either API in this package. */ -export const DEEPSEEK_WEB_API_GUIDANCE: Omit = { - plugin: 'web-deepseek-api', field: 'baseURL', label: 'Search / Fetch API base URL', page: 'Web search', -} - /** Authentication, endpoint, and result settings snapshotted for one search. */ -export interface DeepSeekSearchApiOptions extends DeepSeekWebAuth { - /** API base including its path prefix; `/search` is appended. */ +export interface DeepSeekSearchApiOptions { + /** API base the API key is sent to, including its path prefix; `/search` is appended. An account token goes to its own base. */ baseURL: string + /** Authentication sources of the search. */ + auth: DeepSeekWebAuth + /** Settings that endpoint failures of the search name. */ + guidance: DeepSeekWebGuidance /** Results requested per search, from 1 through 50; a smaller request bound wins. */ count: number /** Most Search requests this provider starts in any one-second window. */ @@ -74,6 +73,17 @@ export class DeepSeekSearchApiProvider implements WebSearchProvider { readonly id = DEEPSEEK_SEARCH_API_PROVIDER_ID /** Monotonic start times (ms) of requests inside the current window, oldest first. */ private readonly starts: number[] = [] + /** Picker description: what this provider suits and what it costs or cannot do. */ + readonly description: NonNullable = { + en: 'Queries the search endpoint directly for titles, URLs, and snippets without an extra model call. Uses the DeepSeek account of the current conversation, or a DeepSeek API key, and may incur API charges.', + zh: '直接请求搜索接口返回标题、URL 与摘要,无需额外调用模型。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费', + ja: '追加のモデル呼び出しなしで検索エンドポイントに直接問い合わせ、タイトル、URL、スニペットを返します。現在の会話の DeepSeek アカウント、または DeepSeek API キーを使用し、API 料金が発生する場合があります。', + es: 'Consulta directamente el punto de conexión de búsqueda y devuelve títulos, URL y fragmentos sin una llamada adicional al modelo. Usa la cuenta de DeepSeek de la conversación actual o una clave de API de DeepSeek, y puede generar cargos de API.', + fr: "Interroge directement le point de terminaison de recherche pour obtenir titres, URL et extraits, sans appel de modèle supplémentaire. Utilise le compte DeepSeek de la conversation en cours ou une clé API DeepSeek, et peut entraîner des frais d'API.", + de: 'Fragt den Suchendpunkt direkt nach Titeln, URLs und Ausschnitten ab, ohne zusätzlichen Modellaufruf. Verwendet das DeepSeek-Konto der aktuellen Unterhaltung oder einen DeepSeek-API-Schlüssel und kann API-Kosten verursachen.', + ru: 'Напрямую запрашивает поисковый сервис и возвращает заголовки, URL и фрагменты без дополнительного вызова модели. Использует учётную запись DeepSeek текущего разговора или ключ API DeepSeek; возможна оплата API.', + pt: 'Consulta diretamente o endpoint de pesquisa e retorna títulos, URLs e trechos sem uma chamada extra ao modelo. Usa a conta DeepSeek da conversa atual ou uma chave de API da DeepSeek e pode gerar cobranças de API.', + } /** * @param resolveOptions - the options for the NEXT search, read once at each @@ -83,15 +93,16 @@ export class DeepSeekSearchApiProvider implements WebSearchProvider { available(): boolean { const options = this.resolveOptions() - return hasDeepSeekWebCredential(options) && URL.canParse(options.baseURL) + return hasDeepSeekWebCredential(options.auth) && URL.canParse(options.baseURL) && isPositiveInteger(options.count) && isPositiveInteger(options.requestsPerSecond) } async search(request: WebSearchRequest, signal?: AbortSignal): Promise { const options = this.resolveOptions() const question = boundedQuestion(options.originalQuestion?.()) - return dispatchDeepSeekWeb(options, { - endpoint: `${options.baseURL.replace(/\/+$/u, '')}/search`, + return dispatchDeepSeekWeb(options.auth, { + baseURL: options.baseURL, + path: '/search', body: { query: request.query, count: Math.min(options.count, request.maxResults ?? options.count), @@ -100,7 +111,7 @@ export class DeepSeekSearchApiProvider implements WebSearchProvider { }, decode: mapSearchResponse, waitToSend: waitSignal => this.waitForRequestSlot(options.requestsPerSecond, waitSignal), - guidance: { subject: 'Search', ...DEEPSEEK_WEB_API_GUIDANCE }, + guidance: options.guidance, }, signal) } diff --git a/packages/web/web-deepseek-api/src/transport.ts b/packages/web/web-deepseek-api/src/transport.ts index 196591a586..1d85de8488 100644 --- a/packages/web/web-deepseek-api/src/transport.ts +++ b/packages/web/web-deepseek-api/src/transport.ts @@ -1,32 +1,50 @@ /** * Authentication and redirect-rejecting transport shared by the Search and - * Fetch API providers in this package. Credential resolution and its readiness - * check, the dispatch call, and the cancellation helpers deliberately repeat - * `dsh-web-search-deepseek`, so neither plugin depends on the other; the - * `jscpd:ignore` regions mark those repeats + * Fetch API providers in this package * ([rationale](../../../../.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md)). * @module @deepseek-ai/dsh-web-deepseek-api/transport */ -import type { Context } from '@deepseek-ai/cordis' -import type {} from '@deepseek-ai/dsh-agent' -import { credentialRef, type CredentialRef } from '@deepseek-ai/dsh-credentials' -import type {} from '@deepseek-ai/dsh-deepseek-account' -import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' +import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { WebError } from '@deepseek-ai/dsh-web' -const ACCOUNT_PROVIDER = 'deepseek-account' const USER_AGENT = 'deepseek-harness/0.0.1' -/** Authentication and endpoint values resolved once for an operation. */ +/** Account token tried before the API key of an operation. */ +interface DeepSeekWebAccountAuth { + /** API base the token is sent to, including its path prefix. */ + baseURL: string + /** Resolve the token the account releases for a dispatch endpoint; undefined or empty selects the API key. */ + resolveToken: (endpoint: string) => Promise + /** Invalidate the exact token rejected by HTTP 401; failures must not replace the request error. */ + rejectToken: (token: string) => Promise +} + +/** Authentication values resolved once for an operation. */ export interface DeepSeekWebAuth { - /** Resolve an allowed account token; undefined selects API-key authentication. */ - resolveAccountToken?: (endpoint: string) => Promise + /** Account token sent to its own base when released; otherwise the API key is sent to the request's base. */ + account?: DeepSeekWebAccountAuth /** Literal API key; takes precedence over the credential resolver. */ apiKey?: string /** Resolve the current API key without retaining it. */ resolveApiKey?: () => Promise /** Credential reference named by missing-key diagnostics. */ apiKeyEnv?: CredentialRef + /** Model-facing diagnostic thrown when no API key resolves; the generic one names {@link apiKeyEnv}. */ + missingKeyMessage?: string + /** + * A mounted model route the operation depends on and whose settings do not resolve. The operation fails with + * `WEB_PROVIDER_MISCONFIGURED` and {@link DeepSeekWebInvalidRoute.message} at the stage that would use the route, + * before resolving its credential or sending anything. + */ + invalidRoute?: DeepSeekWebInvalidRoute +} + +/** A model route whose settings do not resolve, and the stage of an operation that needs it. */ +interface DeepSeekWebInvalidRoute { + /** `account` fails before the account token is read; `api-key` fails where the API key would be resolved. */ + stage: 'account' | 'api-key' + /** Model-facing diagnostic naming the route and its recoveries. */ + message: string } /** User-owned endpoint settings named by dispatch failures. */ @@ -35,18 +53,20 @@ export interface DeepSeekWebGuidance { subject: 'Search' | 'Fetch' /** Plugin configuration namespace. */ plugin: string - /** Endpoint configuration field. */ - field: string - /** Local settings label. */ - label: string - /** Built-in settings page title. */ - page: string + /** Whether the endpoint is the capability's own base URL rather than one derived from a model route. */ + custom: boolean + /** Configuration field of the capability's own base URL. */ + baseURLField: string + /** Configuration field of the capability's own credential reference. */ + apiKeyEnvField: string } /** One fully resolved web API request. */ interface DeepSeekWebRequest { - /** Absolute dispatch URL. */ - endpoint: string + /** API base the API key is sent to, including its path prefix. */ + baseURL: string + /** Operation path appended to the base of the credential sent, such as `/search`. */ + path: string /** JSON request body. */ body: object /** Protocol headers without credentials. */ @@ -63,78 +83,13 @@ interface DeepSeekWebRequest { waitToSend?: (signal?: AbortSignal) => Promise } -/* jscpd:ignore-start */ -/** - * Resolve credential sources for one operation without retaining secret values. - * @param ctx - Provider context with request routing and credential services. - * @param config - Literal key and credential reference from the settings snapshot. - * @returns Authentication sources for one operation. - */ -export function deepSeekWebAuth(ctx: Context, config: { apiKey: string | undefined; apiKeyEnv: string }): DeepSeekWebAuth { - const apiKeyEnv = credentialRef(config.apiKeyEnv) - const literalApiKey = config.apiKey !== undefined && config.apiKey.length > 0 - ? config.apiKey - : undefined - return { - ...literalApiKey === undefined ? {} : { apiKey: literalApiKey }, - resolveAccountToken: async (endpoint) => { - // The latest request context names the route that served the model - // request which called this search, as account sign-out reads it. - const provider = ctx.get('agents')?.currentInitiator()?.session.requestContext()?.provider - if (provider !== ACCOUNT_PROVIDER) return undefined - return await ctx.get('deepseekAccount')?.resolveToken(endpoint) - }, - resolveApiKey: async () => { - const credentials = ctx.get('credentials') - if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value - // Without the seam the environment is the whole credential plane. - const ambient = launchEnvironmentOf(ctx).get(apiKeyEnv) - return ambient !== undefined && ambient.value.length > 0 ? ambient.value : undefined - }, - apiKeyEnv, - } -} - -/** - * Whether an operation for a model request on `route` has a credential, checked in the order the - * operation resolves them: the account token first on the account route, then the API key. A failed - * read answers false, because the operation fails on that read instead of trying the next credential. - * Only presence is checked: the key's value is not read and the endpoint is not contacted, so a wrong - * key still counts as configured. - * @param ctx - Provider context with the account, credential, and environment planes. - * @param config - Literal key, credential reference, and API base from the settings snapshot. - * @param route - Provider route of the model request being prepared. - * @returns Whether the account token or an API key is available to that operation. - */ -export async function deepSeekWebCredentialReady( - ctx: Context, config: { apiKey: string | undefined; apiKeyEnv: string; baseURL: string }, route: string | undefined, -): Promise { - const account = ctx.get('deepseekAccount') - if (route === ACCOUNT_PROVIDER && account !== undefined) { - let token: string | undefined - try { token = await account.resolveToken(config.baseURL) } - catch (_unreadableGrant) { return false /* The operation fails on this read before it tries the API key. */ } - if (token !== undefined && token.length > 0) return true - } - if (config.apiKey !== undefined && config.apiKey.length > 0) return true - const apiKeyEnv = credentialRef(config.apiKeyEnv) - const credentials = ctx.get('credentials') - if (credentials === undefined) { - const ambient = launchEnvironmentOf(ctx).get(apiKeyEnv) - return ambient !== undefined && ambient.value.length > 0 - } - try { return (await credentials.describe(apiKeyEnv)).configured } - catch (_unreadableStore) { return false /* A store that cannot be read supplies no key to the operation either. */ } -} -/* jscpd:ignore-end */ - /** * Check whether an authentication source exists; does not resolve credentials. * @param auth - Sources for the operation. * @returns Whether a literal key or resolver is present. */ export function hasDeepSeekWebCredential(auth: DeepSeekWebAuth): boolean { - return (auth.apiKey?.length ?? 0) > 0 || auth.resolveApiKey !== undefined || auth.resolveAccountToken !== undefined + return (auth.apiKey?.length ?? 0) > 0 || auth.resolveApiKey !== undefined || auth.account !== undefined } /** Narrow a remote JSON object. @@ -152,12 +107,16 @@ export function invalidResponse(): WebError { return new WebError('DeepSeek returned an invalid response body', 'WEB_PROVIDER_ERROR') } -/** Authentication headers for one operation, tagged by the credential that produced them. */ +/** Authentication headers and endpoint for one operation, tagged by the credential that produced them. */ interface SearchAuth { /** `account` for a DeepSeek account token, `api-key` for an API key. */ readonly kind: 'account' | 'api-key' + /** Absolute dispatch URL under the base of the credential. */ + readonly endpoint: string /** Headers carrying the credential. */ readonly headers: Readonly> + /** Request-bound invalidation, present only when an account token was sent. */ + readonly reject?: () => Promise } /** Dispatch one operation using a single settings and credential snapshot. @@ -169,8 +128,9 @@ interface SearchAuth { export async function dispatchDeepSeekWeb( options: DeepSeekWebAuth, request: DeepSeekWebRequest, signal?: AbortSignal, ): Promise { - const { endpoint, body, decode, recordRequest, guidance } = request + const { body, decode, recordRequest, guidance } = request const auth = await authHeaders(options, request, signal) + const { endpoint } = auth await request.waitToSend?.(signal) throwIfSearchAborted(signal) recordRequest?.(endpoint) @@ -200,11 +160,11 @@ export async function dispatchDeepSeekWeb( error, ) } - /* jscpd:ignore-end */ if (!response.ok) { const status = response.status let message = `DeepSeek API error (HTTP ${status})` + let aborted: WebError | undefined try { const parsed: unknown = await response.json() if (isRecord(parsed)) { @@ -216,16 +176,24 @@ export async function dispatchDeepSeekWeb( // An abort fired mid-body must surface as WEB_ABORTED, not be swallowed // into a generic HTTP-error message — cancellation is not a provider // error (the seam's cancellation contract). - if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error) + if (signal?.aborted === true || isAbortError(error)) aborted = searchAborted(signal, error) // Otherwise: the HTTP status is already captured in `message` above; a // malformed/non-JSON error body (normal for gateway 5xx/429s) can only // cost a richer provider message, never the real error. } // The account service released this token only for its inference // origin, so the endpoint is not what the user must change. - if (status === 401 && auth.kind === 'account') throw accountRejectedError(message) + if (status === 401 && auth.kind === 'account') { + // Invalidation can cancel this Session; finish reading the HTTP failure before it does. A cancelled read still + // invalidates the token the status rejected. + try { await auth.reject?.() } + catch (_credentialRemovalFailed) { /* Report the original failure even if credential removal fails. */ } + throw aborted ?? accountRejectedError(message) + } + if (aborted !== undefined) throw aborted throw searchEndpointError(guidance, endpoint, message) } + /* jscpd:ignore-end */ try { const payload: unknown = await response.json() @@ -251,7 +219,7 @@ function accountRejectedError(message: string): WebError { /** * Add endpoint recovery instructions to failures that occur after request dispatch begins. - * @param options - the plugin, settings field, and settings page the guidance names. + * @param options - the plugin and settings fields the guidance names. * @param endpoint - the dispatched endpoint. * @param message - the failure description. * @param cause - the underlying failure, when one exists. @@ -260,12 +228,18 @@ function accountRejectedError(message: string): WebError { function searchEndpointError( options: DeepSeekWebGuidance, endpoint: string, message: string, cause?: unknown, ): WebError { + const { subject, plugin } = options + const baseURLField = `${plugin}.${options.baseURLField}`, apiKeyEnvField = `${plugin}.${options.apiKeyEnvField}` + const recovery = options.custom + ? `${subject} sends the API key under ${apiKeyEnvField} to ${baseURLField}. If that endpoint is not intended, guide the` + + ` user to correct ${baseURLField} and ${apiKeyEnvField}, or to remove both so ${subject} uses the DeepSeek` + + ' credential of the conversation.' + : `${subject} derives this endpoint from the base URL of the DeepSeek model provider whose credential it uses. If that` + + ' endpoint is not intended, guide the user to check that provider\'s base URL on the Models page, or to set' + + ` ${baseURLField} to a trusted DeepSeek endpoint and ${apiKeyEnvField} to the credential reference of its API key.` return new WebError( - `${message}\n\nThe web request used endpoint ${JSON.stringify(endpoint)}. ` - + `${options.subject} endpoint configuration is separate from chat. If that endpoint is not intended, ` - + `guide the user to Plugins > Built-in plugin settings > ${options.page}, where they can ` - + `change and save ${options.label}. If that settings page is unavailable, the user can set ` - + `${options.plugin}.${options.field} to a trusted DeepSeek endpoint. Only the user should choose or change the endpoint.`, + `${message}\n\nThe web request used endpoint ${JSON.stringify(endpoint)}. ${recovery} Only the user should choose or` + + ' change the endpoint.', 'WEB_PROVIDER_ERROR', cause === undefined ? undefined : { cause }, ) @@ -274,45 +248,57 @@ function searchEndpointError( /** * Resolve one operation's authentication headers without retaining a credential on the provider. * @param options - the caller's snapshot, so the credential and the endpoint it is sent to come from one section. - * @param request - the resolved request; its endpoint scopes the account token. + * @param request - the resolved request; its path under the account base scopes the account token. * @param signal - abort signal for the surrounding operation. - * @returns the account-token header when one resolves, otherwise the API-key headers, tagged by credential kind. + * @returns the account-token header and account endpoint when a token resolves, otherwise the API-key headers and the + * request's endpoint, tagged by credential kind. */ async function authHeaders( options: DeepSeekWebAuth, request: DeepSeekWebRequest, signal?: AbortSignal, ): Promise { - const { resolveAccountToken } = options - const token = resolveAccountToken === undefined - ? undefined - : await resolveCredential(() => resolveAccountToken(request.endpoint), signal) - if (token !== undefined && token.length > 0) return { kind: 'account', headers: { 'x-dsh-auth-token': token } } - const apiKey = await resolveApiKey(options, request.guidance.plugin, signal) - return { kind: 'api-key', headers: { + const { account, invalidRoute } = options + if (invalidRoute?.stage === 'account') throw routeMisconfigured(invalidRoute) + if (account !== undefined) { + const endpoint = joinPath(account.baseURL, request.path) + const token = await resolveCredential(() => account.resolveToken(endpoint), signal) + if (token !== undefined && token.length > 0) return { + kind: 'account', endpoint, headers: { 'x-dsh-auth-token': token }, + reject: async () => { await account.rejectToken(token) }, + } + } + if (invalidRoute?.stage === 'api-key') throw routeMisconfigured(invalidRoute) + const apiKey = await resolveApiKey(options, signal) + return { kind: 'api-key', endpoint: joinPath(request.baseURL, request.path), headers: { ...request.apiKeyHeaders?.(apiKey), 'authorization': `Bearer ${apiKey}`, } } } +/** The failure of an operation that would use a model route whose settings do not resolve. */ +function routeMisconfigured(route: DeepSeekWebInvalidRoute): WebError { + return new WebError(route.message, 'WEB_PROVIDER_MISCONFIGURED') +} + +/** Append an operation path to an API base, without doubling a trailing slash. */ +function joinPath(baseURL: string, path: string): string { + return `${baseURL.replace(/\/+$/u, '')}${path}` +} + /* jscpd:ignore-start */ /** * Resolve one operation's API key without retaining it on the provider. * @param options - the caller's snapshot, so the key and the endpoint it is sent to come from one section. - * @param plugin - configuration namespace named by the missing-key diagnostic. * @param signal - abort signal for the surrounding operation. * @returns the resolved key. */ -async function resolveApiKey(options: DeepSeekWebAuth, plugin: string, signal?: AbortSignal): Promise { +async function resolveApiKey(options: DeepSeekWebAuth, signal?: AbortSignal): Promise { throwIfSearchAborted(signal) if (options.apiKey !== undefined && options.apiKey.length > 0) return options.apiKey const { resolveApiKey } = options const resolved = resolveApiKey === undefined ? undefined : await resolveCredential(resolveApiKey, signal) if (resolved !== undefined && resolved.length > 0) return resolved - const ref = options.apiKeyEnv ?? 'DEEPSEEK_API_KEY' throw new WebError( - `DeepSeek web access has no API key for "${ref}"; store it through the credentials service` - + ' (the web Models page writes it), export it in the launching environment, or set a literal' - + ` "apiKey" in the ${plugin} config; a conversation using a DeepSeek Account model` - + ' searches with the account sign-in instead', + options.missingKeyMessage ?? `DeepSeek web access has no API key for "${options.apiKeyEnv ?? 'DEEPSEEK_API_KEY'}"`, 'WEB_PROVIDER_CREDENTIAL_MISSING', ) } diff --git a/packages/web/web-deepseek-api/tests/composition.spec.ts b/packages/web/web-deepseek-api/tests/composition.spec.ts new file mode 100644 index 0000000000..e046335bed --- /dev/null +++ b/packages/web/web-deepseek-api/tests/composition.spec.ts @@ -0,0 +1,142 @@ +/** + * Real Loader composition: the DeepSeek model routes, the web seam, and this plugin boot from a `cordis.yml`, so the + * API-key endpoint and key come from the route entries the Loader activated. Only the network and the account service + * are stubbed. + */ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Context } from '@deepseek-ai/cordis' +import type { ModuleLoaderV2 } from '@deepseek-ai/cordis-plugin-loader' +import { boot } from '@deepseek-ai/dsh-app-boot' +import type { DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import LlmRuntime from '@deepseek-ai/dsh-llm' +import * as Account from '@deepseek-ai/dsh-llm-deepseek-account' +import * as Official from '@deepseek-ai/dsh-llm-deepseek-api-key' +import WebRuntime from '@deepseek-ai/dsh-web' +import * as webApiPlugin from '@deepseek-ai/dsh-web-deepseek-api' + +const cleanups: Array<() => Promise> = [] + +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup() + vi.unstubAllGlobals() +}) + +const MODULES = new Map([ + ['@deepseek-ai/dsh-llm', LlmRuntime], + ['@deepseek-ai/dsh-llm-deepseek-api-key', Official], + ['@deepseek-ai/dsh-llm-deepseek-account', Account], + ['@deepseek-ai/dsh-web', WebRuntime], + ['@deepseek-ai/dsh-web-deepseek-api', webApiPlugin], +]) + +/** One dispatched Search or Fetch API request. */ +interface Sent { + endpoint: string + authorization: string | null + token: string | null +} + +/** + * Boot the composition with a fixed launch environment and a stubbed network. + * @param web - web-deepseek-api config. + * @param environment - launch-environment variables. + * @returns the booted root context and the Search and Fetch API requests sent so far. + */ +async function bootComposition(web: Record, environment: Record) { + // The providers and model listings dispatch to a string endpoint. + const fetchMock = vi.fn(async (url: string, _init?: RequestInit) => { + if (url.endsWith('/models')) return Response.json({ object: 'list', data: [] }) + return Response.json(url.endsWith('/search') ? { results: [] } : { content: '# Page' }) + }) + vi.stubGlobal('fetch', fetchMock) + const root = await mkdtemp(join(tmpdir(), 'dsh-web-deepseek-api-composition-')) + cleanups.push(async () => { await rm(root, { recursive: true, force: true }) }) + const previousHome = process.env.DSH_HOME + process.env.DSH_HOME = root + cleanups.push(async () => { + if (previousHome === undefined) Reflect.deleteProperty(process.env, 'DSH_HOME') + else process.env.DSH_HOME = previousHome + }) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, JSON.stringify([ + { id: 'llm', name: '@deepseek-ai/dsh-llm' }, + { id: 'llm-deepseek', name: '@deepseek-ai/dsh-llm-deepseek-api-key' }, + { id: 'llm-deepseek-account', name: '@deepseek-ai/dsh-llm-deepseek-account' }, + { id: 'web', name: '@deepseek-ai/dsh-web', config: { searchProvider: 'deepseek-search-api', fetchProvider: 'deepseek-fetch-api' } }, + { id: 'web-deepseek-api', name: '@deepseek-ai/dsh-web-deepseek-api', config: web }, + ])) + const ctx: Context = await boot('web-deepseek-api-composition', configPath, [], (host) => { + const modules: ModuleLoaderV2 = { + version: 'v2', loadCache: new Map(), + async import(specifier) { + if (!MODULES.has(specifier)) throw new Error(`Unexpected fixture module ${specifier}`) + return MODULES.get(specifier) + }, + register(): never { throw new Error('Unexpected module hook registration') }, + getOrCreateModuleJob(): never { throw new Error('Unexpected module job creation') }, + resolveSync(): never { throw new Error('Unexpected synchronous module resolution') }, + load(): never { throw new Error('Unexpected module load') }, + } + host.loader.internal = modules + host.provide('launchEnvironment', createLaunchEnvironmentSnapshot([{ source: 'process', values: environment }])) + host.provide('deepseekAccount', { resolveToken: async () => 'account-token' } as Pick as DeepSeekAccount) + }) + cleanups.push(async () => { await ctx.fiber.dispose() }) + const sent = (): Sent[] => fetchMock.mock.calls.flatMap(([endpoint, init]) => { + if (!endpoint.includes('/v1/tools/')) return [] + const headers = new Headers(init?.headers) + return [{ endpoint, authorization: headers.get('authorization'), token: headers.get('x-dsh-auth-token') }] + }) + return { ctx, sent } +} + +/** The active loader entry with `id`. */ +function entry(ctx: Context, id: string) { + const found = [...ctx.loader.entries()].find(row => row.options.id === id) + if (found?.fiber === undefined) throw new Error(`expected loader entry ${id}`) + return found.fiber +} + +describe('web-deepseek-api over the real model routes', () => { + it('serves a user with only DEEPSEEK_API_KEY by default, at the origin DEEPSEEK_BASE_URL names', async () => { + const { ctx, sent } = await bootComposition({}, { + DEEPSEEK_API_KEY: 'chat-key', DEEPSEEK_BASE_URL: 'https://internal.deepseek.test/anthropic', + }) + expect([await ctx.web.searchReady(undefined), await ctx.web.fetchReady(undefined)]).toEqual([true, true]) + await ctx.web.search({ query: 'q' }) + await ctx.web.fetch({ url: 'https://example.test/' }) + expect(sent()).toEqual([ + { endpoint: 'https://internal.deepseek.test/v1/tools/search', authorization: 'Bearer chat-key', token: null }, + { endpoint: 'https://internal.deepseek.test/v1/tools/fetch', authorization: 'Bearer chat-key', token: null }, + ]) + }) + + it('sends Fetch to its own YAML endpoint while Search follows the route', async () => { + const { ctx, sent } = await bootComposition( + { fetchBaseURL: 'https://own.deepseek.test/v1/tools', fetchApiKeyEnv: 'OWN_FETCH_KEY' }, + { DEEPSEEK_API_KEY: 'chat-key', OWN_FETCH_KEY: 'own-key', DEEPSEEK_BASE_URL: 'https://internal.deepseek.test/anthropic' }, + ) + await ctx.web.search({ query: 'q' }) + await ctx.web.fetch({ url: 'https://example.test/' }) + expect(sent()).toEqual([ + { endpoint: 'https://internal.deepseek.test/v1/tools/search', authorization: 'Bearer chat-key', token: null }, + { endpoint: 'https://own.deepseek.test/v1/tools/fetch', authorization: 'Bearer own-key', token: null }, + ]) + }) + + it('sends DEEPSEEK_API_KEY to the official base once the route entry is disposed, and unregisters with its own fiber', async () => { + const { ctx, sent } = await bootComposition({}, { + DEEPSEEK_API_KEY: 'chat-key', DEEPSEEK_BASE_URL: 'https://internal.deepseek.test/anthropic', + }) + await entry(ctx, 'llm-deepseek').dispose() + expect(await ctx.web.searchReady(undefined)).toBe(true) + await ctx.web.search({ query: 'q' }) + expect(sent()).toEqual([{ endpoint: 'https://api.deepseek.com/v1/tools/search', authorization: 'Bearer chat-key', token: null }]) + await entry(ctx, 'web-deepseek-api').dispose() + expect(ctx.web.listProviders()).toEqual({ search: [], fetch: [] }) + }) +}) diff --git a/packages/web/web-deepseek-api/tests/credential-resolution.spec.ts b/packages/web/web-deepseek-api/tests/credential-resolution.spec.ts new file mode 100644 index 0000000000..7a8b46e69a --- /dev/null +++ b/packages/web/web-deepseek-api/tests/credential-resolution.spec.ts @@ -0,0 +1,471 @@ +/** + * Endpoint and credential of the Search and Fetch APIs: each capability follows the DeepSeek credential of the + * conversation unless it has its own base URL or key reference, and readiness answers what the operation would send. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context, FiberState } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account' +import { DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import WebRuntime from '@deepseek-ai/dsh-web' +import * as webApiPlugin from '@deepseek-ai/dsh-web-deepseek-api' +import { provideRoutes, type RouteRow } from './routes.ts' + +/** A value the API-key route resolver throws instead of resolving; undefined lets it resolve normally. */ +const resolverThrow = vi.hoisted(() => ({ value: undefined as string | undefined })) + +vi.mock('@deepseek-ai/dsh-llm-deepseek-api-key', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + resolveAdapterOptions: (...args: Parameters) => { + if (resolverThrow.value !== undefined) throw resolverThrow.value + return original.resolveAdapterOptions(...args) + }, + } +}) + +type Operation = 'search' | 'fetch' + +const OPERATIONS: readonly Operation[] = ['search', 'fetch'] +const OFFICIAL = 'https://api.deepseek.com/v1/tools' +const ROUTE = 'https://route.deepseek.test/v1/tools' +const ACCOUNT = 'https://account.deepseek.test/v1/tools' +const OWN = 'https://own.deepseek.test/api/v1/tools' + +const mounted: Context[] = [] + +afterEach(async () => { + vi.unstubAllGlobals() + resolverThrow.value = undefined + await Promise.all(mounted.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +interface Setup { + /** web-deepseek-api config. */ + config?: Record + /** Launch-environment variables. */ + environment?: Record + /** Model routes the provider directory lists. */ + routes?: readonly RouteRow[] + /** Account service stand-in. */ + account?: Pick & Partial> + /** Credentials service stand-in. */ + credentials?: object + /** API response status. */ + status?: number +} + +/** One dispatched request: endpoint and credential headers. */ +interface Sent { + endpoint: string + authorization: string | null + token: string | null +} + +/** + * Mount both APIs with stubbed endpoints. + * @param setup - config, routes, and credential planes. + * @returns the context and the requests sent so far. + */ +async function mount(setup: Setup): Promise<{ ctx: Context; sent: () => Sent[] }> { + const fetchMock = vi.fn(async (endpoint: string, _init?: RequestInit) => new Response( + JSON.stringify(setup.status === undefined ? endpoint.endsWith('/search') ? { results: [] } : { content: '# Page' } : {}), + { status: setup.status ?? 200, headers: { 'content-type': 'application/json' } }, + )) + vi.stubGlobal('fetch', fetchMock) + const ctx = new Context() + mounted.push(ctx) + ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot([{ source: 'process', values: setup.environment ?? {} }])) + if (setup.account !== undefined) ctx.provide('deepseekAccount', setup.account as DeepSeekAccount) + if (setup.credentials !== undefined) ctx.provide('credentials', setup.credentials as never) + if (setup.routes !== undefined) provideRoutes(ctx, setup.routes) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(WebRuntime, { searchProvider: 'deepseek-search-api', fetchProvider: 'deepseek-fetch-api' }) + await ctx.plugin(webApiPlugin, setup.config ?? {}) + return { + ctx, + sent: () => fetchMock.mock.calls.map(([endpoint, init]) => { + const headers = new Headers(init?.headers) + return { endpoint, authorization: headers.get('authorization'), token: headers.get('x-dsh-auth-token') } + }), + } +} + +let sessions = 0 + +/** + * Run one operation, inside a Session whose latest model request used `route` when one is given. + * @param ctx - the mounted context. + * @param operation - search or fetch. + * @param route - provider route of the initiating Session's latest model request; undefined runs outside a Session. + * @returns the settled operation. + */ +async function operate(ctx: Context, operation: Operation, route?: string): Promise { + const run = () => operation === 'search' ? ctx.web.search({ query: 'q' }) : ctx.web.fetch({ url: 'https://example.test/' }) + if (route === undefined) return await run() + const session = ctx.sessions.create(SessionId(`web-api-${String(sessions++)}`)) + session.append('turn/start', { turn: 1 }) + session.append('request/context', { provider: route, model: 'test-model' }) + return await ctx.agents.withInitiator({ session } as Agent, run) +} + +/** Ask whether one operation is ready for a model request on `route`. */ +function ready(ctx: Context, operation: Operation, route: string | undefined): Promise { + return operation === 'search' ? ctx.web.searchReady(route) : ctx.web.fetchReady(route) +} + +/** The account service of a signed-in user, recording the URLs it is asked for. */ +function signedIn(asked: string[] = []): Pick { + return { resolveToken: async (url: string) => { asked.push(url); return 'account-token' } } +} + +const officialRoute = (config: Record = { baseURL: 'https://route.deepseek.test/anthropic' }): RouteRow => + ({ provider: 'deepseek-official', config }) +const accountRoute = (config: Record = { baseURL: 'https://account.deepseek.test/anthropic' }): RouteRow => + ({ provider: 'deepseek-account', config }) +const bothRoutes = [officialRoute(), accountRoute()] + +describe.each(OPERATIONS)('%s in a conversation on the DeepSeek account route', (operation) => { + it('sends only the account token to the account route origin, asking for that endpoint', async () => { + const asked: string[] = [] + const { ctx, sent } = await mount({ routes: bothRoutes, account: signedIn(asked), environment: { DEEPSEEK_API_KEY: 'chat-key' } }) + expect(await ready(ctx, operation, 'deepseek-account')).toBe(true) + await operate(ctx, operation, 'deepseek-account') + expect(sent()).toEqual([{ endpoint: `${ACCOUNT}/${operation}`, authorization: null, token: 'account-token' }]) + expect(asked).toEqual([ACCOUNT, `${ACCOUNT}/${operation}`]) + }) + + it('derives the account endpoint from DEEPSEEK_BASE_URL', async () => { + const { ctx, sent } = await mount({ + routes: [accountRoute({})], account: signedIn(), environment: { DEEPSEEK_BASE_URL: 'https://internal.deepseek.test/anthropic' }, + }) + await operate(ctx, operation, 'deepseek-account') + expect(sent()).toEqual([{ endpoint: `https://internal.deepseek.test/v1/tools/${operation}`, authorization: null, token: 'account-token' }]) + }) + + it('invalidates the token the API rejects with 401', async () => { + const rejectToken = vi.fn(async () => {}) + const { ctx } = await mount({ routes: bothRoutes, account: { ...signedIn(), rejectToken }, status: 401 }) + const failure = operate(ctx, operation, 'deepseek-account') + await expect(failure).rejects.toMatchObject({ code: 'WEB_PROVIDER_ERROR' }) + await expect(failure).rejects.toThrow('HTTP 401') + expect(rejectToken).toHaveBeenCalledExactlyOnceWith('account-token') + }) + + it.each<[string, Setup['account']]>([ + ['the user is not signed in', { resolveToken: async () => undefined }], + ['the account releases an empty token', { resolveToken: async () => '' }], + ['no account service is mounted', undefined], + ])('sends the API key to the API-key endpoint when %s', async (_label, account) => { + const { ctx, sent } = await mount({ + routes: bothRoutes, ...account === undefined ? {} : { account }, environment: { DEEPSEEK_API_KEY: 'chat-key' }, + }) + expect(await ready(ctx, operation, 'deepseek-account')).toBe(true) + await operate(ctx, operation, 'deepseek-account') + expect(sent()).toEqual([{ endpoint: `${ROUTE}/${operation}`, authorization: 'Bearer chat-key', token: null }]) + }) + + it('is not ready and names both recoveries when neither a sign-in nor the key is available', async () => { + const subject = operation === 'search' ? 'Search' : 'Fetch' + const { ctx, sent } = await mount({ routes: bothRoutes, account: { resolveToken: async () => undefined } }) + expect(await ready(ctx, operation, 'deepseek-account')).toBe(false) + await expect(operate(ctx, operation, 'deepseek-account')).rejects.toMatchObject({ + code: 'WEB_PROVIDER_CREDENTIAL_MISSING', + message: `DeepSeek ${subject} API uses the DeepSeek API key, and no key is configured for "DEEPSEEK_API_KEY". Guide the` + + ' user to add the DeepSeek API key on the Models page or export "DEEPSEEK_API_KEY" in the launching environment, or' + + ' to sign in to DeepSeek and chat on the DeepSeek account model provider.', + }) + expect(sent()).toEqual([]) + }) + + it('sends the API key without asking the account when the account route is not mounted', async () => { + const asked: string[] = [] + const { ctx, sent } = await mount({ routes: [officialRoute()], account: signedIn(asked), environment: { DEEPSEEK_API_KEY: 'chat-key' } }) + expect(await ready(ctx, operation, 'deepseek-account')).toBe(true) + await operate(ctx, operation, 'deepseek-account') + expect(sent()).toEqual([{ endpoint: `${ROUTE}/${operation}`, authorization: 'Bearer chat-key', token: null }]) + expect(asked).toEqual([]) + }) + + it('is not ready and fails without a token or key when the mounted account route settings do not resolve', async () => { + const subject = operation === 'search' ? 'Search' : 'Fetch' + const asked: string[] = [] + const { ctx, sent } = await mount({ + routes: [officialRoute(), accountRoute({ thinking: 'disabled', reasoningEffort: 'high' })], + account: signedIn(asked), environment: { DEEPSEEK_API_KEY: 'chat-key' }, + }) + expect(await ready(ctx, operation, 'deepseek-account')).toBe(false) + await expect(operate(ctx, operation, 'deepseek-account')).rejects.toMatchObject({ + code: 'WEB_PROVIDER_MISCONFIGURED', + message: `DeepSeek ${subject} API sends the DeepSeek account sign-in to the origin of the DeepSeek account model` + + ' provider, and that provider\'s settings do not resolve: llm-deepseek: only reasoningEffort "off" can be configured' + + ` when thinking is disabled. ${subject} did not send the request. Guide the user to correct that provider's settings` + + ' on the Models page or in the deployment configuration, or to set' + + ` web-deepseek-api.${operation}BaseURL and web-deepseek-api.${operation}ApiKeyEnv so ${subject} uses its own endpoint.`, + }) + expect(sent()).toEqual([]) + expect(asked).toEqual([]) + }) + + it('sends the account token while the mounted API-key route settings do not resolve', async () => { + const { ctx, sent } = await mount({ + routes: [officialRoute({ thinking: 'disabled', reasoningEffort: 'high' }), accountRoute()], + account: signedIn(), environment: { DEEPSEEK_API_KEY: 'chat-key' }, + }) + expect(await ready(ctx, operation, 'deepseek-account')).toBe(true) + await operate(ctx, operation, 'deepseek-account') + expect(sent()).toEqual([{ endpoint: `${ACCOUNT}/${operation}`, authorization: null, token: 'account-token' }]) + }) + + it('fails instead of sending DEEPSEEK_API_KEY when there is no sign-in and the API-key route settings do not resolve', async () => { + const { ctx, sent } = await mount({ + routes: [officialRoute({ thinking: 'disabled', reasoningEffort: 'high' }), accountRoute()], + account: { resolveToken: async () => undefined }, environment: { DEEPSEEK_API_KEY: 'chat-key' }, + }) + expect(await ready(ctx, operation, 'deepseek-account')).toBe(false) + await expect(operate(ctx, operation, 'deepseek-account')).rejects.toMatchObject({ code: 'WEB_PROVIDER_MISCONFIGURED' }) + expect(sent()).toEqual([]) + }) + + it('is not ready and fails without trying the key when the account grant cannot be read', async () => { + const { ctx, sent } = await mount({ + routes: bothRoutes, account: { resolveToken: () => Promise.reject(new Error('grant unreadable')) }, + environment: { DEEPSEEK_API_KEY: 'chat-key' }, + }) + expect(await ready(ctx, operation, 'deepseek-account')).toBe(false) + await expect(operate(ctx, operation, 'deepseek-account')).rejects.toMatchObject({ + code: 'WEB_PROVIDER_ERROR', message: 'DeepSeek web credential resolution failed: Error: grant unreadable', + }) + expect(sent()).toEqual([]) + }) +}) + +describe.each(OPERATIONS)('%s in any other conversation', (operation) => { + const others = ['deepseek-official', 'moonshotai', undefined] as const + + it.each(others)('sends the key of the DeepSeek API-key route to its origin for route %s, never the account token', async (route) => { + const asked: string[] = [] + const { ctx, sent } = await mount({ routes: bothRoutes, account: signedIn(asked), environment: { DEEPSEEK_API_KEY: 'chat-key' } }) + expect(await ready(ctx, operation, route)).toBe(true) + await operate(ctx, operation, route) + expect(sent()).toEqual([{ endpoint: `${ROUTE}/${operation}`, authorization: 'Bearer chat-key', token: null }]) + expect(asked).toEqual([]) + }) + + it('is not ready with only a sign-in', async () => { + const { ctx } = await mount({ routes: bothRoutes, account: signedIn() }) + for (const route of others) expect(await ready(ctx, operation, route)).toBe(false) + }) + + it('sends the key under the route apiKeyEnv to the origin DEEPSEEK_BASE_URL names, from the credential store', async () => { + const { ctx, sent } = await mount({ + routes: [officialRoute({ apiKeyEnv: 'TEAM_KEY' })], + credentials: { describe: async (ref: string) => ({ configured: ref === 'TEAM_KEY' }), resolve: async (ref: string) => ref === 'TEAM_KEY' ? { value: 'team-key' } : undefined }, + environment: { DEEPSEEK_API_KEY: 'chat-key', DEEPSEEK_BASE_URL: 'https://internal.deepseek.test/anthropic' }, + }) + expect(await ready(ctx, operation, 'deepseek-official')).toBe(true) + await operate(ctx, operation, 'deepseek-official') + expect(sent()).toEqual([{ endpoint: `https://internal.deepseek.test/v1/tools/${operation}`, authorization: 'Bearer team-key', token: null }]) + }) + + it.each<[string, readonly RouteRow[] | undefined]>([ + ['no model provider directory is mounted', undefined], + ['the route entry is not active', [{ provider: 'deepseek-official', config: { apiKeyEnv: 'TEAM_KEY' }, state: FiberState.DISPOSED }]], + ['another package registers the route', [{ provider: 'deepseek-official', config: { apiKeyEnv: 'TEAM_KEY' }, name: '@example/other-llm' }]], + ['the route settings are nested', [{ provider: 'deepseek-official', config: { apiKeyEnv: 'TEAM_KEY' }, settingsPath: ['routes', 'official'] }]], + ])('sends DEEPSEEK_API_KEY to the official base when %s', async (_label, routes) => { + const { ctx, sent } = await mount({ + ...routes === undefined ? {} : { routes }, + environment: { DEEPSEEK_API_KEY: 'chat-key', TEAM_KEY: 'team-key', DEEPSEEK_BASE_URL: 'https://internal.deepseek.test/anthropic' }, + }) + expect(await ready(ctx, operation, 'deepseek-official')).toBe(true) + await operate(ctx, operation, 'deepseek-official') + expect(sent()).toEqual([{ endpoint: `${OFFICIAL}/${operation}`, authorization: 'Bearer chat-key', token: null }]) + }) + + it.each<[string, Record, string]>([ + [ + 'thinking is disabled with a reasoning effort', { thinking: 'disabled', reasoningEffort: 'high' }, + 'llm-deepseek: only reasoningEffort "off" can be configured when thinking is disabled', + ], + [ + 'the base URL carries credentials', { baseURL: 'https://user:secret@internal.deepseek.test/anthropic' }, + 'llm-deepseek: Messages baseURL must be an HTTP(S) root without credentials, query, or fragment', + ], + ])('is not ready and fails without sending when the mounted route settings do not resolve because %s', async (_label, config, reason) => { + const subject = operation === 'search' ? 'Search' : 'Fetch' + const { ctx, sent } = await mount({ + routes: [officialRoute({ apiKeyEnv: 'TEAM_KEY', ...config })], + environment: { DEEPSEEK_API_KEY: 'chat-key', TEAM_KEY: 'team-key' }, + }) + for (const route of ['deepseek-official', 'moonshotai', undefined]) { + expect(await ready(ctx, operation, route)).toBe(false) + await expect(operate(ctx, operation, route)).rejects.toMatchObject({ + code: 'WEB_PROVIDER_MISCONFIGURED', + message: `DeepSeek ${subject} API sends that provider's API key to the origin of the DeepSeek API-key model provider,` + + ` and that provider's settings do not resolve: ${reason}. ${subject} did not send the request. Guide the user to` + + ' correct that provider\'s settings on the Models page or in the deployment configuration, or to set' + + ` web-deepseek-api.${operation}BaseURL and web-deepseek-api.${operation}ApiKeyEnv so ${subject} uses its own endpoint.`, + }) + } + expect(sent()).toEqual([]) + }) + + it('uses its own endpoint settings while the mounted route settings do not resolve', async () => { + const { ctx, sent } = await mount({ + config: { [`${operation}BaseURL`]: OWN }, + routes: [officialRoute({ thinking: 'disabled', reasoningEffort: 'high' })], environment: { DEEPSEEK_API_KEY: 'chat-key' }, + }) + expect(await ready(ctx, operation, 'deepseek-official')).toBe(true) + await operate(ctx, operation, 'deepseek-official') + expect(sent()).toEqual([{ endpoint: `${OWN}/${operation}`, authorization: 'Bearer chat-key', token: null }]) + }) + + it('is not ready when the credential store cannot be read', async () => { + const { ctx } = await mount({ routes: [officialRoute()], credentials: { describe: () => Promise.reject(new Error('store locked')) } }) + expect(await ready(ctx, operation, 'deepseek-official')).toBe(false) + }) + + it('follows a route change at the next operation', async () => { + const { ctx, sent } = await mount({ environment: { DEEPSEEK_API_KEY: 'chat-key' } }) + await operate(ctx, operation) + provideRoutes(ctx, [officialRoute({ baseURL: 'https://next.deepseek.test/anthropic' })]) + await operate(ctx, operation) + expect(sent().map(row => row.endpoint)).toEqual([`${OFFICIAL}/${operation}`, `https://next.deepseek.test/v1/tools/${operation}`]) + }) + + it('names the model provider base URL and the own fields in endpoint guidance', async () => { + const subject = operation === 'search' ? 'Search' : 'Fetch' + const { ctx } = await mount({ routes: [officialRoute()], environment: { DEEPSEEK_API_KEY: 'chat-key' }, status: 404 }) + await expect(operate(ctx, operation)).rejects.toMatchObject({ + code: 'WEB_PROVIDER_ERROR', + message: 'DeepSeek API error (HTTP 404)\n\n' + + `The web request used endpoint "${ROUTE}/${operation}". ${subject} derives this endpoint from the base URL of the` + + ' DeepSeek model provider whose credential it uses. If that endpoint is not intended, guide the user to check that' + + ` provider's base URL on the Models page, or to set web-deepseek-api.${operation}BaseURL to a trusted DeepSeek` + + ` endpoint and web-deepseek-api.${operation}ApiKeyEnv to the credential reference of its API key. Only the user` + + ' should choose or change the endpoint.', + }) + }) +}) + +describe.each(OPERATIONS)('%s with its own endpoint settings', (operation) => { + const subject = operation === 'search' ? 'Search' : 'Fetch' + const baseURL = `${operation}BaseURL`, apiKeyEnv = `${operation}ApiKeyEnv` + + it.each<[string, Record, Sent]>([ + ['only a base URL', { [baseURL]: OWN }, { endpoint: `${OWN}/${operation}`, authorization: 'Bearer chat-key', token: null }], + ['only a key reference', { [apiKeyEnv]: 'OWN_KEY' }, { endpoint: `${OFFICIAL}/${operation}`, authorization: 'Bearer own-key', token: null }], + ['both', { [baseURL]: OWN, [apiKeyEnv]: 'OWN_KEY' }, { endpoint: `${OWN}/${operation}`, authorization: 'Bearer own-key', token: null }], + ])('uses %s on every conversation route and never the account token', async (_label, config, expected) => { + const asked: string[] = [] + const { ctx, sent } = await mount({ + config, routes: bothRoutes, account: signedIn(asked), environment: { DEEPSEEK_API_KEY: 'chat-key', OWN_KEY: 'own-key' }, + }) + for (const route of ['deepseek-account', 'deepseek-official', 'moonshotai']) { + expect(await ready(ctx, operation, route)).toBe(true) + await operate(ctx, operation, route) + } + expect(sent()).toEqual([expected, expected, expected]) + expect(asked).toEqual([]) + }) + + it('is not ready without its key, despite a sign-in and the route key', async () => { + const { ctx, sent } = await mount({ + config: { [apiKeyEnv]: 'OWN_KEY' }, routes: bothRoutes, account: signedIn(), environment: { DEEPSEEK_API_KEY: 'chat-key' }, + }) + expect(await ready(ctx, operation, 'deepseek-account')).toBe(false) + await expect(operate(ctx, operation, 'deepseek-account')).rejects.toMatchObject({ + code: 'WEB_PROVIDER_CREDENTIAL_MISSING', + message: `DeepSeek ${subject} API sends the API key under "OWN_KEY" to its own endpoint, and no key is configured for` + + ' "OWN_KEY". Guide the user to export "OWN_KEY" in the launching environment, or to correct' + + ` web-deepseek-api.${operation}ApiKeyEnv.`, + }) + expect(sent()).toEqual([]) + }) + + it('treats empty values as unset and follows the conversation', async () => { + const { ctx, sent } = await mount({ + config: { [baseURL]: '', [apiKeyEnv]: '' }, routes: bothRoutes, account: signedIn(), environment: { DEEPSEEK_API_KEY: 'chat-key' }, + }) + await operate(ctx, operation, 'deepseek-account') + expect(sent()).toEqual([{ endpoint: `${ACCOUNT}/${operation}`, authorization: null, token: 'account-token' }]) + }) + + it('reports an unparseable base URL as an unavailable provider', async () => { + const { ctx, sent } = await mount({ config: { [baseURL]: 'not a url' }, environment: { DEEPSEEK_API_KEY: 'chat-key' } }) + await expect(operate(ctx, operation)).rejects.toMatchObject({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' }) + expect(sent()).toEqual([]) + }) +}) + +describe('independent capabilities', () => { + it('resolves Search from its own settings while Fetch follows the conversation', async () => { + const { ctx, sent } = await mount({ + config: { searchBaseURL: OWN }, routes: bothRoutes, account: signedIn(), environment: { DEEPSEEK_API_KEY: 'chat-key' }, + }) + await operate(ctx, 'search', 'deepseek-account') + await operate(ctx, 'fetch', 'deepseek-account') + expect(sent()).toEqual([ + { endpoint: `${OWN}/search`, authorization: 'Bearer chat-key', token: null }, + { endpoint: `${ACCOUNT}/fetch`, authorization: null, token: 'account-token' }, + ]) + }) + + it('reports one capability not ready while the other is ready', async () => { + const { ctx } = await mount({ config: { fetchApiKeyEnv: 'UNSET_KEY' }, routes: [officialRoute()], environment: { DEEPSEEK_API_KEY: 'chat-key' } }) + expect([await ready(ctx, 'search', undefined), await ready(ctx, 'fetch', undefined)]).toEqual([true, false]) + }) +}) + +describe('route resolver failures', () => { + it('reports a thrown non-Error value as its string', async () => { + const { ctx, sent } = await mount({ routes: [officialRoute()], environment: { DEEPSEEK_API_KEY: 'chat-key' } }) + resolverThrow.value = 'route resolver unavailable' + const failure = operate(ctx, 'search', 'deepseek-official') + await expect(failure).rejects.toMatchObject({ code: 'WEB_PROVIDER_MISCONFIGURED' }) + await expect(failure).rejects.toThrow('that provider\'s settings do not resolve: route resolver unavailable. Search did not send') + expect(sent()).toEqual([]) + }) +}) + +describe('stored settings', () => { + const removed = { + baseURL: 'https://legacy.deepseek.test/v1/tools', apiKeyEnv: 'LEGACY_KEY', apiKey: 'legacy-key', + } + + it('loads stored removed fields without error and leaves both capabilities unset', () => { + const config = webApiPlugin.Config(removed) + expect([config.searchBaseURL.get(), config.searchApiKeyEnv.get(), config.fetchBaseURL.get(), config.fetchApiKeyEnv.get()]) + .toEqual([undefined, undefined, undefined, undefined]) + }) + + it.each(OPERATIONS)('ignores removed fields when a %s runs', async (operation) => { + const { ctx, sent } = await mount({ config: removed, environment: { DEEPSEEK_API_KEY: 'chat-key', LEGACY_KEY: 'legacy-env-key' } }) + await operate(ctx, operation) + expect(sent()).toEqual([{ endpoint: `${OFFICIAL}/${operation}`, authorization: 'Bearer chat-key', token: null }]) + }) +}) + +describe('provider registration', () => { + it('lists both APIs with their descriptions for the provider menus', async () => { + const { ctx } = await mount({}) + const { search, fetch } = ctx.web.listProviders() + expect(search.map(({ id }) => id)).toEqual(['deepseek-search-api']) + expect(fetch.map(({ id }) => id)).toEqual(['deepseek-fetch-api']) + expect(search[0]?.description).toMatchObject({ + en: 'Queries the search endpoint directly for titles, URLs, and snippets without an extra model call. Uses the DeepSeek account of the current conversation, or a DeepSeek API key, and may incur API charges.', + zh: '直接请求搜索接口返回标题、URL 与摘要,无需额外调用模型。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费', + }) + expect(fetch[0]?.description).toMatchObject({ + en: 'Fetches public URLs on DeepSeek servers and returns Markdown. Uses the DeepSeek account of the current conversation, or a DeepSeek API key, may incur API charges, and cannot reach intranet or loopback addresses.', + zh: '由 DeepSeek 服务端抓取公网页面并返回 Markdown。使用当前会话的 DeepSeek 账号或 DeepSeek API Key,可能产生计费,且无法访问内网和本机', + }) + }) +}) diff --git a/packages/web/web-deepseek-api/tests/credentials.spec.ts b/packages/web/web-deepseek-api/tests/credentials.spec.ts index 25b7591a20..4fcae996e9 100644 --- a/packages/web/web-deepseek-api/tests/credentials.spec.ts +++ b/packages/web/web-deepseek-api/tests/credentials.spec.ts @@ -10,8 +10,6 @@ import { DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot } from '@de import WebRuntime from '@deepseek-ai/dsh-web' import * as webApiPlugin from '@deepseek-ai/dsh-web-deepseek-api' -const BASE = 'https://api.deepseek.test/v1/tools' - /** Stub the Search API and read back the bearer credential of every dispatched request. */ function captureAuthorization(): () => (string | null)[] { const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response( @@ -30,7 +28,7 @@ function captureAuthorization(): () => (string | null)[] { async function mount(ctx: Context, config: Record, environment: Record): Promise { ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot([{ source: 'process', values: environment }])) await ctx.plugin(WebRuntime, { searchProvider: 'deepseek-search-api' }) - await ctx.plugin(webApiPlugin, { baseURL: BASE, ...config }) + await ctx.plugin(webApiPlugin, config) } afterEach(() => { vi.unstubAllGlobals() }) @@ -45,7 +43,7 @@ describe('web-deepseek-api API-key resolution', () => { await mount(ctx, {}, {}) const missing = ctx.web.search({ query: 'missing' }) await expect(missing).rejects.toMatchObject({ code: 'WEB_PROVIDER_CREDENTIAL_MISSING' }) - await expect(missing).rejects.toThrow(/"apiKey" in the web-deepseek-api config/) + await expect(missing).rejects.toThrow(/export "DEEPSEEK_API_KEY" in the launching environment/) const ref = credentialRef('DEEPSEEK_API_KEY') await ctx.credentials.set(ref, 'stored-key') await ctx.web.search({ query: 'stored' }) @@ -60,8 +58,7 @@ describe('web-deepseek-api API-key resolution', () => { it.each([ ['the default reference', {}, { DEEPSEEK_API_KEY: 'env-key' }, 'Bearer env-key'], - ['a configured reference', { apiKeyEnv: 'SEARCH_API_KEY' }, { SEARCH_API_KEY: 'custom-key' }, 'Bearer custom-key'], - ['an empty literal key', { apiKey: '' }, { DEEPSEEK_API_KEY: 'env-key' }, 'Bearer env-key'], + ['a configured reference', { searchApiKeyEnv: 'SEARCH_API_KEY' }, { SEARCH_API_KEY: 'custom-key' }, 'Bearer custom-key'], ])('falls back to the launch environment for %s without a credentials service', async (_case, config, environment, expected) => { const authorization = captureAuthorization() const ctx = new Context() @@ -82,13 +79,13 @@ describe('web-deepseek-api API-key resolution', () => { } finally { await ctx.fiber.dispose() } }) - it('treats an empty credential value as missing', async () => { + it.each(['', 'bad key', '非法密钥'])('rejects malformed stored key %j before dispatch', async (value) => { const authorization = captureAuthorization() const ctx = new Context() try { - ctx.provide('credentials', { resolve: async () => ({ value: '' }) } as never) + ctx.provide('credentials', { resolve: async () => ({ value }), describe: async () => ({ configured: true }) } as never) await mount(ctx, {}, {}) - await expect(ctx.web.search({ query: 'q' })).rejects.toMatchObject({ code: 'WEB_PROVIDER_CREDENTIAL_MISSING' }) + await expect(ctx.web.search({ query: 'q' })).rejects.toMatchObject({ code: 'WEB_PROVIDER_ERROR' }) expect(authorization()).toEqual([]) } finally { await ctx.fiber.dispose() } }) diff --git a/packages/web/web-deepseek-api/tests/egress.spec.ts b/packages/web/web-deepseek-api/tests/egress.spec.ts index f0fd073474..369a43512c 100644 --- a/packages/web/web-deepseek-api/tests/egress.spec.ts +++ b/packages/web/web-deepseek-api/tests/egress.spec.ts @@ -2,6 +2,7 @@ import { createServer, type Server } from 'node:http' import type { AddressInfo } from 'node:net' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy' +import { customGuidance } from './guidance.ts' let seen: string[] = [] let proxy: Server @@ -34,7 +35,7 @@ async function observe(run: () => Promise): Promise { import { DeepSeekSearchApiProvider } from '../src/search.ts' describe('deepseek search api egress', () => { it('goes through the proxy', async () => { - const p = new DeepSeekSearchApiProvider(() => ({ apiKey: 'probe', baseURL: 'http://dsk-probe.invalid/api/v1/tools', count: 10, requestsPerSecond: 5 })) + const p = new DeepSeekSearchApiProvider(() => ({ auth: { apiKey: 'probe' }, baseURL: 'http://dsk-probe.invalid/api/v1/tools', count: 10, requestsPerSecond: 5, guidance: customGuidance('search') })) expect(await observe(() => p.search({ query: 'probe' }))).toEqual(['REQ http://dsk-probe.invalid/api/v1/tools/search']) }) }) diff --git a/packages/web/web-deepseek-api/tests/fetch.spec.ts b/packages/web/web-deepseek-api/tests/fetch.spec.ts index 6dcf8e3a4b..bd8b6fd1c5 100644 --- a/packages/web/web-deepseek-api/tests/fetch.spec.ts +++ b/packages/web/web-deepseek-api/tests/fetch.spec.ts @@ -3,8 +3,10 @@ import { Context } from '@deepseek-ai/cordis' import WebRuntime from '@deepseek-ai/dsh-web' import { DeepSeekFetchApiProvider, type DeepSeekFetchApiOptions } from '@deepseek-ai/dsh-web-deepseek-api' import * as webApiPlugin from '@deepseek-ai/dsh-web-deepseek-api' +import { DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import { customGuidance } from './guidance.ts' const fetchProvider = (options: DeepSeekFetchApiOptions) => new DeepSeekFetchApiProvider(() => options) -const options = { apiKey: 'ds-key', baseURL: 'https://api.deepseek.test/api/v1/tools' } +const options = { auth: { apiKey: 'ds-key' }, baseURL: 'https://api.deepseek.test/api/v1/tools', guidance: customGuidance('fetch') } function jsonResponse(body: unknown, init: ResponseInit = {}): Response { return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, ...init }) } @@ -39,21 +41,21 @@ describe('DeepSeek Fetch API', () => { await expect(fetchProvider(options).fetch({ url: 'https://example.com' }, AbortSignal.abort())).rejects.toMatchObject({ code: 'WEB_ABORTED' }) expect(fetchMock).not.toHaveBeenCalled() }) - it('names the shared API base in endpoint guidance', async () => { + it('names the Fetch settings in endpoint guidance', async () => { vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: { message: 'not found' } }, { status: 404 }))) await expect(fetchProvider(options).fetch({ url: 'https://example.com' })).rejects.toMatchObject({ code: 'WEB_PROVIDER_ERROR', message: 'DeepSeek API error (HTTP 404): not found\n\n' + 'The web request used endpoint "https://api.deepseek.test/api/v1/tools/fetch". ' - + 'Fetch endpoint configuration is separate from chat. If that endpoint is not intended, ' - + 'guide the user to Plugins > Built-in plugin settings > Web search, where they can ' - + 'change and save Search / Fetch API base URL. If that settings page is unavailable, the user can set ' - + 'web-deepseek-api.baseURL to a trusted DeepSeek endpoint. Only the user should choose or change the endpoint.', + + 'Fetch sends the API key under web-deepseek-api.fetchApiKeyEnv to web-deepseek-api.fetchBaseURL. If that ' + + 'endpoint is not intended, guide the user to correct web-deepseek-api.fetchBaseURL and ' + + 'web-deepseek-api.fetchApiKeyEnv, or to remove both so Fetch uses the DeepSeek credential of the conversation. ' + + 'Only the user should choose or change the endpoint.', }) }) it('is available only with a credential source and a parseable base', () => { expect(fetchProvider(options).available()).toBe(true) - expect(fetchProvider({ ...options, apiKey: '' }).available()).toBe(false) + expect(fetchProvider({ ...options, auth: { apiKey: '' } }).available()).toBe(false) expect(fetchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false) }) it('unregisters both providers with its plugin fiber', async () => { @@ -61,7 +63,8 @@ describe('DeepSeek Fetch API', () => { const ctx = new Context() try { await ctx.plugin(WebRuntime, { searchProvider: 'deepseek-search-api', fetchProvider: 'deepseek-fetch-api' }) - const fiber = await ctx.plugin(webApiPlugin, { apiKey: 'ds-key', baseURL: options.baseURL }) + ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot([{ source: 'process', values: { DEEPSEEK_API_KEY: 'ds-key' } }])) + const fiber = await ctx.plugin(webApiPlugin, { fetchBaseURL: options.baseURL }) await expect(ctx.web.fetch({ url: 'https://example.com' })).resolves.toMatchObject({ body: { content: 'body' } }) await fiber.dispose() await expect(ctx.web.fetch({ url: 'https://example.com' })).rejects.toMatchObject({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }) diff --git a/packages/web/web-deepseek-api/tests/guidance.ts b/packages/web/web-deepseek-api/tests/guidance.ts new file mode 100644 index 0000000000..94167d7ef0 --- /dev/null +++ b/packages/web/web-deepseek-api/tests/guidance.ts @@ -0,0 +1,14 @@ +/** Endpoint guidance values for provider tests that construct a provider or a request directly. */ +import type { DeepSeekWebGuidance } from '../src/transport.ts' + +/** + * Guidance of an operation whose capability uses its own base URL and key. + * @param capability - the capability whose endpoint the guidance names. + * @returns the guidance fields. + */ +export function customGuidance(capability: 'search' | 'fetch'): DeepSeekWebGuidance { + return { + subject: capability === 'search' ? 'Search' : 'Fetch', plugin: 'web-deepseek-api', custom: true, + baseURLField: `${capability}BaseURL`, apiKeyEnvField: `${capability}ApiKeyEnv`, + } +} diff --git a/packages/web/web-deepseek-api/tests/readiness.spec.ts b/packages/web/web-deepseek-api/tests/readiness.spec.ts deleted file mode 100644 index 7b02181e11..0000000000 --- a/packages/web/web-deepseek-api/tests/readiness.spec.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Whether the Search and Fetch APIs have a credential for a model request, on any route. Both - * providers read one credential, so every case runs for both operations. - */ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account' -import { DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import WebRuntime from '@deepseek-ai/dsh-web' -import * as webApiPlugin from '@deepseek-ai/dsh-web-deepseek-api' - -type Operation = 'search' | 'fetch' - -const OPERATIONS: readonly Operation[] = ['search', 'fetch'] - -const mounted: Context[] = [] - -afterEach(async () => { - vi.unstubAllGlobals() - await Promise.all(mounted.splice(0).map(ctx => ctx.fiber.dispose())) -}) - -interface Setup { - /** web-deepseek-api config. */ - config?: Record - /** Launch-environment variables, the credential plane without a credentials service. */ - environment?: Record - /** Credentials service stand-in. */ - credentials?: { describe(ref: string): Promise<{ configured: boolean }> } - /** Account service stand-in. */ - account?: Pick -} - -/** - * Mount the web seam with both APIs selected, as the settings switch selects them. - * @param setup - plugin config and credential planes. - * @returns the root context. - */ -async function mount(setup: Setup): Promise { - const ctx = new Context() - mounted.push(ctx) - ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot([{ source: 'process', values: setup.environment ?? {} }])) - if (setup.credentials !== undefined) ctx.provide('credentials', setup.credentials as never) - if (setup.account !== undefined) ctx.provide('deepseekAccount', setup.account as DeepSeekAccount) - await ctx.plugin(SessionStore) - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(AgentRegistry) - await ctx.plugin(WebRuntime, { searchProvider: 'deepseek-search-api', fetchProvider: 'deepseek-fetch-api' }) - await ctx.plugin(webApiPlugin, setup.config ?? {}) - return ctx -} - -/** Ask the seam whether `operation` is ready for a model request on `route`. */ -function ready(ctx: Context, operation: Operation, route: string | undefined): Promise { - return operation === 'search' ? ctx.web.searchReady(route) : ctx.web.fetchReady(route) -} - -/** - * Run `operation` for a Session whose latest model request ran on `route`. - * @param ctx - the mounted context. - * @param operation - search or fetch. - * @param route - the provider route of the initiating Session's request. - * @returns the settled operation. - */ -async function operateOnRoute(ctx: Context, operation: Operation, route: string): Promise { - const session = ctx.sessions.create(SessionId(`web-api-${operation}-${route}`)) - session.append('turn/start', { turn: 1 }) - session.append('request/context', { provider: route, model: 'test-model' }) - return await ctx.agents.withInitiator({ session } as Agent, () => operation === 'search' - ? ctx.web.search({ query: 'q' }) - : ctx.web.fetch({ url: 'https://example.test/' })) -} - -/** Stub both API endpoints with successful responses and record each request's bearer credential. */ -function stubApis(): () => (string | null)[] { - // The providers dispatch to a string endpoint. - const fetchMock = vi.fn(async (endpoint: string, _init?: RequestInit) => new Response( - JSON.stringify(endpoint.endsWith('/search') ? { results: [] } : { content: '# Page' }), - { status: 200, headers: { 'content-type': 'application/json' } }, - )) - vi.stubGlobal('fetch', fetchMock) - return () => fetchMock.mock.calls.map(([, init]) => new Headers(init?.headers).get('authorization')) -} - -describe.each(OPERATIONS)('%s readiness', (operation) => { - it('is not ready without any credential', async () => { - const ctx = await mount({}) - expect(await ready(ctx, operation, 'deepseek-official')).toBe(false) - }) - - it('is ready with the DeepSeek chat key on the DeepSeek routes and outside a model request', async () => { - const ctx = await mount({ environment: { DEEPSEEK_API_KEY: 'chat-key' } }) - expect(await ready(ctx, operation, 'deepseek-official')).toBe(true) - expect(await ready(ctx, operation, 'deepseek-account')).toBe(true) - expect(await ready(ctx, operation, undefined)).toBe(true) - }) - - it('is ready with a literal key on a DeepSeek route', async () => { - const ctx = await mount({ config: { apiKey: 'own-key' } }) - expect(await ready(ctx, operation, 'deepseek-official')).toBe(true) - }) - - it.each<[string, Setup, boolean]>([ - ['the DeepSeek key', { environment: { DEEPSEEK_API_KEY: 'chat-key' } }, true], - ['a literal key', { config: { apiKey: 'own-key' } }, true], - ['their own key reference', { config: { apiKeyEnv: 'DSH_WEB_API_KEY' }, environment: { DSH_WEB_API_KEY: 'own-key' } }, true], - ['no key', {}, false], - ])('answers on another provider\'s route, as on any route, when the APIs have %s', async (_label, setup, expected) => { - const ctx = await mount(setup) - expect(await ready(ctx, operation, 'moonshotai')).toBe(expected) - }) - - it('treats an empty environment value as no key', async () => { - const ctx = await mount({ environment: { DEEPSEEK_API_KEY: '' } }) - expect(await ready(ctx, operation, 'deepseek-official')).toBe(false) - }) - - it.each<[string, NonNullable, boolean]>([ - ['holds the key', { describe: () => Promise.resolve({ configured: true }) }, true], - ['lacks the key', { describe: () => Promise.resolve({ configured: false }) }, false], - ['cannot be read', { describe: () => Promise.reject(new Error('store locked')) }, false], - ])('answers from the credential store when it %s', async (_label, credentials, expected) => { - const ctx = await mount({ credentials, environment: { DEEPSEEK_API_KEY: 'ignored-env-key' } }) - expect(await ready(ctx, operation, 'deepseek-official')).toBe(expected) - }) - - it('is ready on the account route with the account token alone, asking for the API base', async () => { - const asked: string[] = [] - const ctx = await mount({ - account: { resolveToken: (url: string) => { asked.push(url); return Promise.resolve('account-token') } }, - }) - expect(await ready(ctx, operation, 'deepseek-account')).toBe(true) - expect(await ready(ctx, operation, 'deepseek-official')).toBe(false) - expect(asked).toEqual(['https://api.deepseek.com/v1/tools']) - }) - - it.each<[string, Pick, boolean]>([ - ['releases no token and the chat key is set', { resolveToken: () => Promise.resolve(undefined) }, true], - ['cannot read its grant', { resolveToken: () => Promise.reject(new Error('grant unreadable')) }, false], - ])('answers on the account route when the account %s', async (_label, account, expected) => { - const ctx = await mount({ account, environment: { DEEPSEEK_API_KEY: 'chat-key' } }) - expect(await ready(ctx, operation, 'deepseek-account')).toBe(expected) - }) -}) - -describe.each(OPERATIONS)('%s on any model route', (operation) => { - it.each(['deepseek-official', 'moonshotai'])('sends the DeepSeek key for a conversation on the %s route', async (route) => { - const authorization = stubApis() - const ctx = await mount({ environment: { DEEPSEEK_API_KEY: 'chat-key' } }) - await operateOnRoute(ctx, operation, route) - expect(authorization()).toEqual(['Bearer chat-key']) - }) -}) diff --git a/packages/web/web-deepseek-api/tests/redirect.spec.ts b/packages/web/web-deepseek-api/tests/redirect.spec.ts index f0018a6e9d..bd790cf159 100644 --- a/packages/web/web-deepseek-api/tests/redirect.spec.ts +++ b/packages/web/web-deepseek-api/tests/redirect.spec.ts @@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { createServer, type IncomingMessage, type Server } from 'node:http' import type { AddressInfo } from 'node:net' +import { customGuidance } from './guidance.ts' import { dispatchDeepSeekWeb } from '../src/transport.ts' const TEST_API_KEY = 'redirect-test-key' @@ -47,9 +48,9 @@ describe('dispatchDeepSeekWeb redirect policy', () => { it.each([301, 302, 303, 307, 308])('rejects HTTP %i before contacting Location', async (status) => { targetRequests.length = 0 await expect(dispatchDeepSeekWeb({ apiKey: TEST_API_KEY }, { - endpoint: `${redirectOrigin}/${status}`, body: { query: TEST_QUERY }, decode: value => value, + baseURL: redirectOrigin, path: `/${status}`, body: { query: TEST_QUERY }, decode: value => value, apiKeyHeaders: key => ({ 'x-api-key': key }), - guidance: { subject: 'Search', plugin: 'web-search-deepseek', field: 'baseURL', label: 'Messages API base URL', page: 'Web search' }, + guidance: customGuidance('search'), })).rejects.toMatchObject({ code: 'WEB_PROVIDER_ERROR' }) expect(targetRequests).toHaveLength(0) }) diff --git a/packages/web/web-deepseek-api/tests/routes.ts b/packages/web/web-deepseek-api/tests/routes.ts new file mode 100644 index 0000000000..a6592cea45 --- /dev/null +++ b/packages/web/web-deepseek-api/tests/routes.ts @@ -0,0 +1,48 @@ +/** Stand-in model and loader services that expose DeepSeek model routes the way the provider directory does. */ +import { FiberState, type Context } from '@deepseek-ai/cordis' +import { Config as AccountConfig } from '@deepseek-ai/dsh-llm-deepseek-account' +import { Config as OfficialConfig } from '@deepseek-ai/dsh-llm-deepseek-api-key' + +/** One model route row. */ +export interface RouteRow { + /** Provider id the route registers. */ + provider: 'deepseek-official' | 'deepseek-account' + /** Route plugin config, parsed by the route package's own schema. */ + config?: Record + /** Loader package name; defaults to the package that registers the provider. */ + name?: string + /** Loader fiber state; defaults to active. */ + state?: FiberState + /** Provider directory settings path; defaults to the entry root. */ + settingsPath?: string[] +} + +const PACKAGES = { + 'deepseek-official': '@deepseek-ai/dsh-llm-deepseek-api-key', + 'deepseek-account': '@deepseek-ai/dsh-llm-deepseek-account', +} as const + +/** + * Provide `llm` and `loader` stand-ins whose rows describe `routes`. + * @param ctx - the root context. + * @param routes - route rows; each gets its own loader entry. + */ +export function provideRoutes(ctx: Context, routes: readonly RouteRow[]): void { + const rows = routes.map((route, index) => ({ + route, + entry: { + options: { id: `route-${String(index)}`, name: route.name ?? PACKAGES[route.provider] }, + fiber: { + state: route.state ?? FiberState.ACTIVE, + effectiveConfig: route.provider === 'deepseek-official' ? OfficialConfig(route.config ?? {}) : AccountConfig(route.config ?? {}), + }, + }, + })) + ctx.provide('llm', { + listProviders: () => rows.map(({ route }) => ({ id: route.provider })), + listConfigurableProviders: () => rows.map(({ route, entry }) => ({ + provider: route.provider, settingsNs: entry.options.id, settingsPath: route.settingsPath ?? [], + })), + } as never) + ctx.provide('loader', { entries: () => rows.map(({ entry }) => entry).values() } as never) +} diff --git a/packages/web/web-deepseek-api/tests/search.spec.ts b/packages/web/web-deepseek-api/tests/search.spec.ts index adfb9c30d8..8f6a3ea75e 100644 --- a/packages/web/web-deepseek-api/tests/search.spec.ts +++ b/packages/web/web-deepseek-api/tests/search.spec.ts @@ -4,8 +4,6 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session } from '@deepseek-ai/dsh-session' import WebRuntime from '@deepseek-ai/dsh-web' -import type { DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { DeepSeekSearchApiProvider, @@ -14,16 +12,19 @@ import { type DeepSeekSearchApiOptions, } from '@deepseek-ai/dsh-web-deepseek-api' import * as webApiPlugin from '@deepseek-ai/dsh-web-deepseek-api' +import { DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import { customGuidance } from './guidance.ts' /** Construct the provider over a fixed options value; production passes a live thunk. */ const searchProvider = (options: DeepSeekSearchApiOptions): DeepSeekSearchApiProvider => new DeepSeekSearchApiProvider(() => options) const options = { - apiKey: 'ds-key', + auth: { apiKey: 'ds-key' }, baseURL: 'https://api.deepseek.test/api/v1/tools', count: 10, requestsPerSecond: 5, + guidance: customGuidance('search'), } function jsonResponse(body: unknown, init: ResponseInit = {}): Response { @@ -72,7 +73,7 @@ describe('mapSearchResponse', () => { describe('DeepSeekSearchApiProvider availability', () => { it('is unavailable without a key', () => { - expect(searchProvider({ ...options, apiKey: '' }).available()).toBe(false) + expect(searchProvider({ ...options, auth: { apiKey: '' } }).available()).toBe(false) }) it('is available with a key', () => { @@ -80,7 +81,7 @@ describe('DeepSeekSearchApiProvider availability', () => { }) it('is available with only an account token resolver', () => { - expect(searchProvider({ ...options, apiKey: '', resolveAccountToken: async () => 'account-token' }).available()).toBe(true) + expect(searchProvider({ ...options, auth: { account: { baseURL: options.baseURL, resolveToken: async () => 'account-token', rejectToken: async () => {} } } }).available()).toBe(true) }) it('is misconfigured when the base URL is unparseable', () => { @@ -210,7 +211,8 @@ describe('Search API triggering question', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(WebRuntime, { searchProvider: 'deepseek-search-api' }) - await ctx.plugin(webApiPlugin, { apiKey: 'ds-key', ...config }) + ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot([{ source: 'process', values: { DEEPSEEK_API_KEY: 'ds-key' } }])) + await ctx.plugin(webApiPlugin, config) const messages = [ userMessage('m1', { kind: 'user' }, [{ type: 'text', text: 'Earlier question' }]), userMessage('m2', { kind: 'user' }, [ @@ -243,7 +245,8 @@ describe('Search API configuration and failures', () => { const ctx = new Context() try { await ctx.plugin(WebRuntime, { searchProvider: 'deepseek-search-api' }) - await ctx.plugin(webApiPlugin, { apiKey: 'ds-key' }) + ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot([{ source: 'process', values: { DEEPSEEK_API_KEY: 'ds-key' } }])) + await ctx.plugin(webApiPlugin, {}) await ctx.web.search({ query: 'q' }) expect(fetchMock.mock.calls[0]?.[0]).toBe(`${DEEPSEEK_WEB_API_BASE_URL}/search`) expect(DEEPSEEK_WEB_API_BASE_URL).toBe('https://api.deepseek.com/v1/tools') @@ -256,7 +259,8 @@ describe('Search API configuration and failures', () => { const ctx = new Context() try { await ctx.plugin(WebRuntime, { searchProvider: 'deepseek-search-api' }) - await ctx.plugin(webApiPlugin, { apiKey: 'ds-key', baseURL: '' }) + ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot([{ source: 'process', values: { DEEPSEEK_API_KEY: 'ds-key' } }])) + await ctx.plugin(webApiPlugin, { searchBaseURL: 'not a url' }) await expect(ctx.web.search({ query: 'q' })).rejects.toMatchObject({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' }) expect(fetchMock).not.toHaveBeenCalled() } finally { await ctx.fiber.dispose() } @@ -267,44 +271,16 @@ describe('Search API configuration and failures', () => { await expect(searchProvider(options).search({ query: 'DeepSeek' })).rejects.toThrow('HTTP 502') }) - it('names the Search API base in endpoint guidance', async () => { + it('names the Search settings in endpoint guidance', async () => { vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: { message: 'not found' } }, { status: 404 }))) await expect(searchProvider(options).search({ query: 'DeepSeek' })).rejects.toMatchObject({ code: 'WEB_PROVIDER_ERROR', message: 'DeepSeek API error (HTTP 404): not found\n\n' + 'The web request used endpoint "https://api.deepseek.test/api/v1/tools/search". ' - + 'Search endpoint configuration is separate from chat. If that endpoint is not intended, ' - + 'guide the user to Plugins > Built-in plugin settings > Web search, where they can ' - + 'change and save Search / Fetch API base URL. If that settings page is unavailable, the user can set ' - + 'web-deepseek-api.baseURL to a trusted DeepSeek endpoint. Only the user should choose or change the endpoint.', + + 'Search sends the API key under web-deepseek-api.searchApiKeyEnv to web-deepseek-api.searchBaseURL. If that ' + + 'endpoint is not intended, guide the user to correct web-deepseek-api.searchBaseURL and ' + + 'web-deepseek-api.searchApiKeyEnv, or to remove both so Search uses the DeepSeek credential of the conversation. ' + + 'Only the user should choose or change the endpoint.', }) }) }) - -describe('Search API account route', () => { - it('authenticates with the account token when the initiating Session uses the account route', async () => { - const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => jsonResponse({ results: [] })) - vi.stubGlobal('fetch', fetchMock) - const asked: string[] = [] - const ctx = new Context() - try { - await ctx.plugin(SessionStore) - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(AgentRegistry) - await ctx.plugin(WebRuntime, { searchProvider: 'deepseek-search-api' }) - ctx.provide('deepseekAccount', { - resolveToken: async (url: string) => { asked.push(url); return 'account-token' }, - } as DeepSeekAccount) - await ctx.plugin(webApiPlugin, { apiKey: 'ds-key' }) - const session = ctx.sessions.create(SessionId('web-search-api-account')) - session.append('turn/start', { turn: 1 }) - session.append('request/context', { provider: 'deepseek-account', model: 'deepseek-v4-flash' }) - await ctx.agents.withInitiator({ session } as Agent, () => ctx.web.search({ query: 'q' })) - expect(asked).toEqual(['https://api.deepseek.com/v1/tools/search']) - const headers = (fetchMock.mock.calls[0]?.[1]?.headers ?? {}) as Record - expect(headers['x-dsh-auth-token']).toBe('account-token') - expect(headers).not.toHaveProperty('authorization') - expect(headers).not.toHaveProperty('x-api-key') - } finally { await ctx.fiber.dispose() } - }) -}) diff --git a/packages/web/web-deepseek-api/tests/transport.spec.ts b/packages/web/web-deepseek-api/tests/transport.spec.ts index 1ef8cb6daa..c8589c4aaa 100644 --- a/packages/web/web-deepseek-api/tests/transport.spec.ts +++ b/packages/web/web-deepseek-api/tests/transport.spec.ts @@ -1,13 +1,25 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { WebError } from '@deepseek-ai/dsh-web' +import { customGuidance } from './guidance.ts' import { dispatchDeepSeekWeb, type DeepSeekWebAuth } from '../src/transport.ts' -const options = { apiKey: 'ds-key', baseURL: 'https://api.deepseek.test/anthropic/v1' } -function searchProvider(auth: DeepSeekWebAuth & { baseURL: string; recordRequest?: (endpoint: string) => void }) { - return { search: (body: { query: string }, signal?: AbortSignal) => dispatchDeepSeekWeb(auth, { - endpoint: `${auth.baseURL}/messages`, body, decode: value => value, - ...auth.recordRequest === undefined ? {} : { recordRequest: auth.recordRequest }, +const options = { apiKey: 'ds-key', baseURL: 'https://api.deepseek.test/v1/tools' } +const ACCOUNT_BASE = 'https://account.deepseek.test/v1/tools' +/** Sources of one search; the account fields, when given, form the account credential on {@link ACCOUNT_BASE}. */ +type SearchSources = Omit & { + baseURL: string + recordRequest?: (endpoint: string) => void + resolveAccountToken?: (endpoint: string) => Promise + rejectAccountToken?: (token: string) => Promise +} +function searchProvider({ baseURL, recordRequest, resolveAccountToken, rejectAccountToken, ...auth }: SearchSources) { + const account = resolveAccountToken === undefined ? {} : { + account: { baseURL: ACCOUNT_BASE, resolveToken: resolveAccountToken, rejectToken: rejectAccountToken ?? (async () => {}) }, + } + return { search: (body: { query: string }, signal?: AbortSignal) => dispatchDeepSeekWeb({ ...auth, ...account }, { + baseURL, path: '/search', body, decode: value => value, + ...recordRequest === undefined ? {} : { recordRequest }, apiKeyHeaders: key => ({ 'x-api-key': key }), - guidance: { subject: 'Search', plugin: 'web-search-deepseek', field: 'baseURL', label: 'Messages API base URL', page: 'Web search' }, + guidance: customGuidance('search'), }, signal) } } function jsonResponse(body: unknown, init: ResponseInit = {}): Response { @@ -34,23 +46,25 @@ describe('DeepSeek web account authentication', () => { } } - it('sends only the account token for the dispatched endpoint, ahead of a configured key', async () => { + it('sends only the account token to the account endpoint it was released for, ahead of a configured key', async () => { const { first } = captureFetch() const resolveAccountToken = vi.fn(async (_endpoint: string) => 'account-token') const resolveApiKey = vi.fn(async () => 'resolved-key') await searchProvider({ ...options, resolveAccountToken, resolveApiKey }).search({ query: 'q' }) const { url, headers } = first() - expect(resolveAccountToken).toHaveBeenCalledWith(url) + expect(url).toBe(`${ACCOUNT_BASE}/search`) + expect(resolveAccountToken).toHaveBeenCalledExactlyOnceWith(url) expect(resolveApiKey).not.toHaveBeenCalled() expect(headers['x-dsh-auth-token']).toBe('account-token') expect(headers).not.toHaveProperty('x-api-key') expect(headers).not.toHaveProperty('authorization') }) - it.each([undefined, ''])('falls back to the API key when the account resolves %j', async (token) => { + it.each([undefined, ''])('falls back to the API key on the API-key endpoint when the account resolves %j', async (token) => { const { first } = captureFetch() await searchProvider({ ...options, resolveAccountToken: async () => token }).search({ query: 'q' }) - const { headers } = first() + const { url, headers } = first() + expect(url).toBe(`${options.baseURL}/search`) expect(headers['x-api-key']).toBe('ds-key') expect(headers).not.toHaveProperty('x-dsh-auth-token') }) @@ -82,13 +96,84 @@ describe('DeepSeek web account authentication', () => { }) }) + it.each([false, true])('invalidates the sent account token on 401 and preserves the HTTP error when removal fails: %s', async (fails) => { + const rejectAccountToken = vi.fn(async (_token: string) => { if (fails) throw new Error('storage failed') }) + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: 'invalid token' }, { status: 401 }))) + const error = await rejectedWebError(searchProvider({ + ...options, resolveAccountToken: async () => 'sent-token', rejectAccountToken, + }).search({ query: 'q' })) + expect(error.code).toBe('WEB_PROVIDER_ERROR') + expect(error.message).toContain('HTTP 401') + expect(rejectAccountToken).toHaveBeenCalledExactlyOnceWith('sent-token') + }) + + it.each([[401, undefined], [403, 'account-token']] as const)('does not invalidate a token for status %s and token %s', async (status, token) => { + const rejectAccountToken = vi.fn(async () => {}) + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({}, { status }))) + await expect(searchProvider({ + ...options, resolveAccountToken: async () => token, rejectAccountToken, + }).search({ query: 'q' })).rejects.toMatchObject({ code: 'WEB_PROVIDER_ERROR' }) + expect(rejectAccountToken).not.toHaveBeenCalled() + }) + + it('retains the HTTP failure when account invalidation cancels the requesting Session', async () => { + const controller = new AbortController() + const response = jsonResponse({ error: 'invalid token' }, { status: 401 }) + const json = response.json.bind(response) + vi.spyOn(response, 'json').mockImplementation(async (): Promise => { + controller.signal.throwIfAborted() + const body: unknown = await json() + return body + }) + vi.stubGlobal('fetch', vi.fn(async () => response)) + const error = await rejectedWebError(searchProvider({ + ...options, resolveAccountToken: async () => 'sent-token', + rejectAccountToken: async () => { controller.abort(new Error('signed out')) }, + }).search({ query: 'q' }, controller.signal)) + expect(error.code).toBe('WEB_PROVIDER_ERROR') + expect(error.message).toContain('HTTP 401') + expect(controller.signal.aborted).toBe(true) + }) + + it('invalidates the sent account token once when cancellation interrupts reading the 401 body', async () => { + const controller = new AbortController() + const response = jsonResponse({ error: 'invalid token' }, { status: 401 }) + vi.spyOn(response, 'json').mockImplementation(async (): Promise => { + controller.abort(new Error('caller stopped')) + throw new DOMException('The operation was aborted.', 'AbortError') + }) + vi.stubGlobal('fetch', vi.fn(async () => response)) + const rejectAccountToken = vi.fn(async (_token: string) => {}) + const error = await rejectedWebError(searchProvider({ + ...options, resolveAccountToken: async () => 'sent-token', rejectAccountToken, + }).search({ query: 'q' }, controller.signal)) + expect(error.code).toBe('WEB_ABORTED') + expect(rejectAccountToken).toHaveBeenCalledExactlyOnceWith('sent-token') + }) + + it('reports cancellation during an API-key 401 body read without invalidating a token', async () => { + const controller = new AbortController() + const response = jsonResponse({}, { status: 401 }) + vi.spyOn(response, 'json').mockImplementation(async (): Promise => { + controller.abort(new Error('caller stopped')) + throw new DOMException('The operation was aborted.', 'AbortError') + }) + vi.stubGlobal('fetch', vi.fn(async () => response)) + const rejectAccountToken = vi.fn(async (_token: string) => {}) + const error = await rejectedWebError(searchProvider({ + ...options, resolveAccountToken: async () => undefined, rejectAccountToken, + }).search({ query: 'q' }, controller.signal)) + expect(error.code).toBe('WEB_ABORTED') + expect(rejectAccountToken).not.toHaveBeenCalled() + }) + it('keeps endpoint guidance for other account-authenticated HTTP failures', async () => { vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({}, { status: 503 }))) const error = await rejectedWebError(searchProvider({ ...options, resolveAccountToken: async () => 'account-token', }).search({ query: 'q' })) - expect(error.message).toContain('Search endpoint configuration is separate from chat.') + expect(error.message).toContain('If that endpoint is not intended') }) it('maps a synchronous resolver throw to WEB_PROVIDER_ERROR', async () => { @@ -238,11 +323,11 @@ describe('DeepSeek web cancellation and failures', () => { .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR', message: 'DeepSeek API error (HTTP 429): rate limited\n\n' - + 'The web request used endpoint "https://api.deepseek.test/anthropic/v1/messages". ' - + 'Search endpoint configuration is separate from chat. If that endpoint is not intended, ' - + 'guide the user to Plugins > Built-in plugin settings > Web search, where they can ' - + 'change and save Messages API base URL. If that settings page is unavailable, the user can set ' - + 'web-search-deepseek.baseURL to a trusted DeepSeek endpoint. Only the user should choose or change the endpoint.', + + 'The web request used endpoint "https://api.deepseek.test/v1/tools/search". ' + + 'Search sends the API key under web-deepseek-api.searchApiKeyEnv to web-deepseek-api.searchBaseURL. If that ' + + 'endpoint is not intended, guide the user to correct web-deepseek-api.searchBaseURL and ' + + 'web-deepseek-api.searchApiKeyEnv, or to remove both so Search uses the DeepSeek credential of the conversation. ' + + 'Only the user should choose or change the endpoint.', })) }) @@ -307,7 +392,7 @@ describe('DeepSeek web cancellation and failures', () => { vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new TypeError('connection refused')))) const error = await rejectedWebError(searchProvider(options).search({ query: 'q' })) expect(error.code).toBe('WEB_PROVIDER_ERROR') - expect(error.message).toContain('The web request used endpoint "https://api.deepseek.test/anthropic/v1/messages".') + expect(error.message).toContain('The web request used endpoint "https://api.deepseek.test/v1/tools/search".') }) }) diff --git a/packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts b/packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts index 6269841dce..794e53162e 100644 --- a/packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts +++ b/packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { DeepSeekFetchApiProvider, DeepSeekSearchApiProvider } from '@deepseek-ai/dsh-web-deepseek-api' +import { customGuidance } from './guidance.ts' /** Real Search and Fetch API probes; the base names the deployment the key authenticates. */ const apiKey = process.env.DEEPSEEK_API_KEY @@ -8,7 +9,7 @@ const maybe = apiKey !== undefined && apiKey.length > 0 && baseURL.length > 0 ? maybe('DeepSeek Search and Fetch real API', () => { it('returns citeable sources from the Search API', async () => { - const provider = new DeepSeekSearchApiProvider(() => ({ apiKey: apiKey!, baseURL, count: 10, requestsPerSecond: 5 })) + const provider = new DeepSeekSearchApiProvider(() => ({ auth: { apiKey: apiKey! }, baseURL, count: 10, requestsPerSecond: 5, guidance: customGuidance('search') })) const result = await provider.search({ query: 'DeepSeek', maxResults: 3 }, AbortSignal.timeout(30_000)) expect(result.sources.length).toBeGreaterThan(0) expect(result.sources.length).toBeLessThanOrEqual(3) @@ -16,7 +17,7 @@ maybe('DeepSeek Search and Fetch real API', () => { }, 40_000) it('returns Markdown from the Fetch API', async () => { - const provider = new DeepSeekFetchApiProvider(() => ({ apiKey: apiKey!, baseURL })) + const provider = new DeepSeekFetchApiProvider(() => ({ auth: { apiKey: apiKey! }, baseURL, guidance: customGuidance('fetch') })) const result = await provider.fetch({ url: 'https://example.com' }, AbortSignal.timeout(30_000)) expect(result.body.kind).toBe('text') expect(result.body.content.length).toBeGreaterThan(0) diff --git a/packages/web/web-deepseek-api/tsconfig.json b/packages/web/web-deepseek-api/tsconfig.json index 423c1fc570..0fb683fb97 100644 --- a/packages/web/web-deepseek-api/tsconfig.json +++ b/packages/web/web-deepseek-api/tsconfig.json @@ -8,6 +8,7 @@ "src" ], "references": [ + { "path": "../../llm/llm-deepseek" }, { "path": "../../util/launch-environment" }, @@ -37,6 +38,18 @@ }, { "path": "../../util/http-proxy" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../llm/llm-deepseek-api-key" + }, + { + "path": "../../llm/llm-deepseek-account" } ] } diff --git a/packages/web/web-fetch-http/src/provider.ts b/packages/web/web-fetch-http/src/provider.ts index 1842e30c8e..51ee157a1a 100644 --- a/packages/web/web-fetch-http/src/provider.ts +++ b/packages/web/web-fetch-http/src/provider.ts @@ -32,6 +32,17 @@ export const LOCAL_FETCH_PROVIDER_ID = 'http' /** The anonymous HTTP(S) fetch provider. */ export class HttpFetchProvider implements WebFetchProvider { readonly id = LOCAL_FETCH_PROVIDER_ID + /** Picker description: what this provider suits and what it costs or cannot do. */ + readonly description: NonNullable = { + en: 'Fetches URLs directly from this machine with no API key or charges, including intranet and loopback addresses. Decodes text, HTML, JSON, and XML only; no JavaScript, cookies, or PDFs. You can also try DeepSeek Fetch API.', + zh: '本机直接请求,免 API Key 与计费,可访问内网和本机。仅解码 HTML、JSON 等文本,不执行 JavaScript、不带登录态,不支持 PDF;也可以试试 DeepSeek Fetch API', + ja: 'このマシンから URL を直接取得し、API キーや料金は不要で、イントラネットやループバックのアドレスにもアクセスできます。テキスト、HTML、JSON、XML のみをデコードし、JavaScript、Cookie、PDF には対応しません。DeepSeek Fetch API も試せます。', + es: 'Obtiene las URL directamente desde este equipo, sin clave de API ni cargos, incluidas direcciones de la intranet y de bucle invertido. Solo decodifica texto, HTML, JSON y XML; sin JavaScript, cookies ni PDF. También puede probar DeepSeek Fetch API.', + fr: 'Récupère les URL directement depuis cette machine, sans clé API ni frais, y compris les adresses intranet et de bouclage. Ne décode que le texte, le HTML, le JSON et le XML ; ni JavaScript, ni cookies, ni PDF. Vous pouvez aussi essayer DeepSeek Fetch API.', + de: 'Ruft URLs direkt von diesem Rechner ab, ohne API-Schlüssel oder Kosten, auch Intranet- und Loopback-Adressen. Dekodiert nur Text, HTML, JSON und XML; kein JavaScript, keine Cookies, keine PDFs. Sie können auch DeepSeek Fetch API ausprobieren.', + ru: 'Загружает URL напрямую с этого компьютера без ключа API и оплаты, включая адреса интрасети и loopback. Декодирует только текст, HTML, JSON и XML; без JavaScript, cookie и PDF. Можно также попробовать DeepSeek Fetch API.', + pt: 'Obtém URLs diretamente desta máquina, sem chave de API nem cobranças, incluindo endereços da intranet e de loopback. Decodifica apenas texto, HTML, JSON e XML; sem JavaScript, cookies ou PDFs. Você também pode experimentar a DeepSeek Fetch API.', + } /** @param limits - resolved transport and response limits. */ constructor(private readonly limits: HttpFetchLimits) {} diff --git a/packages/web/web-search-deepseek/README.i18n.yaml b/packages/web/web-search-deepseek/README.i18n.yaml index 3aefd0663a..5cbf683924 100644 --- a/packages/web/web-search-deepseek/README.i18n.yaml +++ b/packages/web/web-search-deepseek/README.i18n.yaml @@ -24,8 +24,8 @@ en: 0553b6c926c44cf4 zh: 5cd3221d3e8eebd9 /deepseek-ai-dsh-web-search-deepseek/use-this-package/authentication: - en: e9104c20ee90b3be - zh: 5fcc71d403976eaa + en: 7a765ba931329067 + zh: 6fc14edcc378684a /deepseek-ai-dsh-web-search-deepseek/use-this-package/what-a-search-returns: en: e30442c90365357b zh: 9c9e2f1182963a8a @@ -33,8 +33,8 @@ en: 467b8238cf7e140c zh: 85a519c8811557aa /deepseek-ai-dsh-web-search-deepseek/use-this-package/failures-and-recovery: - en: e8b3d05fa39ccf47 - zh: 2faa8b06ce0c4ad4 + en: 8e69cf009b707c49 + zh: f7d50bfc06b01b6d /deepseek-ai-dsh-web-search-deepseek/understand-the-implementation: en: 9842c69ad3636bb3 zh: 3cdc3f816cbdd0cb @@ -48,8 +48,8 @@ en: d6c3f30b70a5f4b5 zh: ab7c5b7d6fc3cae6 /deepseek-ai-dsh-web-search-deepseek/further-exploration: - en: 4c0730d979693c5a - zh: 74681ad82b7b45ed + en: 3a650478cf33d792 + zh: ab4247c4c31f4a90 /deepseek-ai-dsh-web-search-deepseek/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 @@ -96,5 +96,5 @@ en: 7f775cc79dbce94b zh: 12b8f68f9e7c48ca /deepseek-ai-dsh-web-search-deepseek/known-limitations-and-deferred-work/dev-note/dedicated-retrieval-endpoint: - en: 2a0437bd17a5a8a4 - zh: 4032f3feb2388f6e + en: 188ca3400a73352e + zh: f948e2c565ec6ac1 diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md index c82dc6768b..78329bf915 100644 --- a/packages/web/web-search-deepseek/README.md +++ b/packages/web/web-search-deepseek/README.md @@ -58,10 +58,12 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### Authentication -A search authenticates with the DeepSeek account when the latest `request/context` event of the initiating Session names the `deepseek-account` provider route and `ctx.deepseekAccount` resolves a token for the search endpoint. The account service resolves one only while signed in and only for its deployment-configured inference origin, `https://api.deepseek.com` by default. That search sends only `x-dsh-auth-token`, even when an API key is configured. Every other search, including a call without an initiating Session and a search whose endpoint has another origin, sends the API key as both `x-api-key` and `Authorization: Bearer`. An HTTP 401 response to an account-authenticated search fails as `WEB_PROVIDER_ERROR` with sign-in guidance instead of endpoint guidance, and leaves the account signed in. +A search authenticates with the DeepSeek account when the latest `request/context` event of the initiating Session names the `deepseek-account` provider route and `ctx.deepseekAccount` resolves a token for the search endpoint. The account service resolves one only while signed in and only for its deployment-configured inference origin, `https://api.deepseek.com` by default. That search sends only `x-dsh-auth-token`, even when an API key is configured. Every other search, including a call without an initiating Session and a search whose endpoint has another origin, sends the API key as both `x-api-key` and `Authorization: Bearer`. An HTTP 401 response to an account-authenticated search fails as `WEB_PROVIDER_ERROR` with sign-in guidance instead of endpoint guidance, and invalidates only the token that search sent: a newer sign-in is preserved, and a credential-removal failure or a cancelled error-body read does not skip invalidation or replace the reported failure. An API-key 401 and every HTTP 403 leave the account signed in. The provider's `ready()` answers whether the search a model request would make has a credential, on any model route: the request's route is `deepseek-account` and `ctx.deepseekAccount` resolves a token for the configured search endpoint, or a literal `apiKey` is set or `ctx.credentials.describe` reports the `apiKeyEnv` reference configured (without that service, the process environment must carry it). It checks them in the order a search resolves them, and a failed account-token or credential-store read answers false, because the search fails on that read instead of trying the next credential. While this provider is the selected search provider, [`dsh-tool-web`](../tool-web/README.md#search-readiness) withholds `web_search` and its prompt guidance from each request for which it answers false. Only presence is checked: a wrong key, or an endpoint that does not serve the native `web_search_20250305` server tool, counts as ready and fails each call. A call that still arrives resolves credentials when it runs. +Referenced keys use the [shared DeepSeek credential helpers](../../llm/llm-deepseek/README.md#account-credentials), including the same whitespace normalization and format validation as model requests. A malformed referenced key fails before dispatch; readiness remains a presence-only check. + ### What a search returns `content` is always omitted: DeepSeek's provider prose is not trusted as an answer. `sources[]` comes from `web_search_result` items inside `web_search_tool_result` blocks — `url` and `title` directly, and `publishedAt` from `page_age` — with snippets joined from URL-keyed `cited_text` entries where an excerpt exists. Results are deduplicated by URL, and because DeepSeek exposes no result-count knob, the service enforces `maxResults` by truncating and flagging. @@ -72,7 +74,7 @@ A search running under an initiating agent appends the log-only `web/deepseek-se ### Failures and recovery -Failures throw `WebError` with a machine-routable code: a missing credential is `WEB_PROVIDER_CREDENTIAL_MISSING`, caller cancellation is `WEB_ABORTED`, and provider or transport failures — including a response with no `web_search_tool_result` block — are `WEB_PROVIDER_ERROR`. HTTP redirects are rejected before the `Location` target is contacted. Every failure after dispatch names the resolved search endpoint and explains that search endpoint configuration is separate from chat. If the endpoint is unintended, the message tells the conversation model to guide the user to the Messages API base URL field under Plugins > Built-in plugin settings > Web search and save the change. When that page is unavailable, it names `web-search-deepseek.baseURL` as the deployment configuration alternative. The model must not choose or change the endpoint. The model-facing `web_search` tool surfaces this text under its own error wrapper. +Failures throw `WebError` with a machine-routable code: a missing credential is `WEB_PROVIDER_CREDENTIAL_MISSING`, caller cancellation is `WEB_ABORTED`, and provider or transport failures — including a response with no `web_search_tool_result` block — are `WEB_PROVIDER_ERROR`. HTTP redirects are rejected before the `Location` target is contacted. Every failure after dispatch names the resolved search endpoint and explains that search endpoint configuration is separate from chat. If the endpoint is unintended, the message tells the conversation model to guide the user to Plugins > Built-in plugin settings > Web search and fetch, select DeepSeek (legacy) under Search, and save its Base URL. When that page is unavailable, it names `web-search-deepseek.baseURL` as the deployment configuration alternative. The model must not choose or change the endpoint. The model-facing `web_search` tool surfaces this text under its own error wrapper. ----- @@ -115,7 +117,7 @@ Readiness captures the same options but reads no API key value: on the account r Read these pages when the package-level contract is not enough. They move from the shared vocabulary to the service, the model-facing tools, and the design rationale. - [Web subsystem](../../../docs/subsystems/web.md) — the exhaustive search request/result vocabulary and error codes. -- [Web package map](../README.md) — the seven-package family and each role. +- [Web package map](../README.md) — the web package family and each role. - [dsh-web](../web/README.md) — the web service this provider registers into. - [dsh-tool-web](../tool-web/README.md) — the model-facing `web_search` tool that renders this provider's sources. - [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-search-deepseek) — every accepted config field and its source declaration. @@ -193,6 +195,6 @@ This Dev Note is working context for maintainers: open questions and undecided d #### Dedicated retrieval endpoint -DeepSeek's Search and Fetch APIs, which avoid the full model turn, live in [dsh-web-deepseek-api](../web-deepseek-api/README.md) and are selected by the Web search settings switch; this provider stays the default until the Search API launch is announced. +DeepSeek's Search and Fetch APIs, which avoid the full model turn, live in [dsh-web-deepseek-api](../web-deepseek-api/README.md) and are selected with the Search and Fetch menus on the Web search and fetch settings page. Shipped profiles select the Search API, so this provider runs only when `searchProvider: deepseek-official` is selected (for example, on gateways without Search API routes).
diff --git a/packages/web/web-search-deepseek/README.zh.md b/packages/web/web-search-deepseek/README.zh.md index de97463ee9..4755a0deaf 100644 --- a/packages/web/web-search-deepseek/README.zh.md +++ b/packages/web/web-search-deepseek/README.zh.md @@ -58,10 +58,12 @@ kind: "package-reference" ### 鉴权 -当发起会话最近的 `request/context` 事件指明 `deepseek-account` 提供方路由,且 `ctx.deepseekAccount` 为搜索端点解析出 token 时,搜索使用 DeepSeek 账号鉴权。账号服务仅在已登录时、且仅对部署配置的推理 origin(默认 `https://api.deepseek.com`)解析 token。这类搜索只发送 `x-dsh-auth-token`,即使配置了 API 密钥也是如此。其余所有搜索,包括没有发起会话的调用以及端点属于其他 origin 的搜索,都把 API 密钥同时作为 `x-api-key` 与 `Authorization: Bearer` 发送。账号鉴权的搜索收到 HTTP 401 时以 `WEB_PROVIDER_ERROR` 失败,附带登录指引而非端点指引,账号保持登录。 +当发起会话最近的 `request/context` 事件指明 `deepseek-account` 提供方路由,且 `ctx.deepseekAccount` 为搜索端点解析出 token 时,搜索使用 DeepSeek 账号鉴权。账号服务仅在已登录时、且仅对部署配置的推理 origin(默认 `https://api.deepseek.com`)解析 token。这类搜索只发送 `x-dsh-auth-token`,即使配置了 API 密钥也是如此。其余所有搜索,包括没有发起会话的调用以及端点属于其他 origin 的搜索,都把 API 密钥同时作为 `x-api-key` 与 `Authorization: Bearer` 发送。账号鉴权的搜索收到 HTTP 401 时以 `WEB_PROVIDER_ERROR` 失败,附带登录指引而非端点指引,并只作废该搜索实际发送的 token:更新的登录不受影响,凭据删除失败或错误正文读取被取消既不会跳过作废,也不会替换所报告的失败。API 密钥请求的 401 与所有 HTTP 403 不影响账号登录状态。 提供方的 `ready()` 回答模型请求将发起的搜索是否有凭据,不论模型路由:该请求的路由是 `deepseek-account` 且 `ctx.deepseekAccount` 为配置的搜索端点解析出 token,或者设置了字面 `apiKey`,或 `ctx.credentials.describe` 报告 `apiKeyEnv` 引用已配置(没有该服务时,进程环境中必须存在该变量)。它按搜索解析凭据的顺序检查,读取账号 token 或凭据存储失败时回答 false,因为搜索会在该次读取上失败,而不会尝试下一种凭据。本提供方是选中的搜索提供方时,[`dsh-tool-web`](../tool-web/README.zh.md#search-readiness) 会从它回答 false 的每个请求中撤下 `web_search` 及其提示词说明。这里只检查凭据是否存在:错误的密钥,或不提供原生 `web_search_20250305` 服务器工具的端点,都视为就绪并使每次调用失败。仍然到达的调用会在运行时解析凭据。 +引用密钥使用[共享 DeepSeek 凭据函数](../../llm/llm-deepseek/README.zh.md#account-credentials),接受与模型请求相同的首尾空白规范化和格式校验。引用密钥格式错误会在发送请求前失败;就绪判断仍只检查是否存在。 + ### 搜索返回什么 `content` 始终省略:DeepSeek 的提供方文本不作为答案受到信任。`sources[]` 来自 `web_search_tool_result` 块内的 `web_search_result` 条目——`url` 和 `title` 直接取自同名字段,`publishedAt` 取自 `page_age`——snippet 在存在摘录时按 URL 关联的 `cited_text` 条目拼接。结果按 URL 去重,且由于 DeepSeek 不公开结果数量旋钮,服务通过截断并标记来强制执行 `maxResults`。 @@ -72,7 +74,7 @@ kind: "package-reference" ### 失败与恢复 -失败抛出携带可按机器路由 code 的 `WebError`:凭据缺失为 `WEB_PROVIDER_CREDENTIAL_MISSING`,调用方取消为 `WEB_ABORTED`,提供方或传输失败,包括响应中没有 `web_search_tool_result` 块,为 `WEB_PROVIDER_ERROR`。HTTP 重定向会在接触 `Location` 指向的目标之前被拒绝。请求发出后的每项失败都会指出已解析的搜索端点,并说明搜索端点配置独立于聊天端点。如果该端点不符合用户预期,错误消息会要求会话模型指导用户进入 Plugins > Built-in plugin settings > Web search,修改 Messages API base URL 字段并保存。该页面不可用时,消息会把 `web-search-deepseek.baseURL` 作为部署配置方式。模型不得替用户选择或修改端点。面向模型的 `web_search` 工具会在自己的错误包装层内呈现这段文本。 +失败抛出携带可按机器路由 code 的 `WebError`:凭据缺失为 `WEB_PROVIDER_CREDENTIAL_MISSING`,调用方取消为 `WEB_ABORTED`,提供方或传输失败,包括响应中没有 `web_search_tool_result` 块,为 `WEB_PROVIDER_ERROR`。HTTP 重定向会在接触 `Location` 指向的目标之前被拒绝。请求发出后的每项失败都会指出已解析的搜索端点,并说明搜索端点配置独立于聊天端点。如果该端点不符合用户预期,错误消息会要求会话模型指导用户进入 Plugins > Built-in plugin settings > Web search and fetch,在 Search 中选择 DeepSeek(旧版),修改其 Base URL 并保存。该页面不可用时,消息会把 `web-search-deepseek.baseURL` 作为部署配置方式。模型不得替用户选择或修改端点。面向模型的 `web_search` 工具会在自己的错误包装层内呈现这段文本。 ----- @@ -115,7 +117,7 @@ kind: "package-reference" 当包级约定不够用时阅读以下页面。它们从共享词汇逐步进入服务、面向模型的工具与设计依据。 - [web 子系统](../../../docs/subsystems/web.zh.md)——穷尽式的搜索请求/结果词汇与错误码。 -- [web 包映射](../README.zh.md)——七包家族与各角色。 +- [web 包映射](../README.zh.md)——Web 包家族与各角色。 - [dsh-web](../web/README.zh.md)——本提供方注册进入的 web 服务。 - [dsh-tool-web](../tool-web/README.zh.md)——渲染本提供方来源的面向模型 `web_search` 工具。 - [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-search-deepseek)——每个受支持配置字段及其源声明。 @@ -193,6 +195,6 @@ kind: "package-reference" #### 专用检索端点 -无需完整模型轮次的 DeepSeek Search 与 Fetch API 位于 [dsh-web-deepseek-api](../web-deepseek-api/README.zh.md),由网页搜索设置页的开关选择;在 Search API 正式发布公告之前,本提供方仍是默认值。 +无需完整模型轮次的 DeepSeek Search 与 Fetch API 位于 [dsh-web-deepseek-api](../web-deepseek-api/README.zh.md),由「网页搜索与获取」设置页的搜索和获取下拉框分别选择。内置配置选择 Search API,因此仅在选用 `searchProvider: deepseek-official` 时(例如网关未提供 Search API 路由)本提供方才会运行。
diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index c61771efe6..9ec2df4c97 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -33,6 +33,7 @@ "@deepseek-ai/dsh-credentials": "workspace:*", "@deepseek-ai/dsh-deepseek-account": "workspace:*", "@deepseek-ai/dsh-launch-environment": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-web": "workspace:*", "@deepseek-ai/cordis": "workspace:~" @@ -46,6 +47,7 @@ "@deepseek-ai/dsh-credentials-local": "workspace:*", "@deepseek-ai/dsh-deepseek-account": "workspace:*", "@deepseek-ai/dsh-launch-environment": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-web": "workspace:*", "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/web/web-search-deepseek/src/index.ts b/packages/web/web-search-deepseek/src/index.ts index 932a00368b..af7fad61a5 100644 --- a/packages/web/web-search-deepseek/src/index.ts +++ b/packages/web/web-search-deepseek/src/index.ts @@ -16,6 +16,9 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import type {} from '@deepseek-ai/dsh-deepseek-account' import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' +import { + deepSeekApiKeyConfigured, rejectDeepSeekAccountToken, resolveDeepSeekAccountToken, resolveDeepSeekApiKey, +} from '@deepseek-ai/dsh-llm-deepseek' import type {} from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-web' import { @@ -115,15 +118,10 @@ function resolveOptions( // request which called this search, as account sign-out reads it. const provider = ctx.get('agents')?.currentInitiator()?.session.requestContext()?.provider if (provider !== ACCOUNT_PROVIDER) return undefined - return await ctx.get('deepseekAccount')?.resolveToken(endpoint) - }, - resolveApiKey: async () => { - const credentials = ctx.get('credentials') - if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value - // Without the seam the environment is the whole credential plane. - const ambient = launchEnvironmentOf(ctx).get(apiKeyEnv) - return ambient !== undefined && ambient.value.length > 0 ? ambient.value : undefined + return await resolveDeepSeekAccountToken(ctx, endpoint) }, + rejectAccountToken: token => rejectDeepSeekAccountToken(ctx, token), + resolveApiKey: () => resolveDeepSeekApiKey(ctx, apiKeyEnv), apiKeyEnv, baseURL: config.baseURL ?? launchEnvironmentOf(ctx).get(SEARCH_BASE_URL_ENV)?.value @@ -163,12 +161,7 @@ async function searchConfigured( if (token !== undefined && token.length > 0) return true } if (options.apiKey !== undefined) return true - const credentials = ctx.get('credentials') - if (credentials === undefined) { - const ambient = launchEnvironmentOf(ctx).get(options.apiKeyEnv) - return ambient !== undefined && ambient.value.length > 0 - } - try { return (await credentials.describe(options.apiKeyEnv)).configured } + try { return await deepSeekApiKeyConfigured(ctx, options.apiKeyEnv) } catch (_unreadableStore) { return false /* A store that cannot be read supplies no key to the search either. */ } } @@ -184,6 +177,7 @@ export function apply(ctx: Context, config: Config): void { const provider = new DeepSeekSearchProvider(options) ctx.web.registerSearchProvider({ id: provider.id, + description: provider.description, available: () => provider.available(), ready: llmProvider => searchConfigured(ctx, options(), llmProvider), search: (request, signal) => provider.search(request, signal), diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts index 87924b666a..35490eee64 100644 --- a/packages/web/web-search-deepseek/src/provider.ts +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -98,6 +98,8 @@ export interface DeepSeekSearchProviderOptions { * `undefined` selects API-key authentication. */ resolveAccountToken?: (endpoint: string) => Promise + /** Invalidate the exact account token rejected by HTTP 401; failures must not replace the search error. */ + rejectAccountToken?: (token: string) => Promise /** Literal DeepSeek API key; when present it wins over {@link resolveApiKey}. */ apiKey?: string /** Resolve the current DeepSeek API key for one search operation. */ @@ -191,6 +193,8 @@ interface SearchAuth { readonly kind: 'account' | 'api-key' /** Headers carrying the credential. */ readonly headers: Readonly> + /** Request-bound invalidation, present only when an account token was sent. */ + readonly reject?: () => Promise } /** @@ -199,6 +203,17 @@ interface SearchAuth { */ export class DeepSeekSearchProvider implements WebSearchProvider { readonly id = DEEPSEEK_PROVIDER_ID + /** Picker description: what this provider suits and what it costs or cannot do. */ + readonly description: NonNullable = { + en: 'Runs server-side web search inside a full Messages model call, adding model latency and token usage on every search. Requires a DeepSeek account or API key.', + zh: '通过完整模型调用在服务端执行网页搜索,每次搜索都会产生模型延迟与 token 消耗。需 DeepSeek 账号或 API Key', + ja: '完全な Messages モデル呼び出しの中でサーバー側の Web 検索を実行するため、検索のたびにモデルの待ち時間とトークン消費が発生します。DeepSeek アカウントまたは API キーが必要です。', + es: 'Ejecuta la búsqueda web en el servidor dentro de una llamada completa al modelo de Messages, lo que añade latencia del modelo y consumo de tokens en cada búsqueda. Requiere una cuenta de DeepSeek o una clave de API.', + fr: 'Exécute la recherche web côté serveur dans un appel complet au modèle Messages, ce qui ajoute la latence du modèle et une consommation de jetons à chaque recherche. Nécessite un compte DeepSeek ou une clé API.', + de: 'Führt die Websuche serverseitig innerhalb eines vollständigen Messages-Modellaufrufs aus, was bei jeder Suche Modelllatenz und Tokenverbrauch verursacht. Erfordert ein DeepSeek-Konto oder einen API-Schlüssel.', + ru: 'Выполняет веб-поиск на сервере в рамках полного вызова модели Messages, поэтому каждый поиск добавляет задержку модели и расход токенов. Требуется учётная запись DeepSeek или ключ API.', + pt: 'Executa a pesquisa na web no servidor dentro de uma chamada completa ao modelo Messages, adicionando latência do modelo e consumo de tokens a cada pesquisa. Requer uma conta DeepSeek ou uma chave de API.', + } /** * @param resolveOptions - the options for the NEXT operation, snapshotted @@ -268,6 +283,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider { if (!response.ok) { const status = response.status let message = `DeepSeek API error (HTTP ${status})` + let aborted: WebError | undefined try { const parsed = await response.json() as AnthropicError const detail = typeof parsed.error === 'string' ? parsed.error : parsed.error?.message ?? parsed.message @@ -276,14 +292,21 @@ export class DeepSeekSearchProvider implements WebSearchProvider { // An abort fired mid-body must surface as WEB_ABORTED, not be swallowed // into a generic HTTP-error message — cancellation is not a provider // error (the seam's cancellation contract). - if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error) + if (signal?.aborted === true || isAbortError(error)) aborted = searchAborted(signal, error) // Otherwise: the HTTP status is already captured in `message` above; a // malformed/non-JSON error body (normal for gateway 5xx/429s) can only // cost a richer provider message, never the real error. } // The account service released this token only for its inference // origin, so the endpoint is not what the user must change. - if (status === 401 && auth.kind === 'account') throw accountRejectedError(message) + if (status === 401 && auth.kind === 'account') { + // Invalidation can cancel this Session; finish reading the HTTP failure before it does. A cancelled read + // still invalidates the token the status rejected. + try { await auth.reject?.() } + catch (_credentialRemovalFailed) { /* Report the original failure even if credential removal fails. */ } + throw aborted ?? accountRejectedError(message) + } + if (aborted !== undefined) throw aborted throw searchEndpointError(endpoint, message) } @@ -309,11 +332,14 @@ export class DeepSeekSearchProvider implements WebSearchProvider { private async authHeaders( options: DeepSeekSearchProviderOptions, endpoint: string, signal?: AbortSignal, ): Promise { - const { resolveAccountToken } = options + const { resolveAccountToken, rejectAccountToken } = options const token = resolveAccountToken === undefined ? undefined : await resolveCredential(() => resolveAccountToken(endpoint), signal) - if (token !== undefined && token.length > 0) return { kind: 'account', headers: { 'x-dsh-auth-token': token } } + if (token !== undefined && token.length > 0) return { + kind: 'account', headers: { 'x-dsh-auth-token': token }, + ...rejectAccountToken === undefined ? {} : { reject: () => rejectAccountToken(token) }, + } const apiKey = await this.apiKey(options, signal) // Official DeepSeek expects `x-api-key`; an Anthropic-compatible proxy // may expect `Authorization: Bearer` — send both so either resolves. @@ -381,8 +407,8 @@ function searchEndpointError(endpoint: string, message: string, cause?: unknown) return new WebError( `${message}\n\nThe web request used endpoint ${JSON.stringify(endpoint)}. ` + 'Search endpoint configuration is separate from chat. If that endpoint is not intended, ' - + 'guide the user to Plugins > Built-in plugin settings > Web search, where they can ' - + 'change and save Messages API base URL. If that settings page is unavailable, the user can set ' + + 'guide the user to Plugins > Built-in plugin settings > Web search and fetch, select DeepSeek (legacy) under Search, ' + + 'and change and save its Base URL. If that settings page is unavailable, the user can set ' + 'web-search-deepseek.baseURL to a trusted DeepSeek endpoint. Only the user should choose or change the endpoint.', 'WEB_PROVIDER_ERROR', cause === undefined ? undefined : { cause }, diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index b37a1affcc..3b52fc139a 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -9,6 +9,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Agent } from '@deepseek-ai/dsh-agent' import { credentialRef } from '@deepseek-ai/dsh-credentials' import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local' +import { createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment' import type { DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import WebRuntime, { WebError, type WebSearchResult } from '@deepseek-ai/dsh-web' @@ -187,12 +188,25 @@ describe('DeepSeekSearchProvider availability', () => { }) describe('DeepSeekSearchProvider request mapping', () => { + it.each(['bad key', '非法密钥'])('rejects malformed referenced keys before a legacy request: %s', async (value) => { + const ctx = new Context() + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + try { + ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot([{ source: 'process', values: { DEEPSEEK_API_KEY: value } }])) + await ctx.plugin(WebRuntime, { searchProvider: DEEPSEEK_PROVIDER_ID }) + await ctx.plugin(deepseekPlugin) + await expect(ctx.web.search({ query: 'q' })).rejects.toMatchObject({ code: 'WEB_PROVIDER_ERROR' }) + expect(fetchMock).not.toHaveBeenCalled() + } finally { await ctx.fiber.dispose() } + }) + it('records and posts the same Anthropic Messages request with the web_search server tool', async () => { - const fetchMock = vi.fn(async () => jsonResponse(searchResponse())) + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init: RequestInit = {}) => jsonResponse(searchResponse())) const recordRequest = vi.fn() vi.stubGlobal('fetch', fetchMock) await searchProvider({ ...options, recordRequest }).search({ query: 'hello' }) - const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + const [url, init = {}] = fetchMock.mock.calls[0]! expect(url).toBe('https://api.deepseek.test/anthropic/v1/messages') expect(init).toMatchObject({ method: 'POST', redirect: 'error' }) const headers = init.headers as Record @@ -217,11 +231,11 @@ describe('DeepSeekSearchProvider request mapping', () => { }) it('forwards the abort signal', async () => { - const fetchMock = vi.fn(async () => jsonResponse(searchResponse())) + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init: RequestInit = {}) => jsonResponse(searchResponse())) vi.stubGlobal('fetch', fetchMock) const controller = new AbortController() await searchProvider(options).search({ query: 'q' }, controller.signal) - const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + const [, init = {}] = fetchMock.mock.calls[0]! expect(init.signal).toBe(controller.signal) }) }) @@ -289,6 +303,45 @@ describe('DeepSeekSearchProvider account authentication', () => { }) }) + it.each([false, true])('invalidates the sent account token on 401 and keeps the sign-in guidance when removal fails: %s', async (fails) => { + const rejectAccountToken = vi.fn(async (_token: string) => { if (fails) throw new Error('storage failed') }) + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: 'invalid token' }, { status: 401 }))) + const error = await rejectedWebError(searchProvider({ + ...options, resolveAccountToken: async () => 'sent-token', rejectAccountToken, + }).search({ query: 'q' })) + expect(error.code).toBe('WEB_PROVIDER_ERROR') + expect(error.message).toBe('DeepSeek API error (HTTP 401): invalid token\n\n' + + 'DeepSeek rejected the account sign-in used for this web search. ' + + 'Guide the user to sign in to DeepSeek again; the search endpoint does not need changing.') + expect(rejectAccountToken).toHaveBeenCalledExactlyOnceWith('sent-token') + }) + + it.each([[401, undefined], [403, 'account-token']] as const)('does not invalidate a token for status %s and account token %s', async (status, token) => { + const rejectAccountToken = vi.fn(async (_token: string) => {}) + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({}, { status }))) + const error = await rejectedWebError(searchProvider({ + ...options, resolveAccountToken: async () => token, rejectAccountToken, + }).search({ query: 'q' })) + expect(error.code).toBe('WEB_PROVIDER_ERROR') + expect(rejectAccountToken).not.toHaveBeenCalled() + }) + + it('invalidates the sent account token once when cancellation interrupts reading the 401 body', async () => { + const controller = new AbortController() + const response = jsonResponse({ error: 'invalid token' }, { status: 401 }) + vi.spyOn(response, 'json').mockImplementation(async (): Promise => { + controller.abort(new Error('caller stopped')) + throw new DOMException('The operation was aborted.', 'AbortError') + }) + vi.stubGlobal('fetch', vi.fn(async () => response)) + const rejectAccountToken = vi.fn(async (_token: string) => {}) + const error = await rejectedWebError(searchProvider({ + ...options, resolveAccountToken: async () => 'sent-token', rejectAccountToken, + }).search({ query: 'q' }, controller.signal)) + expect(error.code).toBe('WEB_ABORTED') + expect(rejectAccountToken).toHaveBeenCalledExactlyOnceWith('sent-token') + }) + it('keeps endpoint guidance for other account-authenticated HTTP failures', async () => { vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({}, { status: 503 }))) const error = await rejectedWebError(searchProvider({ @@ -363,7 +416,7 @@ describe('DeepSeekSearchProvider settings changes mid-search', () => { const resolveApiKey = () => new Promise((resolve) => { commitSettings = () => { current = after; resolve('key-from-before') } }) - const fetchMock = vi.fn(async () => jsonResponse(searchResponse())) + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init: RequestInit = {}) => jsonResponse(searchResponse())) vi.stubGlobal('fetch', fetchMock) const provider = new DeepSeekSearchProvider(() => ({ ...current, resolveApiKey })) @@ -372,10 +425,11 @@ describe('DeepSeekSearchProvider settings changes mid-search', () => { commitSettings() await search - const [endpoint, init] = fetchMock.mock.calls[0] as unknown as [string, { headers: Record; body: string }] + const [endpoint, init = {}] = fetchMock.mock.calls[0]! // The key resolved from `before` must never reach `after`'s origin. expect(endpoint).toBe('https://before.test/v1/messages') - expect(init.headers['x-api-key']).toBe('key-from-before') + expect(new Headers(init.headers).get('x-api-key')).toBe('key-from-before') + if (typeof init.body !== 'string') throw new Error('expected JSON request body') expect(JSON.parse(init.body)).toMatchObject({ model: 'model-before' }) }) }) @@ -420,7 +474,7 @@ describe('DeepSeekSearchProvider error handling', () => { }) it('resolves credentials under an active cancellation signal', async () => { - const fetchMock = vi.fn(async () => jsonResponse(searchResponse())) + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init: RequestInit = {}) => jsonResponse(searchResponse())) vi.stubGlobal('fetch', fetchMock) const controller = new AbortController() await expect(searchProvider({ @@ -428,7 +482,7 @@ describe('DeepSeekSearchProvider error handling', () => { apiKey: '', resolveApiKey: async () => 'resolved-key', }).search({ query: 'q' }, controller.signal)).resolves.toMatchObject({ truncated: false }) - const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + const [, init = {}] = fetchMock.mock.calls[0]! expect((init.headers as Record)['x-api-key']).toBe('resolved-key') }) @@ -474,8 +528,8 @@ describe('DeepSeekSearchProvider error handling', () => { message: 'DeepSeek API error (HTTP 429): rate limited\n\n' + 'The web request used endpoint "https://api.deepseek.test/anthropic/v1/messages". ' + 'Search endpoint configuration is separate from chat. If that endpoint is not intended, ' - + 'guide the user to Plugins > Built-in plugin settings > Web search, where they can ' - + 'change and save Messages API base URL. If that settings page is unavailable, the user can set ' + + 'guide the user to Plugins > Built-in plugin settings > Web search and fetch, select DeepSeek (legacy) under Search, ' + + 'and change and save its Base URL. If that settings page is unavailable, the user can set ' + 'web-search-deepseek.baseURL to a trusted DeepSeek endpoint. Only the user should choose or change the endpoint.', })) }) @@ -528,15 +582,17 @@ describe('DeepSeekSearchProvider error handling', () => { }) it('surfaces an abort during success-body parse as WEB_ABORTED', async () => { - const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: true, status: 200 } - vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) + const body = new Response('', { status: 200 }) + vi.spyOn(body, 'json').mockRejectedValue(new DOMException('aborted', 'AbortError')) + vi.stubGlobal('fetch', vi.fn(async () => body)) await expect(searchProvider(options).search({ query: 'q' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) }) it('surfaces an abort during error-body parse as WEB_ABORTED', async () => { - const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: false, status: 500 } - vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) + const body = new Response('', { status: 500 }) + vi.spyOn(body, 'json').mockRejectedValue(new DOMException('aborted', 'AbortError')) + vi.stubGlobal('fetch', vi.fn(async () => body)) await expect(searchProvider(options).search({ query: 'q' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) }) @@ -558,7 +614,7 @@ describe('DeepSeekSearchProvider error handling', () => { describe('web-search-deepseek plugin registration', () => { it('registers the provider into ctx.web (HMR-safe)', async () => { - vi.stubGlobal('fetch', vi.fn(async () => jsonResponse(searchResponse()))) + vi.stubGlobal('fetch', vi.fn(async (_input: RequestInfo | URL, _init: RequestInit = {}) => jsonResponse(searchResponse()))) const ctx = new Context() await ctx.plugin(WebRuntime, { searchProvider: DEEPSEEK_PROVIDER_ID }) const fiber = await ctx.plugin(deepseekPlugin, { apiKey: 'ds-key' }) @@ -605,7 +661,7 @@ describe('web-search-deepseek plugin registration', () => { }) it('boots over ctx.web through the unwrapped module without an inject error', async () => { - vi.stubGlobal('fetch', vi.fn(async () => jsonResponse(searchResponse()))) + vi.stubGlobal('fetch', vi.fn(async (_input: RequestInfo | URL, _init: RequestInit = {}) => jsonResponse(searchResponse()))) const ctx = new Context() await ctx.plugin(WebRuntime, { searchProvider: DEEPSEEK_PROVIDER_ID }) const loader = Object.create(Loader.prototype) as Loader @@ -620,13 +676,13 @@ describe('web-search-deepseek plugin registration', () => { const prev = process.env.DEEPSEEK_API_KEY process.env.DEEPSEEK_API_KEY = 'env-key' try { - const fetchMock = vi.fn(async () => jsonResponse(searchResponse())) + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init: RequestInit = {}) => jsonResponse(searchResponse())) vi.stubGlobal('fetch', fetchMock) const ctx = new Context() await ctx.plugin(WebRuntime, { searchProvider: DEEPSEEK_PROVIDER_ID }) deepseekPlugin.apply(ctx, deepseekPlugin.Config({})) await ctx.web.search({ query: 'q' }) - const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + const [url, init = {}] = fetchMock.mock.calls[0]! expect(url).toBe('https://api.deepseek.com/anthropic/v1/messages') expect((init.headers as Record)['x-api-key']).toBe('env-key') expect(JSON.parse(init.body as string)).toMatchObject({ model: 'deepseek-v4-flash' }) @@ -732,6 +788,34 @@ describe('web-search-deepseek account route selection', () => { expect(headers).not.toHaveProperty('x-api-key') }) + it('keeps a sign-in that replaced the sent token before its late 401', async () => { + let current = 'old-token' + const rejectToken = vi.fn(async (token: string) => { if (token === current) current = '' }) + vi.stubGlobal('fetch', vi.fn(async () => { + current = 'new-token' + return jsonResponse({ error: 'invalid token' }, { status: 401 }) + })) + const ctx = new Context() + try { + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(WebRuntime, { searchProvider: DEEPSEEK_PROVIDER_ID }) + const account: Pick = { resolveToken: async () => current, rejectToken } + ctx.provide('deepseekAccount', account as DeepSeekAccount) + await ctx.plugin(deepseekPlugin, { apiKey: 'ds-key' }) + const session = ctx.sessions.create(SessionId('web-search-account-late-401')) + session.append('turn/start', { turn: 1 }) + session.append('request/context', { provider: 'deepseek-account', model: 'deepseek-v4-flash' }) + const error = await rejectedWebError(ctx.agents.withInitiator({ session } as Agent, () => ctx.web.search({ query: 'q' }))) + expect(error.message).toContain('HTTP 401') + expect(rejectToken).toHaveBeenCalledExactlyOnceWith('old-token') + expect(current).toBe('new-token') + } finally { + await ctx.fiber.dispose() + } + }) + it.each(['deepseek-official', undefined])('keeps API-key authentication for route %j', async (provider) => { const { headers, asked } = await searchAs(provider) expect(asked).toEqual([]) @@ -888,7 +972,7 @@ describe('web-search-deepseek search readiness', () => { ['a launch environment key', {}, 'env-key'], ])('is not ready on the account route when the account grant cannot be read, even with %s', async (_label, setup, envKey) => { if (envKey !== undefined) process.env.DEEPSEEK_API_KEY = envKey - const fetchMock = vi.fn(async () => jsonResponse(searchResponse())) + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init: RequestInit = {}) => jsonResponse(searchResponse())) vi.stubGlobal('fetch', fetchMock) const ctx = await compose({ ...setup, account: { resolveToken: () => Promise.reject(new Error('account storage unreadable')) } }) expect(await ctx.web.searchReady('deepseek-account')).toBe(false) diff --git a/packages/web/web-search-deepseek/tests/settings.spec.ts b/packages/web/web-search-deepseek/tests/settings.spec.ts index d47aa60d67..68e384693d 100644 --- a/packages/web/web-search-deepseek/tests/settings.spec.ts +++ b/packages/web/web-search-deepseek/tests/settings.spec.ts @@ -64,4 +64,15 @@ describe('web-search-deepseek settings section', () => { await bench.ctx.fiber.dispose() }) + it('lists the provider with its description for the Search menu', async () => { + const bench = await boot() + const [listed] = bench.ctx.web.listProviders().search + expect(listed).toMatchObject({ id: 'deepseek-official' }) + expect(listed).not.toHaveProperty('title') + expect(listed?.description).toMatchObject({ + en: 'Runs server-side web search inside a full Messages model call, adding model latency and token usage on every search. Requires a DeepSeek account or API key.', + zh: '通过完整模型调用在服务端执行网页搜索,每次搜索都会产生模型延迟与 token 消耗。需 DeepSeek 账号或 API Key', + }) + await bench.ctx.fiber.dispose() + }) }) diff --git a/packages/web/web-search-deepseek/tsconfig.json b/packages/web/web-search-deepseek/tsconfig.json index 423c1fc570..c3f7eec093 100644 --- a/packages/web/web-search-deepseek/tsconfig.json +++ b/packages/web/web-search-deepseek/tsconfig.json @@ -8,6 +8,7 @@ "src" ], "references": [ + { "path": "../../llm/llm-deepseek" }, { "path": "../../util/launch-environment" }, diff --git a/packages/web/web-search-exa/README.i18n.yaml b/packages/web/web-search-exa/README.i18n.yaml deleted file mode 100644 index b02cb565b7..0000000000 --- a/packages/web/web-search-exa/README.i18n.yaml +++ /dev/null @@ -1,61 +0,0 @@ -# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading -# section, a hash of its English and Chinese blocks outside code blocks and generated regions. -# After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/web/web-search-exa/README.md -/: - en: 4b8ba0a939fdf9d4 - zh: 8b8ddab7cef0f022 -/deepseek-ai-dsh-web-search-exa: - en: 1e59112a8781b6f5 - zh: 858672d6f86534b5 -/deepseek-ai-dsh-web-search-exa/summary: - en: 0cd54ea674cbf9ff - zh: 62214ad882ff0e44 -/deepseek-ai-dsh-web-search-exa/table-of-contents: - en: d152484eb41ac6b4 - zh: 09388d293f9be9cb -/deepseek-ai-dsh-web-search-exa/use-this-package: - en: 44ecd69ae3a9e8d3 - zh: b5f26aa1580f90fc -/deepseek-ai-dsh-web-search-exa/use-this-package/when-to-choose-it: - en: d33f61122c271792 - zh: e097094e69b3f425 -/deepseek-ai-dsh-web-search-exa/use-this-package/minimal-configuration: - en: a90f952a3cb9a155 - zh: 27c8402fb6f85be2 -/deepseek-ai-dsh-web-search-exa/use-this-package/what-a-search-returns: - en: 72c9eb8a1563bfd6 - zh: 26f7069376a74090 -/deepseek-ai-dsh-web-search-exa/use-this-package/failures-and-recovery: - en: 5740595fa2cebf20 - zh: 7316e46e09d8ad9f -/deepseek-ai-dsh-web-search-exa/understand-the-implementation: - en: 9842c69ad3636bb3 - zh: 3cdc3f816cbdd0cb -/deepseek-ai-dsh-web-search-exa/understand-the-implementation/design-philosophy: - en: 341f17a367eca5b0 - zh: ca3a4e4b4956892d -/deepseek-ai-dsh-web-search-exa/understand-the-implementation/source-map: - en: ca70cbf87a47c506 - zh: bb440072bbd6a478 -/deepseek-ai-dsh-web-search-exa/understand-the-implementation/request-and-mapping-flow: - en: 1fe92c2ad0408273 - zh: cc8c573e0accc8dc -/deepseek-ai-dsh-web-search-exa/further-exploration: - en: 3184010b324cfe4c - zh: e92c26f5ae57f21b -/deepseek-ai-dsh-web-search-exa/model-experience: - en: b1b5d9c71a0b07b4 - zh: 2802bef7723b6bf4 -/deepseek-ai-dsh-web-search-exa/model-experience/kv-cache-effect: - en: 40294c1c31b74824 - zh: d75d48f485447a74 -/deepseek-ai-dsh-web-search-exa/known-limitations-and-deferred-work: - en: 24860838d1b7462e - zh: 4eb921101d89c877 -/deepseek-ai-dsh-web-search-exa/known-limitations-and-deferred-work/dev-note: - en: 7f775cc79dbce94b - zh: 12b8f68f9e7c48ca -/deepseek-ai-dsh-web-search-exa/known-limitations-and-deferred-work/dev-note/future-wider-exa-control-surface: - en: 7a6c72d34be40225 - zh: b525946646a9f856 diff --git a/packages/web/web-search-exa/README.md b/packages/web/web-search-exa/README.md deleted file mode 100644 index b1e7862ccd..0000000000 --- a/packages/web/web-search-exa/README.md +++ /dev/null @@ -1,142 +0,0 @@ ---- -description: "The Exa-backed search provider for ctx.web: how deployments mount vendor-native web search with portable snippets and publication dates." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-web-search-exa - -English | [中文](README.zh.md) - -## Summary - -With `dsh-web-search-exa`, the harness searches the web through Exa and gets vendor-native results with portable snippets and publication dates. Choose it when a deployment has an Exa API key and wants Exa's keyword or neural search. Exa returns no generated answer, so results carry no `content` — only citeable sources. A result with no non-blank highlight is dropped, so a call can return fewer sources than requested. The model-facing `web_search` tool lives in `dsh-tool-web`. - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -Mount the provider in a composition that already loads the web service; it registers as the `exa` search provider, so `ctx.web.search()` resolves it automatically when it is the only usable search backend — or pin it with `searchProvider: exa`. - -### When to choose it - -Choose this backend when a deployment holds an Exa API key and wants Exa's keyword or neural search with per-result highlight snippets and publication dates. The provider is unavailable — and every search call fails with a structured error — when the key is empty or the endpoint base does not parse. - -### Minimal configuration - -Load the web service and the provider; the API key falls back to `$EXA_API_KEY` from the launch environment, and all other settings have safe defaults. - -```yaml -- name: '@deepseek-ai/dsh-web' -- name: '@deepseek-ai/dsh-web-search-exa' - config: - apiKey: !!js process.env.EXA_API_KEY -``` - -| Field | Default | Meaning | -|---|---|---| -| `apiKey` | `$EXA_API_KEY` | Exa API key; empty or absent makes the provider unavailable | -| `baseURL` | `https://api.exa.ai` | Endpoint base; `/search` is appended. An unparseable value makes the provider unavailable | -| `searchType` | `auto` | Retrieval mode sent as Exa's `type`: `auto`, `keyword`, or `neural` | -| `numResults` | (unset) | Default result count when a request carries no `maxResults`; must be a positive integer | -| `highlightsPerResult` | `1` | Highlight sentences requested per result (Exa's `highlightsPerUrl`); must be a positive integer | - -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-search-exa) is the exhaustive source for every accepted field and its JSDoc. - -### What a search returns - -Each Exa result maps to a `WebSearchSource`: `url`, `title`, the first non-blank highlight as `snippet`, and `publishedDate` as `publishedAt`; a result with no highlight has no portable snippet and is dropped. A request's `maxResults` wins over the configured `numResults` default and is sent to Exa as a cost and latency optimization — the final bound is enforced by the service, which truncates and flags. Exa returns no generated answer, so the result carries no `content`. - -### Failures and recovery - -Provider failures — HTTP errors, network failures, unparseable or wrong-shape bodies — surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`. Callers route on the code; the model-facing `web_search` tool surfaces failures to the model under its own error wrapper. - ------ - - -## Understand the implementation - -
-Implementation internals — click to expand - -This section explains the design decisions behind the provider; the observable behavior is fully covered in [Use this package](#use-this-package). - -### Design philosophy - -The provider is a thin adapter over Exa's API with two deliberate rules: - -- **Portable snippets only.** A source gains a `snippet` only from a real highlight; inventing one from other fields would make the seam lie, so snippet-less results are dropped entirely. -- **No invented answers.** Exa returns no generated answer, so `content` is omitted rather than fabricating provider prose the model might trust. - -### Source map - -| File | Role | -|---|---| -| [`src/index.ts`](src/index.ts) | Plugin entry: config schema, environment fallback, provider registration | -| [`src/provider.ts`](src/provider.ts) | The `ExaSearchProvider`: request dispatch, abort classification, result mapping | -| [`src/types.ts`](src/types.ts) | Exa wire types: `ExaSearchResponse`, `ExaResult`, `ExaError` | - -### Request and mapping flow - -`search()` posts the query, retrieval mode, highlight request, and optional result count to `{baseURL}/search` with `redirect: 'error'`, so a redirect fails the request without contacting the target. The parsed `results[]` are mapped one by one, snippet-less entries dropped, and the service applies the final `maxResults` bound on the way back. An abort — a `DOMException` named `AbortError` — becomes `WEB_ABORTED`; anything else becomes `WEB_PROVIDER_ERROR`. - -
- ------ - - -## Further Exploration - -Read these pages when the package-level contract is not enough. They move from the shared vocabulary to the service, the model-facing tools, and the design rationale. - -- [Web subsystem](../../../docs/subsystems/web.md) — the exhaustive search request/result vocabulary and error codes. -- [Web package map](../README.md) — the six-package family and each role. -- [dsh-web](../web/README.md) — the web service this provider registers into. -- [dsh-tool-web](../tool-web/README.md) — the model-facing `web_search` tool that renders this provider's sources. -- [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-search-exa) — every accepted config field and its source declaration. -- [Web capability seam decision](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) — why search and fetch share one provider-selection service. - ------ - - -## Model Experience - -Indirectly, through `dsh-tool-web`, which retains this provider's `maxResults`-bounded URLs, titles, first highlights, and publication dates or its exact `Exa search aborted`, `Exa search request failed: `, and `Exa returned an unprocessable response body: ` failures under the consumer's error wrapper. - -#### KV Cache effect - -No direct invalidation; the named consumer owns any request-prefix changes. - -## Known Limitations and Deferred Work - - - - -These limits define when the provider is a poor fit. They are current package constraints. - -- **A result with no non-blank highlight is dropped entirely** — there is no portable snippet to map, so fewer sources than requested can return. -- **Only `searchType`/`numResults`/`highlightsPerResult` are exposed** — Exa's other controls (livecrawl, category, domain/date filters, full-text contents) wait on provider-neutral service fields ([seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md)). -- **Abort classification is error-shape-based** — only a `DOMException` named `AbortError` maps to `WEB_ABORTED`; an abort carrying a custom reason (such as `dsh-timeout`'s `TimeoutReason`) surfaces as `WEB_PROVIDER_ERROR`. - - -### Dev Note - -
-Working context for maintainers — click to expand - -This Dev Note is working context for maintainers: open questions and undecided directions. It is explicitly non-authoritative — shipped behavior, limits, and rationale live in the sections above and the linked Agent Notes. - -#### Future: wider Exa control surface - -Exa's livecrawl, category, domain and date filters, and full-text contents stay unexposed. Exposing them needs provider-neutral service fields first, so the family adds one coordinated control rather than a vendor-specific argument. - -
diff --git a/packages/web/web-search-exa/README.zh.md b/packages/web/web-search-exa/README.zh.md deleted file mode 100644 index 5781e30d61..0000000000 --- a/packages/web/web-search-exa/README.zh.md +++ /dev/null @@ -1,142 +0,0 @@ ---- -description: "ctx.web 的 Exa 搜索提供方:部署方如何挂载厂商原生 web 搜索,获得可移植 snippet 与发布日期。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-web-search-exa - -[English](README.md) | 中文 - -## 概述 - -有了 `dsh-web-search-exa`,harness 可以通过 Exa 搜索 web,获得带可移植 snippet 与发布日期的厂商原生结果。当部署持有 Exa API 密钥、并希望使用 Exa 的关键词或神经搜索时选择它。Exa 不返回生成答案,因此结果不携带 `content`——只产出可引用的来源。没有非空白高亮的来源会被丢弃,因此一次调用返回的来源可能少于请求数量。面向模型的 `web_search` 工具位于 `dsh-tool-web`。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -在已加载 web 服务的组合中挂载本提供方;它以 `exa` 搜索提供方身份注册,因此当它是唯一可用的搜索后端时,`ctx.web.search()` 会自动解析到它——也可以用 `searchProvider: exa` 固定。 - -### 何时选择 - -当部署持有 Exa API 密钥,并希望使用 Exa 的关键词或神经搜索、获得每项结果的高亮 snippet 与发布日期时,选择此后端。密钥为空或端点基址无法解析时,提供方不可用——每次搜索调用都会以结构化错误失败。 - -### 最小配置 - -加载 web 服务与本提供方;API 密钥回退到启动环境中的 `$EXA_API_KEY`,其余设置都有安全默认值。 - -```yaml -- name: '@deepseek-ai/dsh-web' -- name: '@deepseek-ai/dsh-web-search-exa' - config: - apiKey: !!js process.env.EXA_API_KEY -``` - -| 字段 | 默认值 | 含义 | -|---|---|---| -| `apiKey` | `$EXA_API_KEY` | Exa API 密钥;为空或缺失时提供方不可用 | -| `baseURL` | `https://api.exa.ai` | 端点基址;追加 `/search`。无法解析时提供方不可用 | -| `searchType` | `auto` | 以 Exa `type` 发送的检索模式:`auto`、`keyword` 或 `neural` | -| `numResults` | (未设置) | 请求不含 `maxResults` 时使用的默认结果数;必须是正整数 | -| `highlightsPerResult` | `1` | 每个结果请求的 highlight 句子数(Exa `highlightsPerUrl`);必须是正整数 | - -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-search-exa)是每个受支持字段及其 JSDoc 的穷尽式真源。 - -### 搜索返回什么 - -每项 Exa 结果映射为 `WebSearchSource`:`url`、`title`、以首个非空白高亮作为 `snippet`、`publishedDate` 作为 `publishedAt`;没有高亮的来源缺少可移植的 snippet,会被丢弃。请求的 `maxResults` 优先于已配置的默认 `numResults`,并作为成本与延迟优化发送给 Exa——最终上限由服务强制执行:截断并标记。Exa 不返回生成答案,因此结果不携带 `content`。 - -### 失败与恢复 - -提供方失败——HTTP 错误、网络失败、响应体无法解析或结构不符——以 `WebError` `WEB_PROVIDER_ERROR` 呈现;中止请求以 `WEB_ABORTED` 呈现。HTTP 重定向会在访问 `Location` 指向的目标之前被拒绝,并以 `WEB_PROVIDER_ERROR` 呈现。调用方根据错误码进行分流;面向模型的 `web_search` 工具会在自己的错误包装层内把失败呈现给模型。 - ------ - - -## 理解实现 - -
-实现细节——点击展开 - -本节解释提供方背后的设计决策;可观察行为已在[使用本包](#use-this-package)中完整说明。 - -### 设计理念 - -该提供方是 Exa API 之上的薄适配器,遵循两条刻意的规则: - -- **只取可移植的 snippet。** 来源只有在真实高亮存在时才获得 `snippet`;用其他字段捏造会让 seam 说谎,因此没有 snippet 的结果被整个丢弃。 -- **不虚构答案。** Exa 不返回生成答案,因此省略 `content`,而不是编造模型可能信任的提供方文本。 - -### 源码地图 - -| 文件 | 职责 | -|---|---| -| [`src/index.ts`](src/index.ts) | 插件入口:配置 schema、环境变量回退、提供方注册 | -| [`src/provider.ts`](src/provider.ts) | `ExaSearchProvider`:请求分发、中止分类、结果映射 | -| [`src/types.ts`](src/types.ts) | Exa 协议类型:`ExaSearchResponse`、`ExaResult`、`ExaError` | - -### 请求与映射流程 - -`search()` 以 `redirect: 'error'` 把查询、检索模式、高亮请求与可选结果数 POST 到 `{baseURL}/search`,因此重定向会在不接触目标的情况下使请求失败。解析后的 `results[]` 逐项映射,没有 snippet 的条目被丢弃,服务在返回路径上应用最终的 `maxResults` 上限。中止——名为 `AbortError` 的 `DOMException`——变为 `WEB_ABORTED`;其余情况变为 `WEB_PROVIDER_ERROR`。 - -
- ------ - - -## 进一步探索 - -当包级约定不够用时阅读以下页面。它们从共享词汇逐步进入服务、面向模型的工具与设计依据。 - -- [web 子系统](../../../docs/subsystems/web.zh.md)——穷尽式的搜索请求/结果词汇与错误码。 -- [web 包映射](../README.zh.md)——六包家族与各角色。 -- [dsh-web](../web/README.zh.md)——本提供方注册进入的 web 服务。 -- [dsh-tool-web](../tool-web/README.zh.md)——渲染本提供方来源的面向模型 `web_search` 工具。 -- [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-search-exa)——每个受支持配置字段及其源声明。 -- [web 能力 seam 决策](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md)——搜索与抓取为何共用一项提供方选择服务。 - ------ - - -## 模型体验 - -通过 `dsh-tool-web` 间接影响模型体验。该工具保留本提供方经 `maxResults` 限制的 URL、标题、首条高亮与发布日期;如果发生失败,则会在消费方的错误包装层内保留原样错误消息 `Exa search aborted`、`Exa search request failed: ` 和 `Exa returned an unprocessable response body: `。 - -#### KV Cache 影响 - -不会直接导致 KV Cache 失效;请求前缀变更由上述消费方负责。 - -## 已知限制与延期工作 - - - - -这些限制说明提供方在哪些情况下不合适。它们是当前包约束。 - -- **没有非空白高亮的来源会被整个丢弃**——没有可映射的可移植 snippet,因此返回来源可能少于请求数量。 -- **只公开 `searchType`/`numResults`/`highlightsPerResult`**——Exa 的其他控制项(livecrawl、category、域名/日期过滤条件、全文内容)等待提供方无关的服务字段(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md))。 -- **按错误形状分类中止**——只有名为 `AbortError` 的 `DOMException` 才映射为 `WEB_ABORTED`;携带自定义原因的中止(例如 `dsh-timeout` 的 `TimeoutReason`)呈现为 `WEB_PROVIDER_ERROR`。 - - -### 开发备注 - -
-维护者的工作上下文——点击展开 - -本开发备注是维护者的工作上下文:开放问题与尚未决定的探索方向。它明确不具权威性——已交付的行为、限制与既定理由以上文和相关 Agent Note 为准。 - -#### 未来:更宽的 Exa 控制面 - -Exa 的 livecrawl、category、域名与日期过滤条件以及全文内容仍未公开。公开它们需要先有提供方无关的服务字段,让家族以一个协调一致的控制项、而非厂商专有参数的方式新增。 - -
diff --git a/packages/web/web-search-exa/locale/de.json b/packages/web/web-search-exa/locale/de.json deleted file mode 100644 index b34a2ecd25..0000000000 --- a/packages/web/web-search-exa/locale/de.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "meta": { - "title": "Exa-Websuche", - "description": "Durchsuchen Sie das Web mit Exa." - } -} diff --git a/packages/web/web-search-exa/locale/en.json b/packages/web/web-search-exa/locale/en.json deleted file mode 100644 index aaf280f917..0000000000 --- a/packages/web/web-search-exa/locale/en.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "meta": { - "title": "Exa web search", - "description": "Search the Web through Exa." - } -} diff --git a/packages/web/web-search-exa/locale/es.json b/packages/web/web-search-exa/locale/es.json deleted file mode 100644 index fcc3ee4c7c..0000000000 --- a/packages/web/web-search-exa/locale/es.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "meta": { - "title": "Exa búsqueda web", - "description": "Busque en la Web a través de Exa." - } -} diff --git a/packages/web/web-search-exa/locale/fr.json b/packages/web/web-search-exa/locale/fr.json deleted file mode 100644 index 8ee6236f6b..0000000000 --- a/packages/web/web-search-exa/locale/fr.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "meta": { - "title": "Recherche sur le Web Exa", - "description": "Recherchez sur le Web via Exa." - } -} diff --git a/packages/web/web-search-exa/locale/ja.json b/packages/web/web-search-exa/locale/ja.json deleted file mode 100644 index 7895b56cff..0000000000 --- a/packages/web/web-search-exa/locale/ja.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "meta": { - "title": "Exa Web 検索", - "description": "Exa を通じて Web 検索を提供します。" - } -} diff --git a/packages/web/web-search-exa/locale/pt.json b/packages/web/web-search-exa/locale/pt.json deleted file mode 100644 index eb2081f00d..0000000000 --- a/packages/web/web-search-exa/locale/pt.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "meta": { - "title": "Exa pesquisa na web", - "description": "Pesquise na Web através do Exa." - } -} diff --git a/packages/web/web-search-exa/locale/ru.json b/packages/web/web-search-exa/locale/ru.json deleted file mode 100644 index 8240ca4a31..0000000000 --- a/packages/web/web-search-exa/locale/ru.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "meta": { - "title": "Веб-поиск Exa", - "description": "Поиск в Интернете через Exa." - } -} diff --git a/packages/web/web-search-exa/locale/zh.json b/packages/web/web-search-exa/locale/zh.json deleted file mode 100644 index 76d864d659..0000000000 --- a/packages/web/web-search-exa/locale/zh.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "meta": { - "title": "Exa 网页搜索", - "description": "通过 Exa 提供网页搜索。" - } -} diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json deleted file mode 100644 index 9fead6e7e3..0000000000 --- a/packages/web/web-search-exa/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-web-search-exa", - "description": "Exa-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.2.2-alpha.2", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/web/web-search-exa" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./src/*": "./src/*", - "./locale/*.json": "./locale/*.json", - "./package.json": "./package.json" - }, - "files": [ - "locale/*.json", - "lib/index.js", - "lib/types/**/*.d.ts" - ], - "license": "MIT", - "peerDependencies": { - "@deepseek-ai/dsh-launch-environment": "workspace:*", - "@deepseek-ai/dsh-web": "workspace:*", - "@deepseek-ai/cordis": "workspace:~" - }, - "dependencies": { - "@deepseek-ai/schemastery": "workspace:~" - }, - "devDependencies": { - "@deepseek-ai/dsh-launch-environment": "workspace:*", - "@deepseek-ai/dsh-web": "workspace:*", - "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-http-proxy": "workspace:*" - } -} diff --git a/packages/web/web-search-exa/src/index.ts b/packages/web/web-search-exa/src/index.ts deleted file mode 100644 index a0a75e54ee..0000000000 --- a/packages/web/web-search-exa/src/index.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Exa-backed `WebSearchProvider` plugin. It contributes to the `ctx.web` - * registry without owning the service. - * - * @module @deepseek-ai/dsh-web-search-exa - */ - -import type { Context } from '@deepseek-ai/cordis' -import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' -import z from '@deepseek-ai/schemastery' -import type {} from '@deepseek-ai/dsh-web' -import { - ExaSearchProvider, - EXA_DEFAULT_BASE_URL, - EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, - EXA_DEFAULT_SEARCH_TYPE, -} from './provider.ts' - -export { - EXA_DEFAULT_BASE_URL, - EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, - EXA_DEFAULT_SEARCH_TYPE, - EXA_PROVIDER_ID, - ExaSearchProvider, -} from './provider.ts' -export type { ExaSearchProviderOptions } from './provider.ts' - -/** Cordis plugin name used by loader diagnostics. */ -export const name = 'web-search-exa' - -/** The web seam this provider registers into. */ -export const inject = ['web'] - -/** Plugin config (all optional — `apply` fills env-var and constant defaults). */ -export interface Config { - /** Exa API key. Falls back to `$EXA_API_KEY`. Empty → provider unavailable. */ - apiKey?: string - /** Endpoint base; `/search` is appended. Defaults to the public API. */ - baseURL?: string - /** Retrieval mode sent as Exa's `type`. Defaults to `auto`. */ - searchType?: 'auto' | 'keyword' | 'neural' - /** Default result count when a request carries no `maxResults`. Omitted = none. */ - numResults?: number - /** Highlight sentences requested per result. Defaults to 1. */ - highlightsPerResult?: number -} - -export const Config: z = z.object({ - apiKey: z.string(), - baseURL: z.string(), - searchType: z.union(['auto', 'keyword', 'neural'] as const), - numResults: z.number().step(1).min(1), - highlightsPerResult: z.number().step(1).min(1), -}) - -/** Register the Exa search provider with `ctx.web`. */ -export function apply(ctx: Context, config: Config): void { - ctx.web.registerSearchProvider(new ExaSearchProvider({ - // Every environment layer may name this key: the product trusts the - // project it is launched in, and the managed store is not involved here. - apiKey: config.apiKey ?? launchEnvironmentOf(ctx).get('EXA_API_KEY')?.value ?? '', - baseURL: config.baseURL ?? EXA_DEFAULT_BASE_URL, - searchType: config.searchType ?? EXA_DEFAULT_SEARCH_TYPE, - highlightsPerResult: config.highlightsPerResult ?? EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, - ...config.numResults !== undefined ? { numResults: config.numResults } : {}, - })) -} diff --git a/packages/web/web-search-exa/src/provider.ts b/packages/web/web-search-exa/src/provider.ts deleted file mode 100644 index a27449ccd4..0000000000 --- a/packages/web/web-search-exa/src/provider.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * `ExaSearchProvider`: a `WebSearchProvider` backed by the Exa search API (`POST /search` with - * highlight contents). It maps the first non-blank highlight to `snippet`, maps - * `publishedDate` to `publishedAt`, drops entries without a snippet, and omits `content` - * because Exa returns no generated answer. - * @module @deepseek-ai/dsh-web-search-exa/provider - */ - -import { WebError } from '@deepseek-ai/dsh-web' -import type { - WebSearchProvider, - WebSearchRequest, - WebSearchResult, - WebSearchSource, -} from '@deepseek-ai/dsh-web' -import type { ExaError, ExaResult, ExaSearchResponse } from './types.ts' - -/** Stable id this provider registers under. */ -export const EXA_PROVIDER_ID = 'exa' - -/** Default Exa search endpoint; `/search` is the operation. */ -export const EXA_DEFAULT_BASE_URL = 'https://api.exa.ai' - -/** Default retrieval mode: let Exa pick between keyword and neural search. */ -export const EXA_DEFAULT_SEARCH_TYPE = 'auto' - -/** Default number of highlight sentences requested per result. */ -export const EXA_DEFAULT_HIGHLIGHTS_PER_RESULT = 1 - -/** Attribution header sent on every request. Bump with the package version. */ -const USER_AGENT = 'deepseek-harness/0.0.1' - -/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */ -export interface ExaSearchProviderOptions { - /** Exa API key. Empty/absent makes the provider unavailable. */ - apiKey: string - /** Endpoint base; `/search` is appended. */ - baseURL: string - /** Retrieval mode sent as Exa's `type`. */ - searchType: 'auto' | 'keyword' | 'neural' - /** Default result count when a request carries no `maxResults`. */ - numResults?: number - /** Highlight sentences requested per result (Exa's `highlightsPerUrl`). */ - highlightsPerResult: number -} - -/** - * Map one Exa result to a normalized source, or `undefined` when it carries no - * portable snippet (an entry with no highlight is dropped — the seam has no - * other field to derive a snippet from, and inventing one would lie). - * - * @param result - one entry of Exa's `results[]`. - * @returns the normalized source, or `undefined` when the entry has no - * non-blank highlight. - */ -export function mapExaResult(result: ExaResult): WebSearchSource | undefined { - const snippet = result.highlights?.find(highlight => highlight.trim().length > 0) - if (snippet === undefined) return undefined - return { - url: result.url, - ...result.title != null && result.title.length > 0 ? { title: result.title } : {}, - snippet, - ...result.publishedDate != null && result.publishedDate.length > 0 ? { publishedAt: result.publishedDate } : {}, - } -} - -/** - * Map an Exa response envelope to a normalized search result. - * - * @param response - the parsed `POST /search` response body. - * @returns the normalized result; snippet-less entries are dropped - * ({@link mapExaResult}). - */ -export function mapExaResponse(response: ExaSearchResponse): WebSearchResult { - const sources = (response.results ?? []) - .map(mapExaResult) - .filter((source): source is WebSearchSource => source !== undefined) - // Exa returns no generated answer, so `content` is omitted. The web service owns the - // final `maxResults` truncation, so this provider reports `truncated: false`. - return { sources, truncated: false } -} - -/** The Exa-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */ -export class ExaSearchProvider implements WebSearchProvider { - readonly id = EXA_PROVIDER_ID - - constructor(private readonly options: ExaSearchProviderOptions) {} - - available(): boolean { - return this.options.apiKey.length > 0 - && isValidBaseUrl(this.options.baseURL) - && isPositiveInteger(this.options.highlightsPerResult) - && (this.options.numResults === undefined || isPositiveInteger(this.options.numResults)) - } - - async search(request: WebSearchRequest, signal?: AbortSignal): Promise { - // A per-request bound wins over the configured default; either may be absent. - const numResults = request.maxResults ?? this.options.numResults - let response: Response - try { - response = await fetch(`${this.options.baseURL}/search`, { - method: 'POST', - redirect: 'error', - headers: { - 'authorization': `Bearer ${this.options.apiKey}`, - 'content-type': 'application/json', - 'accept': 'application/json', - 'user-agent': USER_AGENT, - }, - body: JSON.stringify({ - query: request.query, - type: this.options.searchType, - contents: { highlights: { highlightsPerUrl: this.options.highlightsPerResult } }, - ...numResults !== undefined ? { numResults } : {}, - }), - ...signal !== undefined ? { signal } : {}, - }) - } catch (error: unknown) { - if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error }) - throw new WebError(`Exa search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) - } - - if (!response.ok) { - const status = response.status - let message = `Exa API error (HTTP ${status})` - try { - const parsed = await response.json() as ExaError - const detail = parsed.error ?? parsed.message - if (detail !== undefined && detail.length > 0) message = detail - } catch (error: unknown) { - // An abort fired mid-body must surface as WEB_ABORTED, not be swallowed - // into a generic HTTP-error message — cancellation is not a provider - // error (the seam's cancellation contract). - if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error }) - // Otherwise: the HTTP status is already captured in `message` above; a - // malformed/non-JSON error body (normal for gateway 5xx/429s) can only - // cost a richer provider message, never the real error. - } - throw new WebError(message, 'WEB_PROVIDER_ERROR') - } - - try { - const payload = await response.json() as ExaSearchResponse - return mapExaResponse(payload) - } catch (error: unknown) { - if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error }) - throw new WebError(`Exa returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) - } - } -} - -/** True when `baseURL` parses as an absolute URL (a cheap local config check). */ -function isValidBaseUrl(baseURL: string): boolean { - return URL.canParse(baseURL) -} - -/** True for a request limit that can be sent to Exa (a positive whole number). */ -function isPositiveInteger(value: number): boolean { - return Number.isInteger(value) && value > 0 -} - -/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */ -function isAbortError(error: unknown): boolean { - return error instanceof DOMException && error.name === 'AbortError' -} diff --git a/packages/web/web-search-exa/src/types.ts b/packages/web/web-search-exa/src/types.ts deleted file mode 100644 index fae42d07cd..0000000000 --- a/packages/web/web-search-exa/src/types.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Wire types for the Exa search API (`POST https://api.exa.ai/search`). Types - * only — no runtime code. Exa returns a flat `results[]`; each entry carries a - * URL, optional title, optional `publishedDate`, and (when highlights are - * requested) a `highlights[]` array of salient sentences. - * - * @module @deepseek-ai/dsh-web-search-exa/types - */ - -/** Request body sent to Exa's search endpoint. */ -export interface ExaSearchRequest { - query: string - /** Retrieval mode: keyword, neural (embeddings), or auto (Exa decides). */ - type: 'auto' | 'keyword' | 'neural' - /** Exa's result-count control; the seam still enforces the bound on return. */ - numResults?: number - /** Ask Exa to return highlight sentences per result. */ - contents: { highlights: { highlightsPerUrl: number } } -} - -/** One entry of Exa's flat `results[]`. */ -export interface ExaResult { - url: string - title?: string | null - publishedDate?: string | null - highlights?: string[] -} - -/** Exa's search response envelope. */ -export interface ExaSearchResponse { - results?: ExaResult[] -} - -/** Exa's error response envelope (best-effort; fields vary by failure). */ -export interface ExaError { - error?: string - message?: string -} diff --git a/packages/web/web-search-exa/tests/egress.spec.ts b/packages/web/web-search-exa/tests/egress.spec.ts deleted file mode 100644 index b2475abb94..0000000000 --- a/packages/web/web-search-exa/tests/egress.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { createServer, type Server } from 'node:http' -import type { AddressInfo } from 'node:net' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy' - -let seen: string[] = [] -let proxy: Server -let proxyUrl: string - -beforeAll(async () => { - proxy = createServer((request, response) => { - seen.push(`REQ ${request.url ?? ''}`) - response.writeHead(502); response.end('fake-proxy') - }) - proxy.on('connect', (request, socket) => { - seen.push(`CONNECT ${request.url ?? ''}`) - socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.end() - }) - const a = await new Promise((r) => { proxy.listen(0, '127.0.0.1', () => { r(proxy.address() as AddressInfo) }) }) - proxyUrl = `http://127.0.0.1:${String(a.port)}` -}) -afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) - -/** The launch environment of a user who exported one proxy for both schemes. */ -function proxyEnv(): { get(name: string): { value: string } | undefined } { - return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) } -} -async function observe(run: () => Promise): Promise { - seen = [] - const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined) - try { await run().catch(() => undefined) } finally { await dispose() } - return seen -} -import { ExaSearchProvider } from '../src/provider.ts' -describe('exa egress', () => { - it('goes through the proxy', async () => { - const p = new ExaSearchProvider({ apiKey: 'probe', baseURL: 'http://exa-probe.invalid', searchType: 'auto', highlightsPerResult: 1 }) - expect(await observe(() => p.search({ query: 'probe' }))).toEqual(['REQ http://exa-probe.invalid/search']) - }) -}) diff --git a/packages/web/web-search-exa/tests/exa.e2e.ts b/packages/web/web-search-exa/tests/exa.e2e.ts deleted file mode 100644 index 30a074d1e0..0000000000 --- a/packages/web/web-search-exa/tests/exa.e2e.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { ExaSearchProvider, EXA_DEFAULT_BASE_URL, EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, EXA_DEFAULT_SEARCH_TYPE } from '@deepseek-ai/dsh-web-search-exa' - -/** - * Real-API smoke for the Exa search provider. Self-skips without `$EXA_API_KEY` - * (CI has no secrets), per the with-key e2e policy in docs/testing.md. - */ -const apiKey = process.env.EXA_API_KEY -const maybe = apiKey !== undefined && apiKey.length > 0 ? describe : describe.skip - -maybe('ExaSearchProvider real API', () => { - it('returns sources for a live query', async () => { - const provider = new ExaSearchProvider({ - apiKey: apiKey!, - baseURL: process.env.EXA_BASE_URL ?? EXA_DEFAULT_BASE_URL, - searchType: EXA_DEFAULT_SEARCH_TYPE, - highlightsPerResult: EXA_DEFAULT_HIGHLIGHTS_PER_RESULT, - }) - const result = await provider.search({ query: 'DeepSeek Harness', maxResults: 5 }) - expect(result.sources.length).toBeGreaterThan(0) - for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//) - }, 30_000) -}) diff --git a/packages/web/web-search-exa/tests/exa.spec.ts b/packages/web/web-search-exa/tests/exa.spec.ts deleted file mode 100644 index 1825024349..0000000000 --- a/packages/web/web-search-exa/tests/exa.spec.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import WebRuntime from '@deepseek-ai/dsh-web' -import { ExaSearchProvider, EXA_PROVIDER_ID } from '@deepseek-ai/dsh-web-search-exa' -import * as exaPlugin from '@deepseek-ai/dsh-web-search-exa' -import { mapExaResponse, mapExaResult } from '../src/provider.ts' - -const options = { apiKey: 'exa-key', baseURL: 'https://api.exa.test', searchType: 'auto' as const, highlightsPerResult: 1 } - -function jsonResponse(body: unknown, init: ResponseInit = {}): Response { - return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, ...init }) -} - -afterEach(() => { - vi.unstubAllGlobals() -}) - -describe('Exa result mapping', () => { - it('maps a full result entry', () => { - expect(mapExaResult({ - url: 'https://a.test', - title: 'A', - publishedDate: '2026-01-01', - highlights: ['salient sentence', 'second'], - })).toEqual({ url: 'https://a.test', title: 'A', snippet: 'salient sentence', publishedAt: '2026-01-01' }) - }) - - it('drops a result with no usable highlight', () => { - expect(mapExaResult({ url: 'https://a.test', highlights: [] })).toBeUndefined() - expect(mapExaResult({ url: 'https://a.test' })).toBeUndefined() - expect(mapExaResult({ url: 'https://a.test', highlights: [' '] })).toBeUndefined() - }) - - it('omits null/empty optional fields rather than emitting them', () => { - expect(mapExaResult({ url: 'https://a.test', title: null, publishedDate: null, highlights: ['hi'] })) - .toEqual({ url: 'https://a.test', snippet: 'hi' }) - expect(mapExaResult({ url: 'https://a.test', title: '', publishedDate: '', highlights: ['hi'] })) - .toEqual({ url: 'https://a.test', snippet: 'hi' }) - }) - - it('maps a response to a result with no content and filtered sources', () => { - const result = mapExaResponse({ - results: [ - { url: 'https://a.test', highlights: ['one'] }, - { url: 'https://b.test' }, - { url: 'https://c.test', title: 'C', highlights: ['three'] }, - ], - }) - expect(result).toEqual({ - sources: [ - { url: 'https://a.test', snippet: 'one' }, - { url: 'https://c.test', title: 'C', snippet: 'three' }, - ], - truncated: false, - }) - expect(result.content).toBeUndefined() - }) - - it('tolerates a missing results array', () => { - expect(mapExaResponse({}).sources).toEqual([]) - }) - -}) - -describe('ExaSearchProvider availability', () => { - it('is unavailable without a key', () => { - expect(new ExaSearchProvider({ ...options, apiKey: '' }).available()).toBe(false) - }) - - it('is available with a key', () => { - expect(new ExaSearchProvider(options).available()).toBe(true) - }) - - it('is misconfigured when the base URL is unparseable', () => { - expect(new ExaSearchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false) - }) - - it('is misconfigured when highlightsPerResult is not a positive integer', () => { - expect(new ExaSearchProvider({ ...options, highlightsPerResult: 0 }).available()).toBe(false) - expect(new ExaSearchProvider({ ...options, highlightsPerResult: 1.5 }).available()).toBe(false) - }) - - it('is misconfigured when numResults is set but not a positive integer', () => { - expect(new ExaSearchProvider({ ...options, numResults: -1 }).available()).toBe(false) - }) -}) - -describe('ExaSearchProvider request mapping', () => { - it('sends query, type, highlights, numResults and bearer auth', async () => { - const fetchMock = vi.fn(async () => jsonResponse({ results: [{ url: 'https://a.test', highlights: ['hi'] }] })) - vi.stubGlobal('fetch', fetchMock) - - const provider = new ExaSearchProvider({ ...options, searchType: 'neural', highlightsPerResult: 3 }) - await provider.search({ query: 'hello', maxResults: 5 }) - - expect(fetchMock).toHaveBeenCalledOnce() - const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] - expect(url).toBe('https://api.exa.test/search') - expect(init).toMatchObject({ method: 'POST', redirect: 'error' }) - expect((init.headers as Record)['authorization']).toBe('Bearer exa-key') - expect(JSON.parse(init.body as string)).toEqual({ - query: 'hello', - type: 'neural', - contents: { highlights: { highlightsPerUrl: 3 } }, - numResults: 5, - }) - }) - - it('falls back to the configured numResults when a request omits maxResults', async () => { - const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) - vi.stubGlobal('fetch', fetchMock) - await new ExaSearchProvider({ ...options, numResults: 7 }).search({ query: 'q' }) - const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] - expect(JSON.parse(init.body as string)).toMatchObject({ numResults: 7 }) - }) - - it('lets a request maxResults win over the configured numResults', async () => { - const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) - vi.stubGlobal('fetch', fetchMock) - await new ExaSearchProvider({ ...options, numResults: 7 }).search({ query: 'q', maxResults: 2 }) - const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] - expect(JSON.parse(init.body as string)).toMatchObject({ numResults: 2 }) - }) - - it('omits numResults when neither maxResults nor a configured default is set', async () => { - const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) - vi.stubGlobal('fetch', fetchMock) - await new ExaSearchProvider(options).search({ query: 'q' }) - const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] - expect(JSON.parse(init.body as string)).not.toHaveProperty('numResults') - }) - - it('forwards the abort signal', async () => { - const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) - vi.stubGlobal('fetch', fetchMock) - const controller = new AbortController() - await new ExaSearchProvider(options).search({ query: 'q' }, controller.signal) - const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] - expect(init.signal).toBe(controller.signal) - }) -}) - -describe('ExaSearchProvider error handling', () => { - it('maps an HTTP error to WEB_PROVIDER_ERROR with the provider message', async () => { - vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: 'bad key' }, { status: 401 }))) - await expect(new ExaSearchProvider(options).search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR', message: 'bad key' })) - }) - - it('keeps a status-line message when the error body is not JSON', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response('gateway down', { status: 502 }))) - await expect(new ExaSearchProvider(options).search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR', message: 'Exa API error (HTTP 502)' })) - }) - - it('keeps the status-line message when the JSON error body carries no detail', async () => { - vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({}, { status: 500 }))) - await expect(new ExaSearchProvider(options).search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ message: 'Exa API error (HTTP 500)' })) - }) - - it('maps a network failure to WEB_PROVIDER_ERROR', async () => { - vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new TypeError('connection refused')))) - await expect(new ExaSearchProvider(options).search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) - }) - - it('maps an abort to WEB_ABORTED', async () => { - vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new DOMException('aborted', 'AbortError')))) - await expect(new ExaSearchProvider(options).search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) - }) - - it('maps an unparseable success body to WEB_PROVIDER_ERROR', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response('not json', { status: 200 }))) - await expect(new ExaSearchProvider(options).search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) - }) - - it('maps a well-formed body of the wrong shape to WEB_PROVIDER_ERROR, not a raw TypeError', async () => { - vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ results: {} }, { status: 200 }))) - await expect(new ExaSearchProvider(options).search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) - }) - - it('surfaces an abort during success-body parse as WEB_ABORTED, not provider error', async () => { - const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: true, status: 200 } - vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) - await expect(new ExaSearchProvider(options).search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) - }) - - it('surfaces an abort during error-body parse as WEB_ABORTED', async () => { - const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: false, status: 500 } - vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) - await expect(new ExaSearchProvider(options).search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) - }) -}) - -describe('web-search-exa plugin registration', () => { - it('registers the provider into ctx.web (HMR-safe)', async () => { - vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ results: [] }))) - const ctx = new Context() - await ctx.plugin(WebRuntime, { searchProvider: EXA_PROVIDER_ID }) - const fiber = await ctx.plugin(exaPlugin, { apiKey: 'exa-key' }) - await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ sources: [], truncated: false }) - await fiber.dispose() - await expect(ctx.web.search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) - }) - - it('has no default export (namespace plugin export shape)', () => { - expect('default' in exaPlugin).toBe(false) - }) - - it('threads searchType, highlightsPerResult and numResults config into the request', async () => { - const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) - vi.stubGlobal('fetch', fetchMock) - const ctx = new Context() - await ctx.plugin(WebRuntime, { searchProvider: EXA_PROVIDER_ID }) - const fiber = await ctx.plugin(exaPlugin, { apiKey: 'exa-key', searchType: 'keyword', highlightsPerResult: 2, numResults: 9 }) - await ctx.web.search({ query: 'q' }) - const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] - expect(JSON.parse(init.body as string)).toMatchObject({ type: 'keyword', contents: { highlights: { highlightsPerUrl: 2 } }, numResults: 9 }) - await fiber.dispose() - }) - - it('falls back to $EXA_API_KEY and the default base URL when config omits them', async () => { - const prev = process.env.EXA_API_KEY - process.env.EXA_API_KEY = 'env-key' - try { - const fetchMock = vi.fn(async () => jsonResponse({ results: [] })) - vi.stubGlobal('fetch', fetchMock) - const ctx = new Context() - await ctx.plugin(WebRuntime, { searchProvider: EXA_PROVIDER_ID }) - const fiber = await ctx.plugin(exaPlugin, {}) - await ctx.web.search({ query: 'q' }) - const [url] = fetchMock.mock.calls[0] as unknown as [string] - expect(url).toBe('https://api.exa.ai/search') - await fiber.dispose() - } finally { - if (prev === undefined) delete process.env.EXA_API_KEY - else process.env.EXA_API_KEY = prev - } - }) - - it('is unavailable when neither config nor env supplies a key', async () => { - const prev = process.env.EXA_API_KEY - delete process.env.EXA_API_KEY - try { - const ctx = new Context() - await ctx.plugin(WebRuntime, { searchProvider: EXA_PROVIDER_ID }) - await ctx.plugin(exaPlugin, {}) - await expect(ctx.web.search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' })) - } finally { - if (prev !== undefined) process.env.EXA_API_KEY = prev - } - }) -}) diff --git a/packages/web/web-search-exa/tsconfig.json b/packages/web/web-search-exa/tsconfig.json deleted file mode 100644 index bcab1fb0c8..0000000000 --- a/packages/web/web-search-exa/tsconfig.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../util/launch-environment" - }, - { - "path": "../../../vendor/cosmokit" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../../vendor/schemastery" - }, - { - "path": "../web" - }, - { - "path": "../../util/http-proxy" - } - ] -} diff --git a/packages/web/web-search-perplexity/README.i18n.yaml b/packages/web/web-search-perplexity/README.i18n.yaml deleted file mode 100644 index d208117640..0000000000 --- a/packages/web/web-search-perplexity/README.i18n.yaml +++ /dev/null @@ -1,82 +0,0 @@ -# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading -# section, a hash of its English and Chinese blocks outside code blocks and generated regions. -# After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/web/web-search-perplexity/README.md -/: - en: 526b58d952111706 - zh: 463049f0091c6fa3 -/deepseek-ai-dsh-web-search-perplexity: - en: 81fab27010167b07 - zh: 673d38ae33bd95c0 -/deepseek-ai-dsh-web-search-perplexity/summary: - en: 355bf2550d1655df - zh: c659fa3e25a02d91 -/deepseek-ai-dsh-web-search-perplexity/table-of-contents: - en: d152484eb41ac6b4 - zh: 09388d293f9be9cb -/deepseek-ai-dsh-web-search-perplexity/use-this-package: - en: 1b854e582c65f3b4 - zh: 0e5dd9657ac6a721 -/deepseek-ai-dsh-web-search-perplexity/use-this-package/when-to-choose-it: - en: 194e1c83cf5a3281 - zh: c262d5dd92b62ea7 -/deepseek-ai-dsh-web-search-perplexity/use-this-package/minimal-configuration: - en: c253774c9d932f39 - zh: 08f693d10dcd54ed -/deepseek-ai-dsh-web-search-perplexity/use-this-package/what-a-search-returns: - en: 99d01f74dcf5aed9 - zh: 3c7bdaa3bd35847f -/deepseek-ai-dsh-web-search-perplexity/use-this-package/failures-and-recovery: - en: 5740595fa2cebf20 - zh: d99555411a131d2f -/deepseek-ai-dsh-web-search-perplexity/understand-the-implementation: - en: 9842c69ad3636bb3 - zh: 3cdc3f816cbdd0cb -/deepseek-ai-dsh-web-search-perplexity/understand-the-implementation/design-philosophy: - en: aee86ee45c4635c0 - zh: 45cecc44ae467520 -/deepseek-ai-dsh-web-search-perplexity/understand-the-implementation/source-map: - en: 401317176e3117f5 - zh: 2fad7652819eaa03 -/deepseek-ai-dsh-web-search-perplexity/understand-the-implementation/request-and-mapping-flow: - en: ff5efa9c7b1a9cda - zh: ca1e246eb8e7eaee -/deepseek-ai-dsh-web-search-perplexity/further-exploration: - en: 67cf289420662cf2 - zh: 9f7e6e7d32e45ee7 -/deepseek-ai-dsh-web-search-perplexity/model-experience: - en: 215e7ba838619b7b - zh: a311da3843709f90 -/deepseek-ai-dsh-web-search-perplexity/model-experience/auxiliary-perplexity-request: - en: 9d564a5863828a46 - zh: 143112cfc85fe3f3 -/deepseek-ai-dsh-web-search-perplexity/model-experience/auxiliary-perplexity-request/what-the-model-sees: - en: eacb10a5e907e796 - zh: 74fbd30db7c983a1 -/deepseek-ai-dsh-web-search-perplexity/model-experience/auxiliary-perplexity-request/token-effect: - en: 5f5b2bf40168d371 - zh: 25bc3b50bc74ff20 -/deepseek-ai-dsh-web-search-perplexity/model-experience/auxiliary-perplexity-request/kv-cache-effect: - en: ca308291b52b8028 - zh: 12e8f89f79a2756c -/deepseek-ai-dsh-web-search-perplexity/model-experience/conversation-tool-result-indirectly: - en: 666f5753c99783ab - zh: b44328e99bcc45c9 -/deepseek-ai-dsh-web-search-perplexity/model-experience/conversation-tool-result-indirectly/what-the-model-sees: - en: 1bab9dad335b62eb - zh: 1d64c077b88c316e -/deepseek-ai-dsh-web-search-perplexity/model-experience/conversation-tool-result-indirectly/token-effect: - en: 8c5d9983ba129daf - zh: 3e7889629bed353d -/deepseek-ai-dsh-web-search-perplexity/model-experience/conversation-tool-result-indirectly/kv-cache-effect: - en: f5d5f97a153f7c70 - zh: f681f89596f500f5 -/deepseek-ai-dsh-web-search-perplexity/known-limitations-and-deferred-work: - en: db86c590e6ffb159 - zh: 35c4cfa9f4b6a1fa -/deepseek-ai-dsh-web-search-perplexity/known-limitations-and-deferred-work/dev-note: - en: 7f775cc79dbce94b - zh: 12b8f68f9e7c48ca -/deepseek-ai-dsh-web-search-perplexity/known-limitations-and-deferred-work/dev-note/future-wider-perplexity-control-surface: - en: 86cd7f67c96e6b0e - zh: ab991807ddf2f470 diff --git a/packages/web/web-search-perplexity/README.md b/packages/web/web-search-perplexity/README.md deleted file mode 100644 index 88990ab324..0000000000 --- a/packages/web/web-search-perplexity/README.md +++ /dev/null @@ -1,165 +0,0 @@ ---- -description: "The Perplexity-backed search provider for ctx.web: how deployments mount OpenAI-compatible Perplexity search with generated answers and citations." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-web-search-perplexity - -English | [中文](README.zh.md) - -## Summary - -With `dsh-web-search-perplexity`, the harness searches the web through Perplexity and gets a model-generated answer plus citeable sources in one call. Choose it when a deployment has a Perplexity API key and wants a generated answer. Perplexity has no result-count control, so the returned sources are truncated to the requested bound after the fact. When Perplexity omits structured result metadata, sources fall back to URL-only citations. The model-facing `web_search` tool lives in `dsh-tool-web`. - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -Mount the provider in a composition that already loads the web service; it registers as the `perplexity` search provider, so `ctx.web.search()` resolves it automatically when it is the only usable search backend — or pin it with `searchProvider: perplexity`. - -### When to choose it - -Choose this backend when a deployment holds a Perplexity API key and wants a model-generated answer plus citeable sources in one search. The provider is unavailable — and every search call fails with a structured error — when the key is empty or the endpoint base does not parse. - -### Minimal configuration - -Load the web service and the provider; the API key falls back to `$PERPLEXITY_API_KEY` from the launch environment, and all other settings have safe defaults. - -```yaml -- name: '@deepseek-ai/dsh-web' -- name: '@deepseek-ai/dsh-web-search-perplexity' - config: - apiKey: !!js process.env.PERPLEXITY_API_KEY -``` - -| Field | Default | Meaning | -|---|---|---| -| `apiKey` | `$PERPLEXITY_API_KEY` | Perplexity API key; empty or absent makes the provider unavailable | -| `baseURL` | `https://api.perplexity.ai` | Endpoint base; `/chat/completions` is appended. An unparseable value makes the provider unavailable | -| `model` | `sonar` | Search model name | -| `maxTokens` | `1024` | Upper bound on generated answer tokens (`max_tokens`); must be a positive integer | -| `searchRecency` | (unset) | Recency window sent as `search_recency_filter`: `day`, `week`, `month`, or `year`. Unset sends no filter | - -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-search-perplexity) is the exhaustive source for every accepted field and its JSDoc. - -### What a search returns - -`content` carries Perplexity's generated answer. `sources[]` prefers the structured `search_results[]` (`url`, `title`, `snippet`, `publishedAt` from `date`) and falls back to the URL-only `citations[]` array only when `search_results` is absent — which is why `title`/`snippet`/`publishedAt` are optional on the service. Perplexity exposes no result-count control, so the service enforces `maxResults` by truncating and flagging. - -### Failures and recovery - -Provider failures — HTTP errors, network failures, unparseable or wrong-shape bodies — surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`. Callers route on the code; the model-facing `web_search` tool surfaces failures to the model under its own error wrapper. - ------ - - -## Understand the implementation - -
-Implementation internals — click to expand - -This section explains the design decisions behind the provider; the observable behavior is fully covered in [Use this package](#use-this-package). - -### Design philosophy - -The provider is a thin adapter over Perplexity's chat-completions endpoint with two deliberate rules: - -- **The generated answer is trusted as `content`.** Unlike the other search backends, Perplexity returns a model-generated answer, and this provider passes it through as the normalized `content` field. -- **Structured sources win; URL-only citations are the fallback.** `search_results[]` carries the portable fields; `citations[]` carries only URLs, and the service vocabulary makes those fields optional precisely for this case. - -### Source map - -| File | Role | -|---|---| -| [`src/index.ts`](src/index.ts) | Plugin entry: config schema, environment fallback, provider registration | -| [`src/provider.ts`](src/provider.ts) | The `PerplexitySearchProvider`: request dispatch, abort classification, answer and source mapping | -| [`src/types.ts`](src/types.ts) | Perplexity wire types for the chat-completions response | - -### Request and mapping flow - -`search()` posts the query with the model, token cap, and optional recency filter to `{baseURL}/chat/completions` with `redirect: 'error'`. The response's `content` becomes `content`; `search_results[]` becomes `sources[]` when present, otherwise each `citations[]` entry becomes a URL-only source; and the service applies the final `maxResults` bound on the way back. An abort — a `DOMException` named `AbortError` — becomes `WEB_ABORTED`; anything else becomes `WEB_PROVIDER_ERROR`. - -
- ------ - - -## Further Exploration - -Read these pages when the package-level contract is not enough. They move from the shared vocabulary to the service, the model-facing tools, and the design rationale. - -- [Web subsystem](../../../docs/subsystems/web.md) — the exhaustive search request/result vocabulary and error codes. -- [Web package map](../README.md) — the six-package family and each role. -- [dsh-web](../web/README.md) — the web service this provider registers into. -- [dsh-tool-web](../tool-web/README.md) — the model-facing `web_search` tool that renders this provider's sources. -- [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-search-perplexity) — every accepted config field and its source declaration. -- [Web capability seam decision](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md) — why search and fetch share one provider-selection service. - ------ - - -## Model Experience - -### Auxiliary Perplexity request - -#### What the model sees - -A separate Perplexity model receives `` verbatim as its sole user message through the chat-completions endpoint. This request is not part of the conversation model's context. - -#### Token effect - -Separate provider tokens are incurred per search; `maxTokens` caps the generated answer. - -#### KV Cache effect - -Independent of the conversation request cache. An identical query under the same model route may reuse provider cache; a changed query or route establishes a different prefix. - -### Conversation tool result, indirectly - -#### What the model sees - -Through `dsh-tool-web`, the conversation model sees the generated answer plus structured result metadata or URL-only citations. This provider's exact failures are `Perplexity search aborted`, `Perplexity search request failed: `, and `Perplexity returned an unprocessable response body: `; HTTP failures preserve the provider message. The consumer owns the error wrapper. - -#### Token effect - -Zero direct conversation tokens from registration. Answer and source tokens are data-dependent, source count is service-bounded, and the retained result or error is resent until compaction. - -#### KV Cache effect - -Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. - -## Known Limitations and Deferred Work - - - - -These limits define when the provider is a poor fit. They are current package constraints. - -- **Citation-fallback sources are URL-only** — when Perplexity omits structured `search_results[]`, sources carry no `title`/`snippet`/`publishedAt`, so the tool renders bare hostname labels. -- **Over-returned sources still cost tokens and latency** — with no result-count control on the wire, `maxResults` is enforced only post-hoc by service truncation. -- **Only `model`/`maxTokens`/`searchRecency` are exposed** — Perplexity's other search controls (domain filters, `web_search_options` context size, images) wait on provider-neutral service fields ([seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md)). -- **Abort classification is error-shape-based** — only a `DOMException` named `AbortError` maps to `WEB_ABORTED`; an abort carrying a custom reason (such as `dsh-timeout`'s `TimeoutReason`) surfaces as `WEB_PROVIDER_ERROR`. - - -### Dev Note - -
-Working context for maintainers — click to expand - -This Dev Note is working context for maintainers: open questions and undecided directions. It is explicitly non-authoritative — shipped behavior, limits, and rationale live in the sections above and the linked Agent Notes. - -#### Future: wider Perplexity control surface - -Perplexity's domain filters, `web_search_options` context size, and image support stay unexposed. Exposing them needs provider-neutral service fields first, so the family adds one coordinated control rather than a vendor-specific argument. - -
diff --git a/packages/web/web-search-perplexity/README.zh.md b/packages/web/web-search-perplexity/README.zh.md deleted file mode 100644 index 9c29b9cf5c..0000000000 --- a/packages/web/web-search-perplexity/README.zh.md +++ /dev/null @@ -1,165 +0,0 @@ ---- -description: "ctx.web 的 Perplexity 搜索提供方:部署方如何挂载 OpenAI 兼容的 Perplexity 搜索,获得生成答案与引用。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-web-search-perplexity - -[English](README.md) | 中文 - -## 概述 - -有了 `dsh-web-search-perplexity`,harness 可以通过 Perplexity 搜索 web,一次调用同时获得模型生成的答案与可引用来源。当部署持有 Perplexity API 密钥、并希望获得生成答案时选择它。Perplexity 没有结果数量控制,因此返回的来源会在事后被截断到请求的上限。Perplexity 省略结构化结果元数据时,来源回退为只含 URL 的引用。面向模型的 `web_search` 工具位于 `dsh-tool-web`。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -在已加载 web 服务的组合中挂载本提供方;它以 `perplexity` 搜索提供方身份注册,因此当它是唯一可用的搜索后端时,`ctx.web.search()` 会自动解析到它——也可以用 `searchProvider: perplexity` 固定。 - -### 何时选择 - -当部署持有 Perplexity API 密钥、并希望一次搜索同时获得模型生成的答案与可引用来源时选择此后端。密钥为空或端点基址无法解析时,提供方不可用——每次搜索调用都会以结构化错误失败。 - -### 最小配置 - -加载 web 服务与本提供方;API 密钥回退到启动环境中的 `$PERPLEXITY_API_KEY`,其余设置都有安全默认值。 - -```yaml -- name: '@deepseek-ai/dsh-web' -- name: '@deepseek-ai/dsh-web-search-perplexity' - config: - apiKey: !!js process.env.PERPLEXITY_API_KEY -``` - -| 字段 | 默认值 | 含义 | -|---|---|---| -| `apiKey` | `$PERPLEXITY_API_KEY` | Perplexity API 密钥;为空或缺失时提供方不可用 | -| `baseURL` | `https://api.perplexity.ai` | 端点基址;追加 `/chat/completions`。无法解析时提供方不可用 | -| `model` | `sonar` | 搜索模型名称 | -| `maxTokens` | `1024` | 生成答案 token 上限(`max_tokens`);必须是正整数 | -| `searchRecency` | (未设置) | 以 `search_recency_filter` 发送的新近程度窗口:`day`、`week`、`month` 或 `year`。未设置时不发送过滤条件 | - -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-search-perplexity)是每个受支持字段及其 JSDoc 的穷尽式真源。 - -### 搜索返回什么 - -`content` 携带 Perplexity 的生成答案。`sources[]` 优先使用结构化 `search_results[]`(`url`、`title`、`snippet`、`publishedAt` 取自 `date`),仅当 `search_results` 缺失时才回退到只含 URL 的 `citations[]` 数组——这正是服务上 `title`/`snippet`/`publishedAt` 为可选字段的原因。Perplexity 不公开结果数量控制,因此服务通过截断并标记来强制执行 `maxResults`。 - -### 失败与恢复 - -提供方失败——HTTP 错误、网络失败、响应体无法解析或结构不符——以 `WebError` `WEB_PROVIDER_ERROR` 呈现;中止请求以 `WEB_ABORTED` 呈现。HTTP 重定向会在访问 `Location` 指向的目标之前被拒绝,并以 `WEB_PROVIDER_ERROR` 呈现。调用方根据错误码进行路由;面向模型的 `web_search` 工具会在自己的错误包装层内把失败呈现给模型。 - ------ - - -## 理解实现 - -
-实现细节——点击展开 - -本节解释提供方背后的设计决策;可观察行为已在[使用本包](#use-this-package)中完整说明。 - -### 设计理念 - -该提供方是 Perplexity chat-completions 端点之上的薄适配器,遵循两条刻意的规则: - -- **生成答案直接用作 `content`。** 与其他搜索后端不同,Perplexity 返回模型生成的答案,本提供方将其作为规范化 `content` 字段透传。 -- **结构化来源优先;只含 URL 的引用是回退。** `search_results[]` 携带可移植字段;`citations[]` 只携带 URL,服务词汇把这些字段设为可选,正是为了这种情况。 - -### 源码地图 - -| 文件 | 职责 | -|---|---| -| [`src/index.ts`](src/index.ts) | 插件入口:配置 schema、环境变量回退、提供方注册 | -| [`src/provider.ts`](src/provider.ts) | `PerplexitySearchProvider`:请求分发、中止分类、答案与来源映射 | -| [`src/types.ts`](src/types.ts) | chat-completions 响应的 Perplexity 协议类型 | - -### 请求与映射流程 - -`search()` 以 `redirect: 'error'` 把查询连同模型、token 上限与可选新近程度过滤条件 POST 到 `{baseURL}/chat/completions`。响应的 `content` 变为 `content`;存在 `search_results[]` 时它变为 `sources[]`,否则每个 `citations[]` 条目变为只含 URL 的来源;服务在返回路径上应用最终的 `maxResults` 上限。中止——名为 `AbortError` 的 `DOMException`——变为 `WEB_ABORTED`;其余情况变为 `WEB_PROVIDER_ERROR`。 - -
- ------ - - -## 进一步探索 - -当包级约定不够用时阅读以下页面。它们从共享词汇逐步进入服务、面向模型的工具与设计依据。 - -- [web 子系统](../../../docs/subsystems/web.zh.md)——穷尽式的搜索请求/结果词汇与错误码。 -- [web 包映射](../README.zh.md)——六包家族与各角色。 -- [dsh-web](../web/README.zh.md)——本提供方注册进入的 web 服务。 -- [dsh-tool-web](../tool-web/README.zh.md)——渲染本提供方来源的面向模型 `web_search` 工具。 -- [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-search-perplexity)——每个受支持配置字段及其源声明。 -- [web 能力 seam 决策](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md)——搜索与抓取为何共用一项提供方选择服务。 - ------ - - -## 模型体验 - -### 辅助 Perplexity 请求 - -#### 模型看到的内容 - -独立的 Perplexity 模型通过 chat-completions 端点将 `` 原样作为唯一用户消息接收。该请求不属于会话模型上下文。 - -#### Token 影响 - -每次搜索都会产生独立的提供方 token;`maxTokens` 限制生成答案。 - -#### KV Cache 影响 - -与会话请求缓存相互独立。同一模型路由下的相同查询可能复用提供方缓存;查询或路由改变会建立不同前缀。 - -### 间接的会话工具结果 - -#### 模型看到的内容 - -通过 `dsh-tool-web`,会话模型会看到生成答案及结构化结果元数据,或只含 URL 的引用。该提供方确切的错误消息为 `Perplexity search aborted`、`Perplexity search request failed: ` 和 `Perplexity returned an unprocessable response body: `;HTTP 失败保留提供方消息。错误包装层属于消费方。 - -#### Token 影响 - -注册不会直接产生会话 token。答案与来源 token 取决于数据,来源数量受服务限制;保留的结果或错误会重复发送,直到发生压缩(compaction)。 - -#### KV Cache 影响 - -仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 - -## 已知限制与延期工作 - - - - -这些限制说明提供方在哪些情况下不合适。它们是当前包约束。 - -- **引用回退来源只含 URL**——Perplexity 省略结构化 `search_results[]` 时,来源不含 `title`/`snippet`/`publishedAt`,因此工具只渲染纯主机名标签。 -- **超量返回的来源仍会增加 token 消耗与延迟**——协议没有结果数量控制,`maxResults` 只能由服务在事后截断。 -- **只公开 `model`/`maxTokens`/`searchRecency`**——Perplexity 的其他搜索控制项(域名过滤条件、`web_search_options` 上下文大小、图片)等待提供方无关的服务字段(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md))。 -- **按错误形状分类中止**——只有名为 `AbortError` 的 `DOMException` 才映射为 `WEB_ABORTED`;携带自定义原因的中止(例如 `dsh-timeout` 的 `TimeoutReason`)呈现为 `WEB_PROVIDER_ERROR`。 - - -### 开发备注 - -
-维护者的工作上下文——点击展开 - -本开发备注是维护者的工作上下文:开放问题与尚未决定的探索方向。它明确不具权威性——已交付的行为、限制与既定理由以上文和相关 Agent Note 为准。 - -#### 未来:更宽的 Perplexity 控制面 - -Perplexity 的域名过滤条件、`web_search_options` 上下文大小与图片支持仍未公开。公开它们需要先有提供方无关的服务字段,让家族以一个协调一致的控制项、而非厂商专有参数的方式新增。 - -
diff --git a/packages/web/web-search-perplexity/locale/de.json b/packages/web/web-search-perplexity/locale/de.json deleted file mode 100644 index b689965afe..0000000000 --- a/packages/web/web-search-perplexity/locale/de.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "meta": { - "title": "Verwirrung bei der Websuche", - "description": "Durchsuchen Sie das Web mithilfe von Perplexity." - } -} diff --git a/packages/web/web-search-perplexity/locale/en.json b/packages/web/web-search-perplexity/locale/en.json deleted file mode 100644 index 08e111e12b..0000000000 --- a/packages/web/web-search-perplexity/locale/en.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "meta": { - "title": "Perplexity web search", - "description": "Search the Web through Perplexity." - } -} diff --git a/packages/web/web-search-perplexity/locale/es.json b/packages/web/web-search-perplexity/locale/es.json deleted file mode 100644 index fd9386d848..0000000000 --- a/packages/web/web-search-perplexity/locale/es.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "meta": { - "title": "Búsqueda web de perplejidad", - "description": "Busque en la Web a través de la perplejidad." - } -} diff --git a/packages/web/web-search-perplexity/locale/fr.json b/packages/web/web-search-perplexity/locale/fr.json deleted file mode 100644 index 7753f0205b..0000000000 --- a/packages/web/web-search-perplexity/locale/fr.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "meta": { - "title": "Recherche sur le Web Perplexité", - "description": "Recherchez sur le Web grâce à Perplexity." - } -} diff --git a/packages/web/web-search-perplexity/locale/ja.json b/packages/web/web-search-perplexity/locale/ja.json deleted file mode 100644 index c0d8cdc4f2..0000000000 --- a/packages/web/web-search-perplexity/locale/ja.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "meta": { - "title": "Perplexity Web 検索", - "description": "Perplexity を通じて Web 検索を提供します。" - } -} diff --git a/packages/web/web-search-perplexity/locale/pt.json b/packages/web/web-search-perplexity/locale/pt.json deleted file mode 100644 index 474178de63..0000000000 --- a/packages/web/web-search-perplexity/locale/pt.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "meta": { - "title": "Pesquisa na web de perplexidade", - "description": "Pesquise na Web através da perplexidade." - } -} diff --git a/packages/web/web-search-perplexity/locale/ru.json b/packages/web/web-search-perplexity/locale/ru.json deleted file mode 100644 index bec57bdb8f..0000000000 --- a/packages/web/web-search-perplexity/locale/ru.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "meta": { - "title": "Недоумение в веб-поиске", - "description": "Поиск в Интернете через недоумение." - } -} diff --git a/packages/web/web-search-perplexity/locale/zh.json b/packages/web/web-search-perplexity/locale/zh.json deleted file mode 100644 index 40affd5b09..0000000000 --- a/packages/web/web-search-perplexity/locale/zh.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "meta": { - "title": "Perplexity 网页搜索", - "description": "通过 Perplexity 提供网页搜索。" - } -} diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json deleted file mode 100644 index 93d3dfd555..0000000000 --- a/packages/web/web-search-perplexity/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-web-search-perplexity", - "description": "Perplexity-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.2.2-alpha.2", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/web/web-search-perplexity" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./src/*": "./src/*", - "./locale/*.json": "./locale/*.json", - "./package.json": "./package.json" - }, - "files": [ - "locale/*.json", - "lib/index.js", - "lib/types/**/*.d.ts" - ], - "license": "MIT", - "peerDependencies": { - "@deepseek-ai/dsh-launch-environment": "workspace:*", - "@deepseek-ai/dsh-web": "workspace:*", - "@deepseek-ai/cordis": "workspace:~" - }, - "dependencies": { - "@deepseek-ai/schemastery": "workspace:~" - }, - "devDependencies": { - "@deepseek-ai/dsh-launch-environment": "workspace:*", - "@deepseek-ai/dsh-web": "workspace:*", - "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-http-proxy": "workspace:*" - } -} diff --git a/packages/web/web-search-perplexity/src/index.ts b/packages/web/web-search-perplexity/src/index.ts deleted file mode 100644 index 5584658093..0000000000 --- a/packages/web/web-search-perplexity/src/index.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Perplexity-backed `WebSearchProvider` plugin. It contributes to the - * `ctx.web` registry without owning the service. - * - * @module @deepseek-ai/dsh-web-search-perplexity - */ - -import type { Context } from '@deepseek-ai/cordis' -import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' -import z from '@deepseek-ai/schemastery' -import type {} from '@deepseek-ai/dsh-web' -import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAULT_MAX_TOKENS, PERPLEXITY_DEFAULT_MODEL } from './provider.ts' - -export { - PERPLEXITY_DEFAULT_BASE_URL, - PERPLEXITY_DEFAULT_MAX_TOKENS, - PERPLEXITY_DEFAULT_MODEL, - PERPLEXITY_PROVIDER_ID, - PerplexitySearchProvider, -} from './provider.ts' -export type { PerplexityRecency, PerplexitySearchProviderOptions } from './provider.ts' - -/** Cordis plugin name used by loader diagnostics. */ -export const name = 'web-search-perplexity' - -/** The web seam this provider registers into. */ -export const inject = ['web'] - -/** Plugin config (all optional — `apply` fills env-var and constant defaults). */ -export interface Config { - /** Perplexity API key. Falls back to `$PERPLEXITY_API_KEY`. Empty → unavailable. */ - apiKey?: string - /** Endpoint base; `/chat/completions` is appended. Defaults to the public API. */ - baseURL?: string - /** Search model name. Defaults to `sonar`. */ - model?: string - /** Upper bound on generated answer tokens. Defaults to 1024. */ - maxTokens?: number - /** Recency window sent as `search_recency_filter`. Omitted = no filter. */ - searchRecency?: 'day' | 'week' | 'month' | 'year' -} - -export const Config: z = z.object({ - apiKey: z.string(), - baseURL: z.string(), - model: z.string(), - maxTokens: z.number().step(1).min(1), - searchRecency: z.union(['day', 'week', 'month', 'year'] as const), -}) - -/** Register the Perplexity search provider with `ctx.web`. */ -export function apply(ctx: Context, config: Config): void { - ctx.web.registerSearchProvider(new PerplexitySearchProvider({ - // Every environment layer may name this key: the product trusts the - // project it is launched in, and the managed store is not involved here. - apiKey: config.apiKey ?? launchEnvironmentOf(ctx).get('PERPLEXITY_API_KEY')?.value ?? '', - baseURL: config.baseURL ?? PERPLEXITY_DEFAULT_BASE_URL, - model: config.model ?? PERPLEXITY_DEFAULT_MODEL, - maxTokens: config.maxTokens ?? PERPLEXITY_DEFAULT_MAX_TOKENS, - ...config.searchRecency !== undefined ? { searchRecency: config.searchRecency } : {}, - })) -} diff --git a/packages/web/web-search-perplexity/src/provider.ts b/packages/web/web-search-perplexity/src/provider.ts deleted file mode 100644 index 284174aec3..0000000000 --- a/packages/web/web-search-perplexity/src/provider.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Perplexity search over its OpenAI-compatible chat-completions endpoint. The generated answer - * becomes `content`; sources prefer structured `search_results[]` and fall back to URL-only - * `citations[]`. The wire format and native `fetch` client are provider-private and do not use - * `ctx.llm`. - * @module @deepseek-ai/dsh-web-search-perplexity/provider - */ - -import { WebError } from '@deepseek-ai/dsh-web' -import type { - WebSearchProvider, - WebSearchRequest, - WebSearchResult, - WebSearchSource, -} from '@deepseek-ai/dsh-web' -import type { PerplexityError, PerplexityResponse, PerplexitySearchResult } from './types.ts' - -/** Stable id this provider registers under. */ -export const PERPLEXITY_PROVIDER_ID = 'perplexity' - -/** Default Perplexity endpoint; `/chat/completions` is the operation. */ -export const PERPLEXITY_DEFAULT_BASE_URL = 'https://api.perplexity.ai' - -/** Default search model. */ -export const PERPLEXITY_DEFAULT_MODEL = 'sonar' - -/** Default upper bound on generated answer tokens. */ -export const PERPLEXITY_DEFAULT_MAX_TOKENS = 1024 - -/** Recency filter values Perplexity accepts for `search_recency_filter`. */ -export type PerplexityRecency = 'day' | 'week' | 'month' | 'year' - -/** Attribution header sent on every request. Bump with the package version. */ -const USER_AGENT = 'deepseek-harness/0.0.1' - -/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */ -export interface PerplexitySearchProviderOptions { - /** Perplexity API key. Empty/absent makes the provider unavailable. */ - apiKey: string - /** Endpoint base; `/chat/completions` is appended. */ - baseURL: string - /** Search model name. */ - model: string - /** Upper bound on generated answer tokens (`max_tokens`). */ - maxTokens: number - /** Optional recency window sent as `search_recency_filter`; omitted = no filter. */ - searchRecency?: PerplexityRecency -} - -/** - * Map one structured Perplexity search result to a normalized source. - * - * @param result - one entry of the response's `search_results[]`. - * @returns the normalized source; blank fields are omitted rather than set empty. - */ -export function mapPerplexityResult(result: PerplexitySearchResult): WebSearchSource { - return { - url: result.url, - ...result.title != null && result.title.length > 0 ? { title: result.title } : {}, - ...result.snippet != null && result.snippet.length > 0 ? { snippet: result.snippet } : {}, - ...result.date != null && result.date.length > 0 ? { publishedAt: result.date } : {}, - } -} - -/** - * Map a Perplexity response envelope to a normalized search result. Prefers - * structured `search_results[]`; falls back to URL-only `citations[]` (those - * sources carry just a `url`) only when `search_results` is absent. - * - * @param response - the parsed chat-completions response body. - * @returns the normalized result; `content` is omitted when the answer is empty. - */ -export function mapPerplexityResponse(response: PerplexityResponse): WebSearchResult { - const content = response.choices?.[0]?.message?.content - const sources: WebSearchSource[] = response.search_results !== undefined - ? response.search_results.map(mapPerplexityResult) - : (response.citations ?? []).map(url => ({ url })) - return { - ...content != null && content.length > 0 ? { content } : {}, - sources, - truncated: false, - } -} - -/** The Perplexity-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */ -export class PerplexitySearchProvider implements WebSearchProvider { - readonly id = PERPLEXITY_PROVIDER_ID - - constructor(private readonly options: PerplexitySearchProviderOptions) {} - - // Availability checks stay beside each provider's distinct config contract; - // a shared base class would obscure which fields make this backend usable. - /* jscpd:ignore-start */ - available(): boolean { - return this.options.apiKey.length > 0 - && URL.canParse(this.options.baseURL) - && isPositiveInteger(this.options.maxTokens) - } - /* jscpd:ignore-end */ - - async search(request: WebSearchRequest, signal?: AbortSignal): Promise { - let response: Response - try { - response = await fetch(`${this.options.baseURL}/chat/completions`, { - method: 'POST', - redirect: 'error', - headers: { - 'authorization': `Bearer ${this.options.apiKey}`, - 'content-type': 'application/json', - 'accept': 'application/json', - 'user-agent': USER_AGENT, - }, - body: JSON.stringify({ - model: this.options.model, - max_tokens: this.options.maxTokens, - messages: [{ role: 'user', content: request.query }], - ...this.options.searchRecency !== undefined ? { search_recency_filter: this.options.searchRecency } : {}, - }), - ...signal !== undefined ? { signal } : {}, - }) - } catch (error: unknown) { - if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error }) - throw new WebError(`Perplexity search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) - } - - if (!response.ok) { - const status = response.status - let message = `Perplexity API error (HTTP ${status})` - try { - const parsed = await response.json() as PerplexityError - const detail = typeof parsed.error === 'string' ? parsed.error : parsed.error?.message ?? parsed.message - if (detail !== undefined && detail.length > 0) message = detail - } catch (error: unknown) { - // An abort fired mid-body must surface as WEB_ABORTED, not be swallowed - // into a generic HTTP-error message — cancellation is not a provider - // error (the seam's cancellation contract). - if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error }) - // Otherwise: the HTTP status is already captured in `message` above; a - // malformed/non-JSON error body (normal for gateway 5xx/429s) can only - // cost a richer provider message, never the real error. - } - throw new WebError(message, 'WEB_PROVIDER_ERROR') - } - - try { - const payload = await response.json() as PerplexityResponse - return mapPerplexityResponse(payload) - } catch (error: unknown) { - if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error }) - throw new WebError(`Perplexity returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) - } - } -} - -// These two predicates are intentionally local: exporting generic internals -// from the public web seam would add more API than these pure checks. -/* jscpd:ignore-start */ -/** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */ -function isAbortError(error: unknown): boolean { - return error instanceof DOMException && error.name === 'AbortError' -} - -/** True for a request limit that can be sent to Perplexity (a positive whole number). */ -function isPositiveInteger(value: number): boolean { - return Number.isInteger(value) && value > 0 -} -/* jscpd:ignore-end */ diff --git a/packages/web/web-search-perplexity/src/types.ts b/packages/web/web-search-perplexity/src/types.ts deleted file mode 100644 index 822c5db379..0000000000 --- a/packages/web/web-search-perplexity/src/types.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Wire types for the Perplexity search API (`POST https://api.perplexity.ai/chat/completions`, - * an OpenAI-compatible chat shape). Results prefer structured `search_results` and fall back to - * URL-only `citations`; the provider-private wire shape does not depend on `ctx.llm`. - * @module @deepseek-ai/dsh-web-search-perplexity/types - */ - -/** Request body sent to Perplexity's chat-completions endpoint. */ -export interface PerplexityRequest { - model: string - messages: { role: 'user'; content: string }[] -} - -/** One structured search result (the preferred citation shape). */ -export interface PerplexitySearchResult { - url: string - title?: string | null - snippet?: string | null - date?: string | null -} - -/** Perplexity's response envelope. */ -export interface PerplexityResponse { - choices?: { message?: { content?: string | null } }[] - /** Structured citation data (preferred). */ - search_results?: PerplexitySearchResult[] - /** URL-only citation fallback. */ - citations?: string[] -} - -/** Perplexity's error response envelope (best-effort; fields vary). */ -export interface PerplexityError { - error?: { message?: string } | string - message?: string -} diff --git a/packages/web/web-search-perplexity/tests/egress.spec.ts b/packages/web/web-search-perplexity/tests/egress.spec.ts deleted file mode 100644 index 542cb1db48..0000000000 --- a/packages/web/web-search-perplexity/tests/egress.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { createServer, type Server } from 'node:http' -import type { AddressInfo } from 'node:net' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy' - -let seen: string[] = [] -let proxy: Server -let proxyUrl: string - -beforeAll(async () => { - proxy = createServer((request, response) => { - seen.push(`REQ ${request.url ?? ''}`) - response.writeHead(502); response.end('fake-proxy') - }) - proxy.on('connect', (request, socket) => { - seen.push(`CONNECT ${request.url ?? ''}`) - socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.end() - }) - const a = await new Promise((r) => { proxy.listen(0, '127.0.0.1', () => { r(proxy.address() as AddressInfo) }) }) - proxyUrl = `http://127.0.0.1:${String(a.port)}` -}) -afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) - -/** The launch environment of a user who exported one proxy for both schemes. */ -function proxyEnv(): { get(name: string): { value: string } | undefined } { - return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) } -} -async function observe(run: () => Promise): Promise { - seen = [] - const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined) - try { await run().catch(() => undefined) } finally { await dispose() } - return seen -} -import { PerplexitySearchProvider } from '../src/provider.ts' -describe('perplexity egress', () => { - it('goes through the proxy', async () => { - const p = new PerplexitySearchProvider({ apiKey: 'probe', baseURL: 'http://ppx-probe.invalid', model: 'm', maxTokens: 16 }) - expect(await observe(() => p.search({ query: 'probe' }))).toEqual(['REQ http://ppx-probe.invalid/chat/completions']) - }) -}) diff --git a/packages/web/web-search-perplexity/tests/perplexity.e2e.ts b/packages/web/web-search-perplexity/tests/perplexity.e2e.ts deleted file mode 100644 index ccbae206d3..0000000000 --- a/packages/web/web-search-perplexity/tests/perplexity.e2e.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAULT_MAX_TOKENS, PERPLEXITY_DEFAULT_MODEL } from '@deepseek-ai/dsh-web-search-perplexity' - -/** - * Real-API smoke for the Perplexity search provider. Self-skips without - * `$PERPLEXITY_API_KEY`, per the with-key e2e policy in docs/testing.md. - */ -const apiKey = process.env.PERPLEXITY_API_KEY -const maybe = apiKey !== undefined && apiKey.length > 0 ? describe : describe.skip - -maybe('PerplexitySearchProvider real API', () => { - it('returns a generated answer and sources for a live query', async () => { - const provider = new PerplexitySearchProvider({ - apiKey: apiKey!, - baseURL: process.env.PERPLEXITY_BASE_URL ?? PERPLEXITY_DEFAULT_BASE_URL, - model: process.env.PERPLEXITY_MODEL ?? PERPLEXITY_DEFAULT_MODEL, - maxTokens: PERPLEXITY_DEFAULT_MAX_TOKENS, - }) - const result = await provider.search({ query: 'What is DeepSeek Harness?', maxResults: 5 }) - expect(result.content ?? '').not.toBe('') - for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//) - }, 30_000) -}) diff --git a/packages/web/web-search-perplexity/tests/perplexity.spec.ts b/packages/web/web-search-perplexity/tests/perplexity.spec.ts deleted file mode 100644 index 4090524771..0000000000 --- a/packages/web/web-search-perplexity/tests/perplexity.spec.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import WebRuntime from '@deepseek-ai/dsh-web' -import { - PerplexitySearchProvider, - PERPLEXITY_PROVIDER_ID, -} from '@deepseek-ai/dsh-web-search-perplexity' -import * as perplexityPlugin from '@deepseek-ai/dsh-web-search-perplexity' -import { mapPerplexityResponse } from '../src/provider.ts' - -const options = { apiKey: 'pplx-key', baseURL: 'https://api.perplexity.test', model: 'sonar', maxTokens: 1024 } - -function jsonResponse(body: unknown, init: ResponseInit = {}): Response { - return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' }, ...init }) -} - -afterEach(() => { - vi.unstubAllGlobals() -}) - -describe('Perplexity response mapping', () => { - it('maps the answer and prefers structured search_results', () => { - const result = mapPerplexityResponse({ - choices: [{ message: { content: 'the answer' } }], - search_results: [ - { url: 'https://a.test', title: 'A', snippet: 'snip', date: '2026-02-02' }, - { url: 'https://b.test' }, - ], - citations: ['https://ignored.test'], - }) - expect(result).toEqual({ - content: 'the answer', - sources: [ - { url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-02-02' }, - { url: 'https://b.test' }, - ], - truncated: false, - }) - }) - - it('falls back to URL-only citations when search_results is absent', () => { - const result = mapPerplexityResponse({ - choices: [{ message: { content: 'answer' } }], - citations: ['https://a.test', 'https://b.test'], - }) - expect(result.sources).toEqual([{ url: 'https://a.test' }, { url: 'https://b.test' }]) - }) - - it('omits content when the answer is empty or missing', () => { - expect(mapPerplexityResponse({ citations: [] }).content).toBeUndefined() - expect(mapPerplexityResponse({ choices: [{ message: { content: '' } }] }).content).toBeUndefined() - expect(mapPerplexityResponse({ choices: [{ message: { content: null } }] }).content).toBeUndefined() - }) - - it('omits null/empty optional source fields', () => { - const result = mapPerplexityResponse({ - search_results: [{ url: 'https://a.test', title: null, snippet: '', date: null }], - }) - expect(result.sources).toEqual([{ url: 'https://a.test' }]) - }) - - it('yields no sources when neither search_results nor citations are present', () => { - expect(mapPerplexityResponse({ choices: [{ message: { content: 'a' } }] }).sources).toEqual([]) - }) -}) - -describe('PerplexitySearchProvider availability', () => { - it('is unavailable without a key', () => { - expect(new PerplexitySearchProvider({ ...options, apiKey: '' }).available()).toBe(false) - }) - - it('is available with a key', () => { - expect(new PerplexitySearchProvider(options).available()).toBe(true) - }) - - it('is misconfigured when the base URL is unparseable', () => { - expect(new PerplexitySearchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false) - }) - - it('is misconfigured when maxTokens is not a positive integer', () => { - expect(new PerplexitySearchProvider({ ...options, maxTokens: 0 }).available()).toBe(false) - expect(new PerplexitySearchProvider({ ...options, maxTokens: 1.5 }).available()).toBe(false) - }) -}) - -describe('PerplexitySearchProvider request mapping', () => { - it('sends a chat-completions request with the query, model and max_tokens', async () => { - const fetchMock = vi.fn(async () => jsonResponse({ choices: [{ message: { content: 'a' } }], citations: [] })) - vi.stubGlobal('fetch', fetchMock) - await new PerplexitySearchProvider(options).search({ query: 'hello' }) - const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] - expect(url).toBe('https://api.perplexity.test/chat/completions') - expect(init).toMatchObject({ method: 'POST', redirect: 'error' }) - expect((init.headers as Record)['authorization']).toBe('Bearer pplx-key') - expect(JSON.parse(init.body as string)).toEqual({ model: 'sonar', max_tokens: 1024, messages: [{ role: 'user', content: 'hello' }] }) - }) - - it('sends search_recency_filter when configured, and omits it otherwise', async () => { - const fetchMock = vi.fn(async () => jsonResponse({ choices: [{ message: { content: 'a' } }], citations: [] })) - vi.stubGlobal('fetch', fetchMock) - await new PerplexitySearchProvider({ ...options, searchRecency: 'week' }).search({ query: 'q' }) - expect(JSON.parse((fetchMock.mock.calls[0] as unknown as [string, RequestInit])[1].body as string)).toMatchObject({ search_recency_filter: 'week' }) - - await new PerplexitySearchProvider(options).search({ query: 'q' }) - expect(JSON.parse((fetchMock.mock.calls[1] as unknown as [string, RequestInit])[1].body as string)).not.toHaveProperty('search_recency_filter') - }) - - it('forwards the abort signal', async () => { - const fetchMock = vi.fn(async () => jsonResponse({ citations: [] })) - vi.stubGlobal('fetch', fetchMock) - const controller = new AbortController() - await new PerplexitySearchProvider(options).search({ query: 'q' }, controller.signal) - const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] - expect(init.signal).toBe(controller.signal) - }) -}) - -describe('PerplexitySearchProvider error handling', () => { - it('maps an HTTP error to WEB_PROVIDER_ERROR with the provider message', async () => { - vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: { message: 'rate limited' } }, { status: 429 }))) - await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR', message: 'rate limited' })) - }) - - it('handles a string-form error body', async () => { - vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: 'bad request' }, { status: 400 }))) - await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ message: 'bad request' })) - }) - - it('maps a well-formed body of the wrong shape to WEB_PROVIDER_ERROR, not a raw TypeError', async () => { - vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ search_results: null }, { status: 200 }))) - await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) - }) - - it('keeps a status-line message when the error body is not JSON', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response('upstream error', { status: 503 }))) - await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ message: 'Perplexity API error (HTTP 503)' })) - }) - - it('keeps the status-line message when the JSON error body carries no detail', async () => { - vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({}, { status: 500 }))) - await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ message: 'Perplexity API error (HTTP 500)' })) - }) - - it('maps an abort to WEB_ABORTED', async () => { - vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new DOMException('aborted', 'AbortError')))) - await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) - }) - - it('maps an unparseable success body to WEB_PROVIDER_ERROR', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response('not json', { status: 200 }))) - await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) - }) - - it('surfaces an abort during success-body parse as WEB_ABORTED, not provider error', async () => { - const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: true, status: 200 } - vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) - await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) - }) - - it('surfaces an abort during error-body parse as WEB_ABORTED', async () => { - const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: false, status: 500 } - vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) - await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) - }) - - it('maps a network failure to WEB_PROVIDER_ERROR', async () => { - vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new TypeError('connection refused')))) - await expect(new PerplexitySearchProvider(options).search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) - }) -}) - -describe('web-search-perplexity plugin registration', () => { - it('registers the provider into ctx.web (HMR-safe)', async () => { - vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ choices: [{ message: { content: 'a' } }], citations: [] }))) - const ctx = new Context() - await ctx.plugin(WebRuntime, { searchProvider: PERPLEXITY_PROVIDER_ID }) - const fiber = await ctx.plugin(perplexityPlugin, { apiKey: 'pplx-key' }) - await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ content: 'a', sources: [] }) - await fiber.dispose() - await expect(ctx.web.search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) - }) - - it('has no default export (namespace plugin export shape)', () => { - expect('default' in perplexityPlugin).toBe(false) - }) - - it('threads maxTokens and searchRecency config into the request', async () => { - const fetchMock = vi.fn(async () => jsonResponse({ choices: [{ message: { content: 'a' } }], citations: [] })) - vi.stubGlobal('fetch', fetchMock) - const ctx = new Context() - await ctx.plugin(WebRuntime, { searchProvider: PERPLEXITY_PROVIDER_ID }) - const fiber = await ctx.plugin(perplexityPlugin, { apiKey: 'pplx-key', maxTokens: 256, searchRecency: 'month' }) - await ctx.web.search({ query: 'q' }) - const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] - expect(JSON.parse(init.body as string)).toMatchObject({ max_tokens: 256, search_recency_filter: 'month' }) - await fiber.dispose() - }) - - it('falls back to env key and defaults for base URL and model when config omits them', async () => { - const prev = process.env.PERPLEXITY_API_KEY - process.env.PERPLEXITY_API_KEY = 'env-key' - try { - const fetchMock = vi.fn(async () => jsonResponse({ choices: [{ message: { content: 'a' } }], citations: [] })) - vi.stubGlobal('fetch', fetchMock) - const ctx = new Context() - await ctx.plugin(WebRuntime, { searchProvider: PERPLEXITY_PROVIDER_ID }) - const fiber = await ctx.plugin(perplexityPlugin, {}) - await ctx.web.search({ query: 'q' }) - const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] - expect(url).toBe('https://api.perplexity.ai/chat/completions') - expect(JSON.parse(init.body as string)).toMatchObject({ model: 'sonar' }) - await fiber.dispose() - } finally { - if (prev === undefined) delete process.env.PERPLEXITY_API_KEY - else process.env.PERPLEXITY_API_KEY = prev - } - }) - - it('is unavailable when neither config nor env supplies a key', async () => { - const prev = process.env.PERPLEXITY_API_KEY - delete process.env.PERPLEXITY_API_KEY - try { - const ctx = new Context() - await ctx.plugin(WebRuntime, { searchProvider: PERPLEXITY_PROVIDER_ID }) - await ctx.plugin(perplexityPlugin, {}) - await expect(ctx.web.search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' })) - } finally { - if (prev !== undefined) process.env.PERPLEXITY_API_KEY = prev - } - }) -}) diff --git a/packages/web/web-search-perplexity/tsconfig.json b/packages/web/web-search-perplexity/tsconfig.json deleted file mode 100644 index bcab1fb0c8..0000000000 --- a/packages/web/web-search-perplexity/tsconfig.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../util/launch-environment" - }, - { - "path": "../../../vendor/cosmokit" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../../vendor/schemastery" - }, - { - "path": "../web" - }, - { - "path": "../../util/http-proxy" - } - ] -} diff --git a/packages/web/web/README.i18n.yaml b/packages/web/web/README.i18n.yaml index 8894e5adca..3e5a420ab2 100644 --- a/packages/web/web/README.i18n.yaml +++ b/packages/web/web/README.i18n.yaml @@ -21,14 +21,20 @@ en: 557a8a3f2dfebae4 zh: 3c85382484e4b717 /deepseek-ai-dsh-web/use-this-package/minimal-configuration: - en: 71517ad8040d3c0c - zh: 003068238cb40fa5 + en: cb5267c425eef938 + zh: 26c3c04ee28c8c01 /deepseek-ai-dsh-web/use-this-package/searching-and-fetching: en: 713944abf68263cd zh: c428cb53b8708e99 /deepseek-ai-dsh-web/use-this-package/provider-selection: - en: ae79ba193456b380 - zh: dfc1003554d88aa3 + en: 5973c94b327aa8ae + zh: cbc15a2925f70347 +/deepseek-ai-dsh-web/use-this-package/listing-providers: + en: 229aed591f2194e0 + zh: d62b4b41d26be94c +/deepseek-ai-dsh-web/use-this-package/listing-providers/add-another-provider: + en: cf0c7f95d944be5a + zh: 1a2b3796acec7c29 /deepseek-ai-dsh-web/use-this-package/search-and-fetch-readiness: en: 30fec45dfb417eed zh: 253f887aeb3deeea @@ -42,8 +48,8 @@ en: c4d5437cfb1e2157 zh: 411b1b398de73426 /deepseek-ai-dsh-web/understand-the-implementation/source-map: - en: 1b59d389c50ddf21 - zh: 1f571b07350e3dbe + en: 2d8d315dba3b5f1e + zh: dca7443453d96ba7 /deepseek-ai-dsh-web/understand-the-implementation/data-model: en: 300340ff8465a03e zh: 34ab66eb6969dc94 @@ -51,8 +57,8 @@ en: d5cd4851d81892e6 zh: 18266b0bc8b1d1ae /deepseek-ai-dsh-web/further-exploration: - en: 65bba2a79c267230 - zh: a0782630bd093bf1 + en: f969a627e746906d + zh: e6ffbe79fc539cec /deepseek-ai-dsh-web/model-experience: en: cbc7053cba173085 zh: 73828c1f1ec82d50 @@ -60,11 +66,11 @@ en: 40294c1c31b74824 zh: d75d48f485447a74 /deepseek-ai-dsh-web/known-limitations-and-deferred-work: - en: 5d6031e60a6242af - zh: 1869764b4e486895 + en: 12624cc86e6bc4e9 + zh: c86de7d9b5302601 /deepseek-ai-dsh-web/known-limitations-and-deferred-work/dev-note: en: 7f775cc79dbce94b zh: 12b8f68f9e7c48ca /deepseek-ai-dsh-web/known-limitations-and-deferred-work/dev-note/future-observing-provider-state: - en: 855f97574a820579 - zh: 360ff6cd2ed82904 + en: a4ece830bb4d86ba + zh: 6e6fa8b9b407f142 diff --git a/packages/web/web/README.md b/packages/web/web/README.md index 2532279264..ace53aad16 100644 --- a/packages/web/web/README.md +++ b/packages/web/web/README.md @@ -33,11 +33,13 @@ Choose the service when a plugin or tool must search or fetch without hard-codin ### Minimal configuration -Load the service and let a single mounted backend auto-select, or pin a provider id with `searchProvider`/`fetchProvider`. The environment variables `$DSH_WEB_SEARCH_PROVIDER` and `$DSH_WEB_FETCH_PROVIDER` feed the same fields and are not a separate priority chain. +Load the service and let a single mounted backend auto-select, or pin a provider id with `searchProvider`/`fetchProvider`. The environment variables `$DSH_WEB_SEARCH_PROVIDER` and `$DSH_WEB_FETCH_PROVIDER` feed the same fields and are not a separate priority chain. Both backends below register a fetch provider, so the example pins `fetchProvider: http`. ```yaml - name: '@deepseek-ai/dsh-web' -- name: '@deepseek-ai/dsh-web-search-exa' + config: + fetchProvider: http +- name: '@deepseek-ai/dsh-web-deepseek-api' - name: '@deepseek-ai/dsh-web-fetch-http' ``` @@ -75,8 +77,37 @@ Each call resolves its provider at execution time, and registration or load orde | no id, no usable provider | `WEB_PROVIDER_UNAVAILABLE` | | no id, multiple usable providers | `WEB_PROVIDER_AMBIGUOUS` | +The shipped `deepseek-official` and `deepseek-search-api` search ids select legacy Messages search and the direct Search API respectively. + A provider's availability is a cheap local check — for example whether its API key is present — and never makes network calls, so selection stays fast and deterministic. + +### Listing providers + +`listProviders()` returns every registered search and fetch provider as `{ id, title?, description? }` entries, each list in registration order; it lists unavailable providers too, because a picker shows what can be pinned. `title` is the provider's optional display name, and a picker shows `id` when it is absent. `description` is optional `LocalizedText` from `dsh-package-manifest`, a plain string or translations keyed by lowercase language id with a required `en` entry, stating what the provider suits and what it costs or cannot do; a picker shows it under the selected provider and shows nothing when it is absent. Each registration and each disposal emits the payload-free `web/providers-changed` event after the registry changes, and observer failures cannot veto the change. Clients call the same method as the `web` Remote namespace, `ctx.remote.web.listProviders()`, whose Remote result carries the lists in `value` only when `ok` is true, and refresh on the forwarded `web/providers-changed` event; the [Web search and fetch settings page](../../client/ui-settings-web-search/README.md) fills its Search and Fetch menus this way. A plugin (including one written in creator mode) that registers a provider with a `title` appears in the matching menu under that title, shows its `description` while selected, and choosing it pins `searchProvider` or `fetchProvider` to its `id`. + +```text +// Remote: web/listProviders; Host event: web/providers-changed +const response = await ctx.remote.web.listProviders() +if (response.ok) { + const { search, fetch } = response.value +} // else: response.error is a structured RemoteError; keep any previous list and report it +ctx.remote.$on('web/providers-changed', () => { /* re-read the lists */ }) +``` + + +#### Add another provider + +A search or fetch backend outside the shipped composition is a plugin package that calls `ctx.web.registerSearchProvider()` or `ctx.web.registerFetchProvider()` with a unique `id`, and an optional `title`. To enable one in a profile, install the package with `dsh plugin --profile add ` ([install rules](../../../docs/user/develop/basic/publish.md)), add its row to the profile's user patch layer `$DSH_HOME/profiles//cordis.patch.yml`, and restart DSH. That file is a top-level YAML array of patch entries; append the `insert` entry below, or replace a lone `[]` with it. The example mounts a hypothetical `@example/dsh-web-search-acme`; use the provider's real package name and add the `config` its README documents. + +```yaml +- insert: + - id: web-search-acme + name: '@example/dsh-web-search-acme' +``` + +`dsh --profile --dump-config` prints the row under the patch file's layer. After the restart, the provider appears in the **Web search provider** or **Web fetch provider** menu of the [settings page](../../client/ui-settings-web-search/README.md) under its `title`, or its `id` when it has none; the page needs no change. Choosing it pins `searchProvider` or `fetchProvider` to its `id`. If the row is later removed while the provider is selected, the menu shows the stored id as **(not loaded)** and each call fails with `WEB_PROVIDER_CONFIGURED_MISSING` until another provider is chosen. + ### Search and fetch readiness @@ -114,8 +145,8 @@ The package is built on one deliberate separation: | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Plugin entry: the `WebRuntime` service, both provider registries, execution-time selection, and per-route readiness | -| [`src/types.ts`](src/types.ts) | Vocabulary: request/result types, the closed `WebFetchBody` union, and the `WebError` taxonomy | +| [`src/index.ts`](src/index.ts) | Plugin entry: the `WebRuntime` service, both provider registries, the `web/listProviders` Remote method and `web/providers-changed` event, execution-time selection, and per-route readiness | +| [`src/types.ts`](src/types.ts) | Vocabulary: request/result types, provider list entries, the `web/providers-changed` event declaration, the closed `WebFetchBody` union, and the `WebError` taxonomy | ### Data model @@ -135,7 +166,7 @@ At call time the service resolves the provider — configured id first, then the Read these pages when the package-level contract is not enough. They move from the shared vocabulary to the shipped backends, the model-facing tools, and the design rationale. - [Web subsystem](../../../docs/subsystems/web.md) — the exhaustive search/fetch requests and results, provider availability, and error codes. -- [Web package map](../README.md) — the six-package family and each role. +- [Web package map](../README.md) — the five-package family and each role. - [dsh-tool-web](../tool-web/README.md) — the model-facing `web_search` and `web_fetch` tools over this service. - [dsh-web-fetch-http](../web-fetch-http/README.md) — the shipped anonymous HTTP(S) fetch backend. - [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web) — every accepted config field and its source declaration. @@ -160,7 +191,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. These limits define when the service is incomplete on its own. They are current package constraints. -- **No observation surface** — there is no provider-change event and no capability-status query; `searchReady()` and `fetchReady()` report only the selected provider's credential readiness and answer true when selection fails. Availability is observable only by running a search or fetch and routing the thrown code, and the no-provider failure is the generic `WEB_PROVIDER_UNAVAILABLE` with no per-provider reason enumeration ([Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md)). +- **No availability observation** — `listProviders()` and `web/providers-changed` report which providers are registered, not whether they are usable; there is no capability-status query, and `searchReady()` and `fetchReady()` report only the selected provider's credential readiness and answer true when selection fails. Availability is observable only by running a search or fetch and routing the thrown code, and the no-provider failure is the generic `WEB_PROVIDER_UNAVAILABLE` with no per-provider reason enumeration ([Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md)). - **Search requests carry only `query` and `maxResults`** — provider-neutral controls (recency, domain filters, regional hints, search depth) are deferred until the backends can honor them ([seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md)). - **`WebFetchBody` has no `pdf` arm** — text-extractable PDF support is named deferred work; the closed union makes adding it a compile-enforced change across the web packages. - **Provider-backed page extraction is out of scope of `fetch()`** — a Firecrawl/Tavily-style `web_extract` capability is deferred rather than widening the fetch operation. @@ -175,6 +206,6 @@ This Dev Note is working context for maintainers: open questions and undecided d #### Future: observing provider state -No provider-change event or capability-status query exists; consumers observe availability only by executing a call and routing the thrown code. Restoring a small observation surface is possible if a consumer needs per-provider reasons, but the archived simplification note records why the earlier one was dropped. +Registration is observable through `listProviders()` and `web/providers-changed`, but no capability-status query exists; consumers observe availability only by executing a call and routing the thrown code. Restoring a small observation surface is possible if a consumer needs per-provider reasons, but the archived simplification note records why the earlier one was dropped. diff --git a/packages/web/web/README.zh.md b/packages/web/web/README.zh.md index 8076148050..c1aca29ce9 100644 --- a/packages/web/web/README.zh.md +++ b/packages/web/web/README.zh.md @@ -33,11 +33,13 @@ kind: "package-reference" ### 最小配置 -加载服务并让唯一挂载的后端自动选择,或用 `searchProvider`/`fetchProvider` 固定提供方 id。环境变量 `$DSH_WEB_SEARCH_PROVIDER` 与 `$DSH_WEB_FETCH_PROVIDER` 提供相同字段,不是另一条优先级链。 +加载服务并让唯一挂载的后端自动选择,或用 `searchProvider`/`fetchProvider` 固定提供方 id。环境变量 `$DSH_WEB_SEARCH_PROVIDER` 与 `$DSH_WEB_FETCH_PROVIDER` 提供相同字段,不是另一条优先级链。下面两个后端都注册了抓取提供方,因此示例固定 `fetchProvider: http`。 ```yaml - name: '@deepseek-ai/dsh-web' -- name: '@deepseek-ai/dsh-web-search-exa' + config: + fetchProvider: http +- name: '@deepseek-ai/dsh-web-deepseek-api' - name: '@deepseek-ai/dsh-web-fetch-http' ``` @@ -75,8 +77,37 @@ const page = await ctx.web.fetch({ url: 'https://example.com' }) | 无 id,没有可用提供方 | `WEB_PROVIDER_UNAVAILABLE` | | 无 id,多个可用提供方 | `WEB_PROVIDER_AMBIGUOUS` | +随附的 `deepseek-official` 与 `deepseek-search-api` 搜索 id 分别选择旧版 Messages 搜索与直接 Search API。 + 提供方的可用性是一项廉价的局部检查——例如其 API 密钥是否存在——并且从不发起网络调用,因此选择保持快速且确定。 + +### 列出提供方 + +`listProviders()` 以 `{ id, title?, description? }` 条目返回所有已注册的搜索与抓取提供方,每个列表按注册顺序排列;不可用的提供方同样列出,因为选择器展示的是可以固定的提供方。`title` 是提供方可选的显示名,缺省时选择器显示 `id`。`description` 是可选的 `LocalizedText`(来自 `dsh-package-manifest`),可以是纯字符串,也可以是以小写语言 id 为键、必须含 `en` 的翻译表,说明该提供方适合什么、有什么代价或做不到什么;选择器在选中该提供方时把它显示在下方,缺省时不显示。每次注册与每次注销都会在注册表变更后发出无载荷的 `web/providers-changed` 事件,观察者失败不能否决该变更。客户端通过 `web` Remote 命名空间调用同一方法,即 `ctx.remote.web.listProviders()`;它返回 Remote 结果,只有 `ok` 为 true 时 `value` 才带有列表。客户端在转发的 `web/providers-changed` 事件上刷新;[网页搜索与获取设置页](../../client/ui-settings-web-search/README.zh.md)以此填充搜索与获取下拉框。插件(包括在创造模式中编写的插件)注册带 `title` 的提供方后,即以该名称出现在对应下拉框中,选中时显示其 `description`,选中它会把 `searchProvider` 或 `fetchProvider` 固定为它的 `id`。 + +```text +// Remote: web/listProviders; Host event: web/providers-changed +const response = await ctx.remote.web.listProviders() +if (response.ok) { + const { search, fetch } = response.value +} // else: response.error is a structured RemoteError; keep any previous list and report it +ctx.remote.$on('web/providers-changed', () => { /* re-read the lists */ }) +``` + + +#### 添加其他提供方 + +内置组合之外的搜索或抓取后端是一个插件包,它以唯一的 `id` 和可选的 `title` 调用 `ctx.web.registerSearchProvider()` 或 `ctx.web.registerFetchProvider()`。要在某个 profile 中启用它,先用 `dsh plugin --profile add ` 安装该包([安装规则](../../../docs/user/develop/basic/publish.zh.md)),再把它的行加入该 profile 的用户 patch 层 `$DSH_HOME/profiles//cordis.patch.yml`,然后重启 DSH。该文件是由 patch 条目组成的顶层 YAML 数组;请追加下面的 `insert` 条目,或用它替换单独的 `[]`。示例挂载一个假想的 `@example/dsh-web-search-acme`;请换成提供方真实的包名,并添加其 README 说明的 `config`。 + +```yaml +- insert: + - id: web-search-acme + name: '@example/dsh-web-search-acme' +``` + +`dsh --profile --dump-config` 会在该 patch 文件的层下打印这一行。重启后,该提供方以其 `title`(没有 `title` 时以其 `id`)出现在[设置页](../../client/ui-settings-web-search/README.zh.md)的**网页搜索提供方**或**网页获取提供方**下拉框中,设置页无需任何修改。选中它会把 `searchProvider` 或 `fetchProvider` 固定为它的 `id`。如果之后在该提供方被选中时删除了这一行,下拉框会把已保存的 id 显示为 **(未加载)**,每次调用都会以 `WEB_PROVIDER_CONFIGURED_MISSING` 失败,直到选择其他提供方。 + ### 搜索与抓取就绪 @@ -114,8 +145,8 @@ const fetchOffered = await ctx.web.fetchReady('deepseek-official') | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | 插件入口:`WebRuntime` 服务、两个提供方注册表、执行时选择与按路由的就绪判断 | -| [`src/types.ts`](src/types.ts) | 词汇:请求/结果类型、封闭的 `WebFetchBody` 联合与 `WebError` 分类体系 | +| [`src/index.ts`](src/index.ts) | 插件入口:`WebRuntime` 服务、两个提供方注册表、`web/listProviders` Remote 方法与 `web/providers-changed` 事件、执行时选择与按路由的就绪判断 | +| [`src/types.ts`](src/types.ts) | 词汇:请求/结果类型、提供方列表条目、`web/providers-changed` 事件声明、封闭的 `WebFetchBody` 联合与 `WebError` 分类体系 | ### 数据模型 @@ -135,7 +166,7 @@ const fetchOffered = await ctx.web.fetchReady('deepseek-official') 当包级约定不够用时阅读以下页面。它们从共享词汇逐步进入已交付后端、面向模型的工具与设计依据。 - [web 子系统](../../../docs/subsystems/web.zh.md)——穷尽式的搜索/抓取请求与结果、提供方可用性与错误码。 -- [web 包映射](../README.zh.md)——六包家族与各角色。 +- [web 包映射](../README.zh.md)——五包家族与各角色。 - [dsh-tool-web](../tool-web/README.zh.md)——构建于本服务之上的面向模型 `web_search` 与 `web_fetch` 工具。 - [dsh-web-fetch-http](../web-fetch-http/README.zh.md)——已交付的匿名 HTTP(S) 抓取后端。 - [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web)——每个受支持配置字段及其源声明。 @@ -160,7 +191,7 @@ const fetchOffered = await ctx.web.fetchReady('deepseek-official') 这些限制说明本服务单独使用时在哪些方面不完整。它们是当前包约束。 -- **没有观测接口**:没有提供方变更事件或能力状态查询;`searchReady()` 与 `fetchReady()` 只报告选中提供方的凭据就绪状态,选择失败时回答 true。可用性只能通过执行搜索或抓取并按抛出的 code 路由来观测,无提供方失败是通用的 `WEB_PROVIDER_UNAVAILABLE`,不枚举逐提供方原因(见 [Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md))。 +- **没有可用性观测**:`listProviders()` 与 `web/providers-changed` 只报告哪些提供方已注册,不报告它们是否可用;没有能力状态查询,`searchReady()` 与 `fetchReady()` 只报告选中提供方的凭据就绪状态,选择失败时回答 true。可用性只能通过执行搜索或抓取并按抛出的 code 路由来观测,无提供方失败是通用的 `WEB_PROVIDER_UNAVAILABLE`,不枚举逐提供方原因(见 [Agent Note](../../../.agents/notes/archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md))。 - **搜索请求只携带 `query` 与 `maxResults`**:提供方无关的控制项(新近程度、域名过滤条件、区域提示、搜索深度)暂缓至后端都能诚实支持时(见 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md))。 - **`WebFetchBody` 没有 `pdf` 分支**:可提取文本的 PDF 支持属于明确的延期工作;封闭联合会使新增该分支成为跨 web 包、由编译强制执行的变更。 - **提供方支持的页面提取不属于 `fetch()` 范围**:Firecrawl/Tavily 风格的 `web_extract` 能力延期,而不会扩展抓取操作。 @@ -175,6 +206,6 @@ const fetchOffered = await ctx.web.fetchReady('deepseek-official') #### 未来:观测提供方状态 -没有提供方变更事件或能力状态查询;消费方只能通过执行调用并按抛出的 code 路由来观测可用性。如果消费方需要逐提供方原因,恢复一个小的观测接口是可行的,但已归档的简化笔记记录了为何放弃此前的那个接口。 +注册情况可通过 `listProviders()` 与 `web/providers-changed` 观测,但没有能力状态查询;消费方只能通过执行调用并按抛出的 code 路由来观测可用性。如果消费方需要逐提供方原因,恢复一个小的观测接口是可行的,但已归档的简化笔记记录了为何放弃此前的那个接口。 diff --git a/packages/web/web/package.json b/packages/web/web/package.json index 4edfbfa6dc..5150246c23 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -18,6 +18,18 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json", "./locale/*.json": "./locale/*.json" @@ -25,7 +37,12 @@ "files": [ "locale/*.json", "lib/index.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" ], "license": "MIT", "peerDependencies": { @@ -33,7 +50,9 @@ "@deepseek-ai/cordis": "workspace:~" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:~" + "@deepseek-ai/dsh-package-manifest": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~", + "@deepseek-ai/dsh-typert-protocol": "workspace:*" }, "devDependencies": { "@deepseek-ai/dsh-llm": "workspace:*", diff --git a/packages/web/web/src/index.ts b/packages/web/web/src/index.ts index 2dfe5b3ca8..f5b7982bb9 100644 --- a/packages/web/web/src/index.ts +++ b/packages/web/web/src/index.ts @@ -6,12 +6,15 @@ * @module @deepseek-ai/dsh-web */ -import { Context, Service, type Volatile } from '@deepseek-ai/cordis' +import { Context, type Volatile } from '@deepseek-ai/cordis' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import z from '@deepseek-ai/schemastery' import type { WebFetchProvider, WebFetchRequest, WebFetchResult, + WebProviderInfo, + WebProviderList, WebSearchProvider, WebSearchRequest, WebSearchResult, @@ -26,6 +29,8 @@ export type { WebFetchProvider, WebFetchRequest, WebFetchResult, + WebProviderInfo, + WebProviderList, WebSearchProvider, WebSearchRequest, WebSearchResult, @@ -62,7 +67,8 @@ export interface WebRuntimeConfig { } /** - * The web access service. Registered as `ctx.web` (one instance per context). + * The web access service. Registered as `ctx.web` (one instance per context) + * and exposed to Clients as the `web` Remote namespace (`listProviders`). * * Selection semantics (resolved at execution time, never order-dependent): * - A configured id that is registered and `available()` → that provider. @@ -73,7 +79,7 @@ export interface WebRuntimeConfig { * - No id configured, multiple usable providers → `WEB_PROVIDER_AMBIGUOUS`. * - No id configured, no usable provider → `WEB_PROVIDER_UNAVAILABLE`. */ -export class WebRuntime extends Service { +export class WebRuntime extends TypertRemoteService { /** * Provider selection config. Operational env overrides feed the SAME fields: * `$DSH_WEB_SEARCH_PROVIDER` / `$DSH_WEB_FETCH_PROVIDER` are equivalent to @@ -96,9 +102,11 @@ export class WebRuntime extends Service { } /** - * Register a search provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` - * if its id is already registered for search. Returns a disposer; disposed - * with the calling fiber. + * Register a search provider. `listProviders()` lists it until the + * registration is disposed, which happens with the calling fiber or through + * the returned disposer; registration and disposal each emit + * `web/providers-changed`. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` + * if its id is already registered for search. * @param provider - the provider; its `id` is the registry key. * @returns the disposer that unregisters the provider. */ @@ -107,9 +115,9 @@ export class WebRuntime extends Service { } /** - * Register a fetch provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` - * if its id is already registered for fetch. Returns a disposer; disposed - * with the calling fiber. + * Register a fetch provider, with the listing, disposal, and event behavior + * of {@link WebRuntime.registerSearchProvider}. Throws {@link WebError} + * `WEB_DUPLICATE_PROVIDER` if its id is already registered for fetch. * @param provider - the provider; its `id` is the registry key. * @returns the disposer that unregisters the provider. */ @@ -121,15 +129,50 @@ export class WebRuntime extends Service { if (store.has(provider.id)) { throw new WebError(`a web provider with id "${provider.id}" is already registered`, 'WEB_DUPLICATE_PROVIDER') } + const emitChanged = (): void => { this.emitProvidersChanged() } const dispose = this.ctx.effect(function* () { store.set(provider.id, provider) - yield () => store.delete(provider.id) + emitChanged() + yield () => { + store.delete(provider.id) + emitChanged() + } }, 'web.registerProvider()') // ctx.effect's disposer returns Promise; our disposer API is // synchronous fire-and-forget — discard the (always-resolved) promise. return () => void dispose() } + /** + * List every registered search and fetch provider for provider pickers. + * Availability does not filter the lists. + * @returns detached `{ id, title, description }` entries per capability, in registration order. + */ + @Remote + listProviders(): WebProviderList { + return { + search: [...this.searchProviders.values()].map(providerInfo), + fetch: [...this.fetchProviders.values()].map(providerInfo), + } + } + + /** Emit `web/providers-changed`, containing each listener's failure so none vetoes the mutation. */ + private emitProvidersChanged(): void { + for (const listener of this.ctx.events.dispatch('emit', ['web/providers-changed'])) { + try { + const returned: unknown = listener() + void Promise.resolve(returned).catch((error: unknown) => { this.warnListenerFailure(error) }) + } catch (error: unknown) { + this.warnListenerFailure(error) + } + } + } + + private warnListenerFailure(error: unknown): void { + this.ctx.logger.warn('web: a web/providers-changed listener failed') + this.ctx.logger.warn(error) + } + /** * Run one search through the selected provider. Resolves the provider at call * time with the selection rules above; throws {@link WebError} when the @@ -199,6 +242,15 @@ export class WebRuntime extends Service { } } +/** Project one provider to its picker entry, omitting an absent title or description. */ +function providerInfo(provider: WebSearchProvider | WebFetchProvider): WebProviderInfo { + return { + id: provider.id, + ...provider.title === undefined ? {} : { title: provider.title }, + ...provider.description === undefined ? {} : { description: provider.description }, + } +} + interface ResolvableProvider { readonly id: string available(): boolean diff --git a/packages/web/web/src/types.ts b/packages/web/web/src/types.ts index d0f5fe02e5..ff632770a4 100644 --- a/packages/web/web/src/types.ts +++ b/packages/web/web/src/types.ts @@ -6,6 +6,21 @@ */ import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { LocalizedText } from '@deepseek-ai/dsh-package-manifest' + +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * The set of registered search or fetch providers changed: one provider + * registered, or its registration was disposed. Payload-free; consumers + * re-read `ctx.web.listProviders()` (Remote `web/listProviders`) for the + * new list. Fires after each committed registry mutation. Observer failures + * are contained and cannot veto the mutation. + * @mode emit + */ + 'web/providers-changed'(): void + } +} /** * What one search-capable backend is asked to search. Each request carries one @@ -18,17 +33,17 @@ export interface WebSearchRequest { /** * Upper bound on returned sources; the seam truncates to it. Omitted = no * bound. `dsh-tool-web` always sets it. A provider whose API supports a - * result-count control (Exa's `numResults`) should apply it at the request - * layer as a cost/latency optimization; the seam enforces the bound - * regardless. + * result-count control (the DeepSeek Search API's `count`) should apply it + * at the request layer as a cost/latency optimization; the seam enforces the + * bound regardless. */ readonly maxResults?: number } /** * Normalized search outcome. `content` is optional provider-generated answer - * text or summary (Exa and DeepSeek return none; Perplexity returns a - * generated answer). + * text or summary (DeepSeek returns none; a provider whose API generates an + * answer returns it here). * `sources[]` is the portable citation shape. `truncated` is set by the seam * when it cut `sources[]` down to `maxResults`. */ @@ -44,8 +59,8 @@ export interface WebSearchResult { /** * One citeable source. A source always has a URL; `title`, `snippet`, `summary`, * and `publishedAt` are optional because not every provider returns them — forcing - * adapters to invent them would make the seam lie (Perplexity citations may be - * URL-only). `dsh-tool-web` renders `title ?? hostname(url)` for display. + * adapters to invent them would make the seam lie (some providers return + * URL-only citations). `dsh-tool-web` renders `title ?? hostname(url)` for display. */ export interface WebSearchSource { readonly url: string @@ -104,6 +119,14 @@ export type WebFetchBody = */ export interface WebSearchProvider { readonly id: string + /** Display name of this provider; consumers display `id` when it is omitted. */ + readonly title?: string + /** + * What a provider picker shows under this provider when it is selected: what the provider + * is suited for and what it costs or cannot do. Translations are keyed by lowercase language + * id with a required English entry; consumers show nothing when it is omitted. + */ + readonly description?: LocalizedText /** Cheap local usability check; must not make network calls. */ available(): boolean /** @@ -124,6 +147,14 @@ export interface WebSearchProvider { */ export interface WebFetchProvider { readonly id: string + /** Display name of this provider; consumers display `id` when it is omitted. */ + readonly title?: string + /** + * What a provider picker shows under this provider when it is selected: what the provider + * is suited for and what it costs or cannot do. Translations are keyed by lowercase language + * id with a required English entry; consumers show nothing when it is omitted. + */ + readonly description?: LocalizedText /** Cheap local usability check; must not make network calls. */ available(): boolean /** @@ -138,6 +169,28 @@ export interface WebFetchProvider { fetch(request: WebFetchRequest, signal?: AbortSignal): Promise } +/** One registered provider as listed by {@link WebProviderList}. */ +export interface WebProviderInfo { + /** The provider's registry id, the value `searchProvider` / `fetchProvider` select. */ + readonly id: string + /** The provider's display name; absent when the provider declares none. */ + readonly title?: string + /** The provider's picker description; absent when the provider declares none. */ + readonly description?: LocalizedText +} + +/** + * Every currently registered search and fetch provider, each list in + * registration order. Availability does not filter the lists: a registered + * provider whose `available()` answers false is still listed. + */ +export interface WebProviderList { + /** Registered search providers. */ + readonly search: readonly WebProviderInfo[] + /** Registered fetch providers. */ + readonly fetch: readonly WebProviderInfo[] +} + /** * Typed web error with a machine-routable, open-string `code` and chained `cause`. * Consumers must tolerate provider-specific codes. Shared codes cover unavailable, diff --git a/packages/web/web/tests/web.spec.ts b/packages/web/web/tests/web.spec.ts index 694df9b85f..fab6f7da4c 100644 --- a/packages/web/web/tests/web.spec.ts +++ b/packages/web/web/tests/web.spec.ts @@ -75,6 +75,72 @@ describe('WebRuntime registration', () => { }) }) +describe('WebRuntime provider listing', () => { + it('lists registered providers per capability in registration order, including unavailable ones', async () => { + const { web } = await mountWeb() + expect(web.listProviders()).toEqual({ search: [], fetch: [] }) + web.registerSearchProvider({ ...makeSearchProvider('b', available, () => Promise.resolve(searchResult('b'))), title: 'B search' }) + const disposeA = web.registerSearchProvider(makeSearchProvider('a', unavailable, () => Promise.resolve(searchResult('a')))) + const description = { en: 'Fetches from this machine.', zh: '本机获取' } + web.registerFetchProvider({ ...makeFetchProvider('http', available, fetchResult('http')), title: 'Direct fetch', description }) + expect(web.listProviders()).toEqual({ + search: [{ id: 'b', title: 'B search' }, { id: 'a' }], + fetch: [{ id: 'http', title: 'Direct fetch', description }], + }) + + disposeA() + expect(web.listProviders().search).toEqual([{ id: 'b', title: 'B search' }]) + }) + + it('emits web/providers-changed once after each registration and each disposal', async () => { + const { ctx, web } = await mountWeb() + const observed: Array = [] + ctx.on('web/providers-changed', () => { + observed.push(web.listProviders().fetch.map(provider => provider.id)) + }) + const dispose = web.registerFetchProvider(makeFetchProvider('http', available, fetchResult('http'))) + expect(observed).toEqual([['http']]) + dispose() + expect(observed).toEqual([['http'], []]) + }) + + it('does not emit web/providers-changed for a rejected duplicate registration', async () => { + const { ctx, web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) + let changes = 0 + ctx.on('web/providers-changed', () => { changes += 1 }) + expect(() => web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))).toThrow() + expect(changes).toBe(0) + }) + + it('contains a throwing or rejecting listener so later listeners still run and registration commits', async () => { + const { ctx, web } = await mountWeb() + let later = 0 + ctx.on('web/providers-changed', () => { throw new Error('sync listener failure') }) + // oxlint-disable-next-line typescript/no-misused-promises -- exercises rejected-listener containment + ctx.on('web/providers-changed', () => Promise.reject(new Error('async listener failure'))) + ctx.on('web/providers-changed', () => { later += 1 }) + web.registerFetchProvider(makeFetchProvider('http', available, fetchResult('http'))) + await Promise.resolve() + expect(later).toBe(1) + expect(web.listProviders().fetch).toEqual([{ id: 'http' }]) + }) + + it('removes a contributing fiber\'s providers from the list and announces it (HMR safety)', async () => { + const { ctx, web } = await mountWeb() + let changes = 0 + ctx.on('web/providers-changed', () => { changes += 1 }) + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.web.registerSearchProvider({ ...makeSearchProvider('custom', available, () => Promise.resolve(searchResult('custom'))), title: 'Custom' }) + }, { inject: ['web'] })) + expect(web.listProviders().search).toEqual([{ id: 'custom', title: 'Custom' }]) + expect(changes).toBe(1) + await fiber.dispose() + expect(web.listProviders().search).toEqual([]) + expect(changes).toBe(2) + }) +}) + describe('WebRuntime execution resolution', () => { it('throws WEB_PROVIDER_UNAVAILABLE when nothing is registered', async () => { const { web } = await mountWeb() diff --git a/packages/web/web/tsconfig.json b/packages/web/web/tsconfig.json index e9de391ba1..601a3e4241 100644 --- a/packages/web/web/tsconfig.json +++ b/packages/web/web/tsconfig.json @@ -19,6 +19,12 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../../util/package-manifest" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbf6f714dd..6b8f40e030 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1600,6 +1600,9 @@ importers: '@deepseek-ai/dsh-user-questions': specifier: workspace:* version: link:../../interaction/user-questions + '@deepseek-ai/dsh-web': + specifier: workspace:* + version: link:../../web/web zod: specifier: 4.4.3 version: 4.4.3 @@ -6098,6 +6101,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:* version: link:../ui-slots + '@deepseek-ai/dsh-web': + specifier: workspace:* + version: link:../../web/web react: specifier: ^18.2.0 version: 18.3.1 @@ -17617,6 +17623,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:* version: link:../../core/agent + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:* + version: link:../../util/launch-environment '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../../llm/llm @@ -17653,18 +17662,24 @@ importers: '@deepseek-ai/dsh-web': specifier: workspace:* version: link:../web + '@deepseek-ai/dsh-web-deepseek-api': + specifier: workspace:* + version: link:../web-deepseek-api '@deepseek-ai/dsh-web-fetch-http': specifier: workspace:* version: link:../web-fetch-http - '@deepseek-ai/dsh-web-search-exa': - specifier: workspace:* - version: link:../web-search-exa '@types/turndown': specifier: ^5.0.6 version: 5.0.6 packages/web/web: dependencies: + '@deepseek-ai/dsh-package-manifest': + specifier: workspace:* + version: link:../../util/package-manifest + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:* + version: link:../../typert/protocol '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -17685,9 +17700,15 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:~ + version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:* version: link:../../core/agent + '@deepseek-ai/dsh-app-boot': + specifier: workspace:* + version: link:../../boot/app-boot '@deepseek-ai/dsh-credentials': specifier: workspace:* version: link:../../credentials/credentials @@ -17703,6 +17724,18 @@ importers: '@deepseek-ai/dsh-launch-environment': specifier: workspace:* version: link:../../util/launch-environment + '@deepseek-ai/dsh-llm': + specifier: workspace:* + version: link:../../llm/llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:* + version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-llm-deepseek-account': + specifier: workspace:* + version: link:../../llm/llm-deepseek-account + '@deepseek-ai/dsh-llm-deepseek-api-key': + specifier: workspace:* + version: link:../../llm/llm-deepseek-api-key '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session @@ -17762,6 +17795,9 @@ importers: '@deepseek-ai/dsh-launch-environment': specifier: workspace:* version: link:../../util/launch-environment + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:* + version: link:../../llm/llm-deepseek '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session @@ -17769,44 +17805,6 @@ importers: specifier: workspace:* version: link:../web - packages/web/web-search-exa: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:~ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-http-proxy': - specifier: workspace:* - version: link:../../util/http-proxy - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:* - version: link:../../util/launch-environment - '@deepseek-ai/dsh-web': - specifier: workspace:* - version: link:../web - - packages/web/web-search-perplexity: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:~ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-http-proxy': - specifier: workspace:* - version: link:../../util/http-proxy - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:* - version: link:../../util/launch-environment - '@deepseek-ai/dsh-web': - specifier: workspace:* - version: link:../web - packages/workflow/tool-workflow: dependencies: '@deepseek-ai/schemastery': @@ -18451,12 +18449,6 @@ importers: '@deepseek-ai/dsh-web-search-deepseek': specifier: workspace:* version: link:../../packages/web/web-search-deepseek - '@deepseek-ai/dsh-web-search-exa': - specifier: workspace:* - version: link:../../packages/web/web-search-exa - '@deepseek-ai/dsh-web-search-perplexity': - specifier: workspace:* - version: link:../../packages/web/web-search-perplexity '@deepseek-ai/dsh-workflow': specifier: workspace:* version: link:../../packages/workflow/workflow diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 437ea1705a..9ff3073d71 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -156,8 +156,6 @@ "@deepseek-ai/dsh-web": "workspace:*", "@deepseek-ai/dsh-web-fetch-http": "workspace:*", "@deepseek-ai/dsh-web-search-deepseek": "workspace:*", - "@deepseek-ai/dsh-web-search-exa": "workspace:*", - "@deepseek-ai/dsh-web-search-perplexity": "workspace:*", "@deepseek-ai/dsh-workflow": "workspace:*", "@deepseek-ai/dsh-workflow-ptc": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*", diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 4c41bc1ec0..aacac857c8 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -264,6 +264,7 @@ export const EVENT_SCOPE_PAGE: Record = { 'feedback': 'feedback.md', 'tools': 'tools.md', 'user-questions': 'user-questions.md', + 'web': 'web.md', 'webserver': 'web-server.md', 'workflow': 'workflow.md', 'workspace': 'workspace.md', @@ -882,6 +883,8 @@ export const LINK_MAP: Readonly> = { WebFetchProvider: 'web.md', WebFetchRequest: 'web.md', WebFetchResult: 'web.md', + WebProviderInfo: 'web.md', + WebProviderList: 'web.md', WebSearchProvider: 'web.md', WebSearchRequest: 'web.md', WebSearchResult: 'web.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index ec22ea680d..8d22f4cf70 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -879,7 +879,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'web', title: 'Web access provider registry', mode: 'seam', - implementations: ['web-search-exa', 'web-search-perplexity', 'web-search-deepseek', 'web-deepseek-api', 'web-fetch-http'], + implementations: ['web-search-deepseek', 'web-deepseek-api', 'web-fetch-http'], consumers: ['tool-web'], note: 'Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names.', }, diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 5ba3c34e13..6204f6e7af 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -38,7 +38,7 @@ import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, St import UserQuestionService from '@deepseek-ai/dsh-user-questions' import PlanModeController from '@deepseek-ai/dsh-plan-mode' import WebRuntime from '@deepseek-ai/dsh-web' -import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa' +import * as WebDeepSeekApi from '@deepseek-ai/dsh-web-deepseek-api' import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-http' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubagentProvider } from '@deepseek-ai/dsh-subagent' @@ -722,7 +722,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ // Mount search and fetch providers so both tools register. Their schemas // do not depend on provider identity or availability. await ctx.plugin(WebRuntime) - await ctx.plugin(WebSearchExa) + await ctx.plugin(WebDeepSeekApi) await ctx.plugin(WebFetchLocal) await ctx.plugin(ToolWeb) }, diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 1fe9029cd0..b5bb6da63d 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -1958,19 +1958,6 @@ "packages/web/tool-web/tests/spill.spec.ts": { "ecf3420d023d28e1cbc687d438bb66ee49d55a4ae4cb0828dd0f59b926363903": 1 }, - "packages/web/web-search-deepseek/tests/deepseek.spec.ts": { - "37c9617ff75d6a383f58dd63f939a88803e4ff5e1676bfe7e1c0cc340fc65014": 5, - "598014dcfe77101781e3c6a02709097e3b0151a335e909c44719fac08fdea433": 2 - }, - "packages/web/web-search-exa/tests/exa.spec.ts": { - "37c9617ff75d6a383f58dd63f939a88803e4ff5e1676bfe7e1c0cc340fc65014": 7, - "598014dcfe77101781e3c6a02709097e3b0151a335e909c44719fac08fdea433": 2 - }, - "packages/web/web-search-perplexity/tests/perplexity.spec.ts": { - "37c9617ff75d6a383f58dd63f939a88803e4ff5e1676bfe7e1c0cc340fc65014": 5, - "598014dcfe77101781e3c6a02709097e3b0151a335e909c44719fac08fdea433": 2, - "ed2fec09418bc1a78fde5097fd01d5aabf903166ed1bf480a5e38bccdd3948a6": 1 - }, "packages/workflow/tool-workflow/tests/tool-workflow.spec.ts": { "061944ebda38f8da144d88cfa668e6336efc68c33441a9fe3215d8c001124526": 2 }, diff --git a/scripts/product-package-policy.ts b/scripts/product-package-policy.ts index 9568a91285..d7ee247c03 100644 --- a/scripts/product-package-policy.ts +++ b/scripts/product-package-policy.ts @@ -32,8 +32,6 @@ export const PRODUCT_PACKAGE_POLICY: Readonly { try { await globalThis.fetch(RECORDED_ENDPOINT) - expect(fetchMock.mock.calls.at(-1)?.[0]).toBe('http://127.0.0.1:54322/anthropic/v1/messages') + expect(fetchMock.mock.calls.at(-1)?.[0]).toBe('http://127.0.0.1:54322/api/v1/tools/search') await globalThis.fetch(new URL(RECORDED_ENDPOINT)) - expect(fetchMock.mock.calls.at(-1)?.[0]).toBe('http://127.0.0.1:54322/anthropic/v1/messages') + expect(fetchMock.mock.calls.at(-1)?.[0]).toBe('http://127.0.0.1:54322/api/v1/tools/search') const request = new Request(RECORDED_ENDPOINT, { method: 'POST', headers: { 'x-fixture': 'request' } }) await globalThis.fetch(request) const mappedRequest = fetchMock.mock.calls.at(-1)?.[0] expect(mappedRequest).toBeInstanceOf(Request) if (!(mappedRequest instanceof Request)) throw new TypeError('mapped fetch input must be a Request') - expect(mappedRequest.url).toBe('http://127.0.0.1:54322/anthropic/v1/messages') + expect(mappedRequest.url).toBe('http://127.0.0.1:54322/api/v1/tools/search') expect(mappedRequest.method).toBe('POST') expect(mappedRequest.headers.get('x-fixture')).toBe('request') diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 61a3535ece..d59099190f 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -265,7 +265,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/util/native-command': { kind: 'none', reason: 'The host-side subprocess runner registers nothing model-facing.' }, 'packages/web/web': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-web.' }, 'packages/web/web-fetch-http': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' }, - 'packages/web/web-search-exa': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' }, 'packages/workflow/workflow': { kind: 'indirect', reason: 'The service delegates parent and child model rendering to its consumer and engine.' }, } diff --git a/snapshots/session/deepseek-fetch/cordis.snapshot.yml b/snapshots/session/deepseek-fetch/cordis.snapshot.yml index bd44ceb2a5..84cedde775 100644 --- a/snapshots/session/deepseek-fetch/cordis.snapshot.yml +++ b/snapshots/session/deepseek-fetch/cordis.snapshot.yml @@ -21,14 +21,14 @@ - id: web name: '@deepseek-ai/dsh-web' config: - searchProvider: deepseek-official + searchProvider: deepseek-search-api fetchProvider: deepseek-fetch-api - id: web-deepseek-api name: '@deepseek-ai/dsh-web-deepseek-api' config: - apiKey: snapshot-key - baseURL: http://127.0.0.1:43118/api/v1/tools + fetchApiKeyEnv: DSH_SNAPSHOT_FETCH_KEY + fetchBaseURL: http://127.0.0.1:43118/api/v1/tools - id: tool-web name: '@deepseek-ai/dsh-tool-web' diff --git a/snapshots/session/deepseek-fetch/cordis.yml b/snapshots/session/deepseek-fetch/cordis.yml index b3eaee56d6..865265b3c0 100644 --- a/snapshots/session/deepseek-fetch/cordis.yml +++ b/snapshots/session/deepseek-fetch/cordis.yml @@ -5,14 +5,14 @@ - id: web name: '@deepseek-ai/dsh-web' config: - searchProvider: deepseek-official + searchProvider: deepseek-search-api fetchProvider: deepseek-fetch-api - id: web-deepseek-api name: '@deepseek-ai/dsh-web-deepseek-api' config: - apiKey: snapshot-key - baseURL: http://127.0.0.1:43118/api/v1/tools + fetchApiKeyEnv: DSH_SNAPSHOT_FETCH_KEY + fetchBaseURL: http://127.0.0.1:43118/api/v1/tools - id: tool-web name: '@deepseek-ai/dsh-tool-web' diff --git a/snapshots/session/deepseek-fetch/snapshot.yml b/snapshots/session/deepseek-fetch/snapshot.yml index 76e7d2f732..e3530dd23c 100644 --- a/snapshots/session/deepseek-fetch/snapshot.yml +++ b/snapshots/session/deepseek-fetch/snapshot.yml @@ -9,3 +9,5 @@ header: pin: true systemPromptSource: web-fetch toolSchemasSource: web-fetch +environment: + DSH_SNAPSHOT_FETCH_KEY: snapshot-key diff --git a/snapshots/session/web-search-endpoint-guidance/cordis.snapshot.yml b/snapshots/session/web-search-endpoint-guidance/cordis.snapshot.yml index c4add94e32..c98f75d1de 100644 --- a/snapshots/session/web-search-endpoint-guidance/cordis.snapshot.yml +++ b/snapshots/session/web-search-endpoint-guidance/cordis.snapshot.yml @@ -25,9 +25,9 @@ - id: web-search-error-fixture name: './web-search-error-fixture.mjs' -- id: web-search-deepseek - name: '@deepseek-ai/dsh-web-search-deepseek' +- id: web-deepseek-api + name: '@deepseek-ai/dsh-web-deepseek-api' config: - apiKey: snapshot-key + searchApiKeyEnv: DSH_SNAPSHOT_SEARCH_KEY # The fixture maps this recorded authority to its OS-assigned listener. - baseURL: http://127.0.0.1:43118/anthropic/v1 + searchBaseURL: http://127.0.0.1:43118/api/v1/tools diff --git a/snapshots/session/web-search-endpoint-guidance/cordis.yml b/snapshots/session/web-search-endpoint-guidance/cordis.yml index 13f1776aca..84892737e8 100644 --- a/snapshots/session/web-search-endpoint-guidance/cordis.yml +++ b/snapshots/session/web-search-endpoint-guidance/cordis.yml @@ -3,9 +3,9 @@ - id: web-search-error-fixture name: './web-search-error-fixture.mjs' -- id: web-search-deepseek - name: '@deepseek-ai/dsh-web-search-deepseek' +- id: web-deepseek-api + name: '@deepseek-ai/dsh-web-deepseek-api' config: - apiKey: snapshot-key + searchApiKeyEnv: DSH_SNAPSHOT_SEARCH_KEY # The fixture maps this recorded authority to its OS-assigned listener. - baseURL: http://127.0.0.1:43118/anthropic/v1 + searchBaseURL: http://127.0.0.1:43118/api/v1/tools diff --git a/snapshots/session/web-search-endpoint-guidance/session.v4.jsonl b/snapshots/session/web-search-endpoint-guidance/session.v4.jsonl index 8510d45868..94e8383092 100644 --- a/snapshots/session/web-search-endpoint-guidance/session.v4.jsonl +++ b/snapshots/session/web-search-endpoint-guidance/session.v4.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":4,"id":"{{session:1}}","createdAt":1792136613207,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1793631554249,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} {"type":"permission/preset","data":{"preset":"danger-full-access"}} {"type":"sandbox/mode","data":{"mode":"danger-full-access"}} {"type":"approval/policy","data":{"policy":"never"}} @@ -12,12 +12,11 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"max"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}} {"type":"session/title","data":{"title":"Use web_search exactly once to","messageSeqs":[8],"source":{"kind":"fallback"}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use web_search exactly once for \"DSH endpoint configuration snapshot\". If it fails, don't retry. Report only the endpoint and config/restriction facts stated in the tool error. Let me do that."},{"type":"tool-call","id":"call_00_F6R6olIacpEEdwQuqCm40371","name":"web_search","arguments":"{\"queries\": [\"DSH endpoint configuration snapshot\"]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"fdd73c03-6e1a-41e6-80ee-444ff2130de8"},{"type":"tool-call"}]}},"id":"{{message:4}}"},"usage":{"inputTokens":313,"outputTokens":93,"cacheReadTokens":5504,"cacheWriteTokens":0,"totalTokens":5910},"stream":[{"type":"chunk","time":1792136615206,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1792136615206,"index":0,"dt":[0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," use"," web","_search"," exactly"," once"," for"," \"","DS","H"," endpoint"," configuration"," snapshot","\"."," If"," it"," fails",","," don","'t"," ret","ry","."," Report"," only"," the"," endpoint"," and"," config","/","rest","riction"," facts"," stated"," in"," the"," tool"," error","."," Let"," me"," do"," that","."]},{"type":"chunk","time":1792136615208,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use web_search exactly once for \"DSH endpoint configuration snapshot\". If it fails, don't retry. Report only the endpoint and config/restriction facts stated in the tool error. Let me do that."}}},{"type":"chunk","time":1792136615208,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792136615208,"index":1,"dt":[],"id":"call_00_F6R6olIacpEEdwQuqCm40371","name":"web_search","args":[""]},{"type":"tool-call-chunks","time0":1792136615208,"index":1,"dt":[0,0,0,0,0,3,0,0,18,0,0,0,22],"id":"call_00_F6R6olIacpEEdwQuqCm40371","args":["{","\"","qu","eries","\"",": ","[\"","DS","H"," endpoint"," configuration"," snapshot","\"]","}"]},{"type":"chunk","time":1792136615285,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_F6R6olIacpEEdwQuqCm40371","name":"web_search","arguments":"{\"queries\": [\"DSH endpoint configuration snapshot\"]}"}}},{"type":"chunk","time":1792136615285,"chunk":{"type":"usage","usage":{"inputTokens":313,"outputTokens":93,"cacheReadTokens":5504,"cacheWriteTokens":0,"totalTokens":5910}}},{"type":"chunk","time":1792136615286,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"fdd73c03-6e1a-41e6-80ee-444ff2130de8"},{"type":"tool-call"}]}}}]},"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_F6R6olIacpEEdwQuqCm40371","name":"web_search","arguments":"{\"queries\": [\"DSH endpoint configuration snapshot\"]}"}} -{"type":"web/deepseek-search-llm-request","data":{"endpoint":"http://127.0.0.1:43118/anthropic/v1/messages","apiVersion":"2023-06-01","body":{"model":"deepseek-v4-flash","max_tokens":4096,"messages":[{"role":"user","content":[{"type":"text","text":"Perform a web search for the query: DSH endpoint configuration snapshot"}]}],"tools":[{"type":"web_search_20250305","name":"web_search","max_uses":5}],"thinking":{"type":"disabled"}}}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_F6R6olIacpEEdwQuqCm40371"},"toolCallId":"call_00_F6R6olIacpEEdwQuqCm40371","content":[{"type":"text","text":"Error: DeepSeek API error (HTTP 401): invalid snapshot API key\n\nThe web request used endpoint \"http://127.0.0.1:43118/anthropic/v1/messages\". Search endpoint configuration is separate from chat. If that endpoint is not intended, guide the user to Plugins > Built-in plugin settings > Web search, where they can change and save Messages API base URL. If that settings page is unavailable, the user can set web-search-deepseek.baseURL to a trusted DeepSeek endpoint. Only the user should choose or change the endpoint."}],"isError":true,"id":"{{message:5}}"},"error":{"name":"WebError","code":"WEB_PROVIDER_ERROR"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asks me to use web_search exactly once to search for \"DSH endpoint configuration snapshot\". If it fails, do not retry. Report only the endpoint and the configuration or restriction facts stated in the tool error. Do not infer why it failed or whether the endpoint is correct.\n\nLet me do the search exactly once."},{"type":"tool-call","id":"call_00_HpBk8vxIEM3XcZeUjkt16806","name":"web_search","arguments":"{\"queries\": [\"DSH endpoint configuration snapshot\"]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"7ad8cbc7-3237-4d85-a783-8a01ee302b7f"},{"type":"tool-call"}]}},"id":"{{message:4}}"},"usage":{"inputTokens":6345,"outputTokens":111,"cacheReadTokens":0,"cacheWriteTokens":0,"totalTokens":6456},"stream":[{"type":"chunk","time":1793631555376,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1793631555852,"index":0,"dt":[96,13,1,1,0,0,0,12,1,0,14,1,0,0,0,1,15,0,0,0,0,13,0,0,16,2,0,0,1,0,12,1,0,0,19,1,0,0,0,0,9,1,0,0,0,13,3,1,13,2,0,16,3,0,0,0,19,3,0,0,0,4,2,0,0],"texts":["The"," user"," asks"," me"," to"," use"," web","_search"," exactly"," once"," to"," search"," for"," \"","DS","H"," endpoint"," configuration"," snapshot","\"."," If"," it"," fails",","," do"," not"," ret","ry","."," Report"," only"," the"," endpoint"," and"," the"," configuration"," or"," restriction"," facts"," stated"," in"," the"," tool"," error","."," Do"," not"," infer"," why"," it"," failed"," or"," whether"," the"," endpoint"," is"," correct",".\n\n","Let"," me"," do"," the"," search"," exactly"," once","."]},{"type":"chunk","time":1793631556194,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asks me to use web_search exactly once to search for \"DSH endpoint configuration snapshot\". If it fails, do not retry. Report only the endpoint and the configuration or restriction facts stated in the tool error. Do not infer why it failed or whether the endpoint is correct.\n\nLet me do the search exactly once."}}},{"type":"chunk","time":1793631556194,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1793631556194,"index":1,"dt":[],"id":"call_00_HpBk8vxIEM3XcZeUjkt16806","name":"web_search","args":[""]},{"type":"tool-call-chunks","time0":1793631556206,"index":1,"dt":[3,10,3,0,16,0,0,0,0,1,20,11],"id":"call_00_HpBk8vxIEM3XcZeUjkt16806","args":["{\"","qu","eries","\"",": ","[\"","DS","H"," endpoint"," configuration"," snapshot","\"]","}"]},{"type":"chunk","time":1793631556297,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_HpBk8vxIEM3XcZeUjkt16806","name":"web_search","arguments":"{\"queries\": [\"DSH endpoint configuration snapshot\"]}"}}},{"type":"chunk","time":1793631556298,"chunk":{"type":"usage","usage":{"inputTokens":6345,"outputTokens":111,"cacheReadTokens":0,"cacheWriteTokens":0,"totalTokens":6456}}},{"type":"chunk","time":1793631556298,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"7ad8cbc7-3237-4d85-a783-8a01ee302b7f"},{"type":"tool-call"}]}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_HpBk8vxIEM3XcZeUjkt16806","name":"web_search","arguments":"{\"queries\": [\"DSH endpoint configuration snapshot\"]}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_HpBk8vxIEM3XcZeUjkt16806"},"toolCallId":"call_00_HpBk8vxIEM3XcZeUjkt16806","content":[{"type":"text","text":"Error: DeepSeek API error (HTTP 401): invalid snapshot API key\n\nThe web request used endpoint \"http://127.0.0.1:43118/api/v1/tools/search\". Search sends the API key under web-deepseek-api.searchApiKeyEnv to web-deepseek-api.searchBaseURL. If that endpoint is not intended, guide the user to correct web-deepseek-api.searchBaseURL and web-deepseek-api.searchApiKeyEnv, or to remove both so Search uses the DeepSeek credential of the conversation. Only the user should choose or change the endpoint."}],"isError":true,"id":"{{message:5}}"},"error":{"name":"WebError","code":"WEB_PROVIDER_ERROR"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":""},{"type":"text","text":"The single `web_search` call failed; no retry was made. Per the tool error:\n\n- **Endpoint used:** `http://127.0.0.1:43118/anthropic/v1/messages`\n- **Error:** DeepSeek API error (HTTP 401): invalid snapshot API key\n- **Configuration facts stated:** Search endpoint configuration is separate from chat. If that endpoint is not intended, the user can change/save the Messages API base URL under Plugins > Built-in plugin settings > Web search. If that settings page is unavailable, `web-search-deepseek.baseURL` can be set to a trusted DeepSeek endpoint. Only the user should choose or change the endpoint.\n\nI make no inference about the cause of the failure or the correctness of the endpoint."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"7228c543-8a73-4759-90eb-9c1fbcf6b340"},{"type":"text"}]}},"id":"{{message:6}}"},"usage":{"inputTokens":153,"outputTokens":164,"cacheReadTokens":5888,"cacheWriteTokens":0,"totalTokens":6205},"stream":[{"type":"chunk","time":1792136619364,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"chunk","time":1792136619364,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":""}}},{"type":"chunk","time":1792136619364,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1792136619364,"index":1,"dt":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,87,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0],"texts":["The"," single"," `","web","_search","`"," call"," failed",";"," no"," ret","ry"," was"," made","."," Per"," the"," tool"," error",":\n\n","-"," **","Endpoint"," used",":**"," `","http","://","127",".","0",".","0",".","1",":","431","18","/an","throp","ic","/v","1","/m","essages","`\n","-"," **","Error",":**"," Deep","Se","ek"," API"," error"," (","HTTP"," ","401","):"," invalid"," snapshot"," API"," key","\n","-"," **","Configuration"," facts"," stated",":**"," Search"," endpoint"," configuration"," is"," separate"," from"," chat","."," If"," that"," endpoint"," is"," not"," intended",","," the"," user"," can"," change","/s","ave"," the"," Messages"," API"," base"," URL"," under"," Plug","ins"," >"," Built","-in"," plugin"," settings"," >"," Web"," search","."," If"," that"," settings"," page"," is"," unavailable",","," `","web","-search","-de","ep","seek",".base","URL","`"," can"," be"," set"," to"," a"," trusted"," Deep","Se","ek"," endpoint","."," Only"," the"," user"," should"," choose"," or"," change"," the"," endpoint",".\n\n","I"," make"," no"," inference"," about"," the"," cause"," of"," the"," failure"," or"," the"," correctness"," of"," the"," endpoint","."]},{"type":"chunk","time":1792136619459,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The single `web_search` call failed; no retry was made. Per the tool error:\n\n- **Endpoint used:** `http://127.0.0.1:43118/anthropic/v1/messages`\n- **Error:** DeepSeek API error (HTTP 401): invalid snapshot API key\n- **Configuration facts stated:** Search endpoint configuration is separate from chat. If that endpoint is not intended, the user can change/save the Messages API base URL under Plugins > Built-in plugin settings > Web search. If that settings page is unavailable, `web-search-deepseek.baseURL` can be set to a trusted DeepSeek endpoint. Only the user should choose or change the endpoint.\n\nI make no inference about the cause of the failure or the correctness of the endpoint."}}},{"type":"chunk","time":1792136619459,"chunk":{"type":"usage","usage":{"inputTokens":153,"outputTokens":164,"cacheReadTokens":5888,"cacheWriteTokens":0,"totalTokens":6205}}},{"type":"chunk","time":1792136619459,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"7228c543-8a73-4759-90eb-9c1fbcf6b340"},{"type":"text"}]}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Report only the endpoint and the configuration/restriction facts stated in the tool error. Don't infer why it failed or whether the endpoint is correct.\n\nFacts stated:\n- Endpoint: http://127.0.0.1:43118/api/v1/tools/search\n- Search sends the API key under web-deepseek-api.searchApiKeyEnv to web-deepseek-api.searchBaseURL\n- If that endpoint is not intended, guide the user to correct web-deepseek-api.searchBaseURL and web-deepseek-api.searchApiKeyEnv, or remove both so Search uses the DeepSeek credential of the conversation.\n- Only the user should choose or change the endpoint."},{"type":"text","text":"Search returned an error. Endpoint: `http://127.0.0.1:43118/api/v1/tools/search`\n\nFacts stated in the error:\n- Search sends the API key under `web-deepseek-api.searchApiKeyEnv` to `web-deepseek-api.searchBaseURL`.\n- If that endpoint is not intended, the guidance is to correct `web-deepseek-api.searchBaseURL` and `web-deepseek-api.searchApiKeyEnv`, or remove both so Search uses the conversation's DeepSeek credential.\n- Only the user should choose or change the endpoint."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"6b2c4e7c-ffea-4cfb-a106-f29a27ef404f"},{"type":"text"}]}},"id":"{{message:6}}"},"usage":{"inputTokens":191,"outputTokens":266,"cacheReadTokens":6400,"cacheWriteTokens":0,"totalTokens":6857},"stream":[{"type":"chunk","time":1793631557129,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1793631557307,"index":0,"dt":[34,17,1,0,0,0,11,16,0,1,0,0,0,24,1,0,0,1,2,0,25,3,0,2,0,1,0,0,95,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,34,2,0,0,0,0,3,2,1,0,0,0,444,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["Report"," only"," the"," endpoint"," and"," the"," configuration","/","rest","riction"," facts"," stated"," in"," the"," tool"," error","."," Don","'t"," infer"," why"," it"," failed"," or"," whether"," the"," endpoint"," is"," correct",".\n\n","F","acts"," stated",":\n","-"," End","point",":"," http","://","127",".","0",".","0",".","1",":","431","18","/api","/v","1","/t","ools","/search","\n","-"," Search"," sends"," the"," API"," key"," under"," web","-de","ep","seek","-api",".search","Api","Key","Env"," to"," web","-de","ep","seek","-api",".search","Base","URL","\n","-"," If"," that"," endpoint"," is"," not"," intended",","," guide"," the"," user"," to"," correct"," web","-de","ep","seek","-api",".search","Base","URL"," and"," web","-de","ep","seek","-api",".search","Api","Key","Env",","," or"," remove"," both"," so"," Search"," uses"," the"," Deep","Se","ek"," credential"," of"," the"," conversation",".\n","-"," Only"," the"," user"," should"," choose"," or"," change"," the"," endpoint","."]},{"type":"chunk","time":1793631558034,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Report only the endpoint and the configuration/restriction facts stated in the tool error. Don't infer why it failed or whether the endpoint is correct.\n\nFacts stated:\n- Endpoint: http://127.0.0.1:43118/api/v1/tools/search\n- Search sends the API key under web-deepseek-api.searchApiKeyEnv to web-deepseek-api.searchBaseURL\n- If that endpoint is not intended, guide the user to correct web-deepseek-api.searchBaseURL and web-deepseek-api.searchApiKeyEnv, or remove both so Search uses the DeepSeek credential of the conversation.\n- Only the user should choose or change the endpoint."}}},{"type":"chunk","time":1793631558034,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1793631558035,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,80,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,11,8,1,0,6,4,0,0,0,28,0,0,0,0,0,0,0,0,0,3,0],"texts":["Search"," returned"," an"," error","."," End","point",":"," `","http","://","127",".","0",".","0",".","1",":","431","18","/api","/v","1","/t","ools","/search","`\n\n","F","acts"," stated"," in"," the"," error",":\n","-"," Search"," sends"," the"," API"," key"," under"," `","web","-de","ep","seek","-api",".search","Api","Key","Env","`"," to"," `","web","-de","ep","seek","-api",".search","Base","URL","`.\n","-"," If"," that"," endpoint"," is"," not"," intended",","," the"," guidance"," is"," to"," correct"," `","web","-de","ep","seek","-api",".search","Base","URL","`"," and"," `","web","-de","ep","seek","-api",".search","Api","Key","Env","`,"," or"," remove"," both"," so"," Search"," uses"," the"," conversation","'s"," Deep","Se","ek"," credential",".\n","-"," Only"," the"," user"," should"," choose"," or"," change"," the"," endpoint","."]},{"type":"chunk","time":1793631558183,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Search returned an error. Endpoint: `http://127.0.0.1:43118/api/v1/tools/search`\n\nFacts stated in the error:\n- Search sends the API key under `web-deepseek-api.searchApiKeyEnv` to `web-deepseek-api.searchBaseURL`.\n- If that endpoint is not intended, the guidance is to correct `web-deepseek-api.searchBaseURL` and `web-deepseek-api.searchApiKeyEnv`, or remove both so Search uses the conversation's DeepSeek credential.\n- Only the user should choose or change the endpoint."}}},{"type":"chunk","time":1793631558183,"chunk":{"type":"usage","usage":{"inputTokens":191,"outputTokens":266,"cacheReadTokens":6400,"cacheWriteTokens":0,"totalTokens":6857}}},{"type":"chunk","time":1793631558183,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"6b2c4e7c-ffea-4cfb-a106-f29a27ef404f"},{"type":"text"}]}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/web-search-endpoint-guidance/snapshot.yml b/snapshots/session/web-search-endpoint-guidance/snapshot.yml index 31548acb92..039668c2ed 100644 --- a/snapshots/session/web-search-endpoint-guidance/snapshot.yml +++ b/snapshots/session/web-search-endpoint-guidance/snapshot.yml @@ -8,3 +8,5 @@ header: pin: true systemPromptSource: text-turn toolSchemasSource: text-turn +environment: + DSH_SNAPSHOT_SEARCH_KEY: snapshot-key diff --git a/snapshots/session/web-search-endpoint-guidance/web-search-error-fixture.mjs b/snapshots/session/web-search-endpoint-guidance/web-search-error-fixture.mjs index 3c0d9f5d29..7e2358cc7b 100644 --- a/snapshots/session/web-search-endpoint-guidance/web-search-error-fixture.mjs +++ b/snapshots/session/web-search-endpoint-guidance/web-search-error-fixture.mjs @@ -2,7 +2,7 @@ import { applyLoopbackServerEffect } from '../loopback-fixture-server.mjs' /** Model-visible endpoint retained by the recorded session. */ -const RECORDED_ENDPOINT = 'http://127.0.0.1:43118/anthropic/v1/messages' +const RECORDED_ENDPOINT = 'http://127.0.0.1:43118/api/v1/tools/search' const RECORDED_URL = new URL(RECORDED_ENDPOINT) /** Cordis plugin name. */ @@ -33,13 +33,13 @@ function transportInput(input, transportEndpoint) { return input } -/** Start the local Messages endpoint and stop it with the plugin fiber. */ +/** Start the local Search API endpoint and stop it with the plugin fiber. */ export async function apply(ctx) { let restoreFetch = () => {} await applyLoopbackServerEffect(ctx, { label: 'web-search-error-fixture', requestListener: (request, response) => { - if (request.method === 'POST' && request.url === '/anthropic/v1/messages') { + if (request.method === 'POST' && request.url === '/api/v1/tools/search') { response.writeHead(401, { 'content-type': 'application/json' }) response.end(JSON.stringify({ error: { message: 'invalid snapshot API key' } })) return @@ -48,7 +48,7 @@ export async function apply(ctx) { response.end('not found') }, onListening: (address) => { - const transportEndpoint = `http://127.0.0.1:${String(address.port)}/anthropic/v1/messages` + const transportEndpoint = `http://127.0.0.1:${String(address.port)}/api/v1/tools/search` const originalFetch = globalThis.fetch const fixtureFetch = async (input, init) => originalFetch(transportInput(input, transportEndpoint), init) globalThis.fetch = fixtureFetch diff --git a/snapshots/session/web-search-legacy-endpoint-guidance/cordis.snapshot.yml b/snapshots/session/web-search-legacy-endpoint-guidance/cordis.snapshot.yml new file mode 100644 index 0000000000..6498244d5e --- /dev/null +++ b/snapshots/session/web-search-legacy-endpoint-guidance/cordis.snapshot.yml @@ -0,0 +1,40 @@ +# Keyless replay counterpart: the failed search remains real; only the +# conversation model adapter is replaced by replay. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek-api-key' + disabled: true + +- id: plugin-package-inventory-deepseek + disabled: true + +- insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + contextWindow: 1000000 + defaultMaxTokens: 256000 + reasoningEfforts: ['off', 'low', 'high', 'max'] + defaultReasoningEffort: max + - id: deepseek-v4-pro + + - id: web-search-error-fixture + name: './web-search-error-fixture.mjs' + +- id: web-search-deepseek + name: '@deepseek-ai/dsh-web-search-deepseek' + config: + apiKey: snapshot-key + # The fixture maps this recorded authority to its OS-assigned listener. + baseURL: http://127.0.0.1:43118/anthropic/v1 + +# The case exercises legacy Messages search, which the base no longer selects by default. +- id: web + name: '@deepseek-ai/dsh-web' + config: + searchProvider: deepseek-official + fetchProvider: http diff --git a/snapshots/session/web-search-legacy-endpoint-guidance/cordis.yml b/snapshots/session/web-search-legacy-endpoint-guidance/cordis.yml new file mode 100644 index 0000000000..af45802f6a --- /dev/null +++ b/snapshots/session/web-search-legacy-endpoint-guidance/cordis.yml @@ -0,0 +1,18 @@ +# Live-recording patch for a deterministic failed DeepSeek search request. +- insert: + - id: web-search-error-fixture + name: './web-search-error-fixture.mjs' + +- id: web-search-deepseek + name: '@deepseek-ai/dsh-web-search-deepseek' + config: + apiKey: snapshot-key + # The fixture maps this recorded authority to its OS-assigned listener. + baseURL: http://127.0.0.1:43118/anthropic/v1 + +# The case exercises legacy Messages search, which the base no longer selects by default. +- id: web + name: '@deepseek-ai/dsh-web' + config: + searchProvider: deepseek-official + fetchProvider: http diff --git a/snapshots/session/web-search-legacy-endpoint-guidance/session.v4.jsonl b/snapshots/session/web-search-legacy-endpoint-guidance/session.v4.jsonl new file mode 100644 index 0000000000..d97c4e3c77 --- /dev/null +++ b/snapshots/session/web-search-legacy-endpoint-guidance/session.v4.jsonl @@ -0,0 +1,23 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1792136613207,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use web_search exactly once to search for DSH endpoint configuration snapshot. If it fails, do not retry. Report only the endpoint and the configuration or restriction facts stated in the tool error. Do not infer why it failed or whether the endpoint is correct."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Use web_search exactly once to search for DSH endpoint configuration snapshot. If it fails, do not retry. Report only the endpoint and the configuration or restriction facts stated in the tool error. Do not infer why it failed or whether the endpoint is correct."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are the root agent. Your agent id is \"{{session:1}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:identity","text":"You are the root agent. Your agent id is \"{{session:1}}\".\n\nTo message other agents, use send_message({ agent_id: \"\", message: \"...\" }).\nUse list_agents({}) to find your collaborator agents and their ids."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"max"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}} +{"type":"session/title","data":{"title":"Use web_search exactly once to","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use web_search exactly once for \"DSH endpoint configuration snapshot\". If it fails, don't retry. Report only the endpoint and config/restriction facts stated in the tool error. Let me do that."},{"type":"tool-call","id":"call_00_F6R6olIacpEEdwQuqCm40371","name":"web_search","arguments":"{\"queries\": [\"DSH endpoint configuration snapshot\"]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"fdd73c03-6e1a-41e6-80ee-444ff2130de8"},{"type":"tool-call"}]}},"id":"{{message:4}}"},"usage":{"inputTokens":313,"outputTokens":93,"cacheReadTokens":5504,"cacheWriteTokens":0,"totalTokens":5910},"stream":[{"type":"chunk","time":1792136615206,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1792136615206,"index":0,"dt":[0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," use"," web","_search"," exactly"," once"," for"," \"","DS","H"," endpoint"," configuration"," snapshot","\"."," If"," it"," fails",","," don","'t"," ret","ry","."," Report"," only"," the"," endpoint"," and"," config","/","rest","riction"," facts"," stated"," in"," the"," tool"," error","."," Let"," me"," do"," that","."]},{"type":"chunk","time":1792136615208,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use web_search exactly once for \"DSH endpoint configuration snapshot\". If it fails, don't retry. Report only the endpoint and config/restriction facts stated in the tool error. Let me do that."}}},{"type":"chunk","time":1792136615208,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1792136615208,"index":1,"dt":[],"id":"call_00_F6R6olIacpEEdwQuqCm40371","name":"web_search","args":[""]},{"type":"tool-call-chunks","time0":1792136615208,"index":1,"dt":[0,0,0,0,0,3,0,0,18,0,0,0,22],"id":"call_00_F6R6olIacpEEdwQuqCm40371","args":["{","\"","qu","eries","\"",": ","[\"","DS","H"," endpoint"," configuration"," snapshot","\"]","}"]},{"type":"chunk","time":1792136615285,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_F6R6olIacpEEdwQuqCm40371","name":"web_search","arguments":"{\"queries\": [\"DSH endpoint configuration snapshot\"]}"}}},{"type":"chunk","time":1792136615285,"chunk":{"type":"usage","usage":{"inputTokens":313,"outputTokens":93,"cacheReadTokens":5504,"cacheWriteTokens":0,"totalTokens":5910}}},{"type":"chunk","time":1792136615286,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"fdd73c03-6e1a-41e6-80ee-444ff2130de8"},{"type":"tool-call"}]}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_F6R6olIacpEEdwQuqCm40371","name":"web_search","arguments":"{\"queries\": [\"DSH endpoint configuration snapshot\"]}"}} +{"type":"web/deepseek-search-llm-request","data":{"endpoint":"http://127.0.0.1:43118/anthropic/v1/messages","apiVersion":"2023-06-01","body":{"model":"deepseek-v4-flash","max_tokens":4096,"messages":[{"role":"user","content":[{"type":"text","text":"Perform a web search for the query: DSH endpoint configuration snapshot"}]}],"tools":[{"type":"web_search_20250305","name":"web_search","max_uses":5}],"thinking":{"type":"disabled"}}}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_F6R6olIacpEEdwQuqCm40371"},"toolCallId":"call_00_F6R6olIacpEEdwQuqCm40371","content":[{"type":"text","text":"Error: DeepSeek API error (HTTP 401): invalid snapshot API key\n\nThe web request used endpoint \"http://127.0.0.1:43118/anthropic/v1/messages\". Search endpoint configuration is separate from chat. If that endpoint is not intended, guide the user to Plugins > Built-in plugin settings > Web search and fetch, select DeepSeek (legacy) under Search, and change and save its Base URL. If that settings page is unavailable, the user can set web-search-deepseek.baseURL to a trusted DeepSeek endpoint. Only the user should choose or change the endpoint."}],"isError":true,"id":"{{message:5}}"},"error":{"name":"WebError","code":"WEB_PROVIDER_ERROR"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":""},{"type":"text","text":"The single `web_search` call failed; no retry was made. Per the tool error:\n\n- **Endpoint used:** `http://127.0.0.1:43118/anthropic/v1/messages`\n- **Error:** DeepSeek API error (HTTP 401): invalid snapshot API key\n- **Configuration facts stated:** Search endpoint configuration is separate from chat. If that endpoint is not intended, the user can change/save the Messages API base URL under Plugins > Built-in plugin settings > Web search. If that settings page is unavailable, `web-search-deepseek.baseURL` can be set to a trusted DeepSeek endpoint. Only the user should choose or change the endpoint.\n\nI make no inference about the cause of the failure or the correctness of the endpoint."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"7228c543-8a73-4759-90eb-9c1fbcf6b340"},{"type":"text"}]}},"id":"{{message:6}}"},"usage":{"inputTokens":153,"outputTokens":164,"cacheReadTokens":5888,"cacheWriteTokens":0,"totalTokens":6205},"stream":[{"type":"chunk","time":1792136619364,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"chunk","time":1792136619364,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":""}}},{"type":"chunk","time":1792136619364,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1792136619364,"index":1,"dt":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,87,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0],"texts":["The"," single"," `","web","_search","`"," call"," failed",";"," no"," ret","ry"," was"," made","."," Per"," the"," tool"," error",":\n\n","-"," **","Endpoint"," used",":**"," `","http","://","127",".","0",".","0",".","1",":","431","18","/an","throp","ic","/v","1","/m","essages","`\n","-"," **","Error",":**"," Deep","Se","ek"," API"," error"," (","HTTP"," ","401","):"," invalid"," snapshot"," API"," key","\n","-"," **","Configuration"," facts"," stated",":**"," Search"," endpoint"," configuration"," is"," separate"," from"," chat","."," If"," that"," endpoint"," is"," not"," intended",","," the"," user"," can"," change","/s","ave"," the"," Messages"," API"," base"," URL"," under"," Plug","ins"," >"," Built","-in"," plugin"," settings"," >"," Web"," search","."," If"," that"," settings"," page"," is"," unavailable",","," `","web","-search","-de","ep","seek",".base","URL","`"," can"," be"," set"," to"," a"," trusted"," Deep","Se","ek"," endpoint","."," Only"," the"," user"," should"," choose"," or"," change"," the"," endpoint",".\n\n","I"," make"," no"," inference"," about"," the"," cause"," of"," the"," failure"," or"," the"," correctness"," of"," the"," endpoint","."]},{"type":"chunk","time":1792136619459,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The single `web_search` call failed; no retry was made. Per the tool error:\n\n- **Endpoint used:** `http://127.0.0.1:43118/anthropic/v1/messages`\n- **Error:** DeepSeek API error (HTTP 401): invalid snapshot API key\n- **Configuration facts stated:** Search endpoint configuration is separate from chat. If that endpoint is not intended, the user can change/save the Messages API base URL under Plugins > Built-in plugin settings > Web search. If that settings page is unavailable, `web-search-deepseek.baseURL` can be set to a trusted DeepSeek endpoint. Only the user should choose or change the endpoint.\n\nI make no inference about the cause of the failure or the correctness of the endpoint."}}},{"type":"chunk","time":1792136619459,"chunk":{"type":"usage","usage":{"inputTokens":153,"outputTokens":164,"cacheReadTokens":5888,"cacheWriteTokens":0,"totalTokens":6205}}},{"type":"chunk","time":1792136619459,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"deepseek-v4-flash"},"blocks":[{"type":"reasoning","signature":"7228c543-8a73-4759-90eb-9c1fbcf6b340"},{"type":"text"}]}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/web-search-legacy-endpoint-guidance/snapshot.yml b/snapshots/session/web-search-legacy-endpoint-guidance/snapshot.yml new file mode 100644 index 0000000000..044df89a71 --- /dev/null +++ b/snapshots/session/web-search-legacy-endpoint-guidance/snapshot.yml @@ -0,0 +1,10 @@ +version: 1 +scenario: web-search-legacy-endpoint-guidance +profile: headless +composition: web-search-legacy-endpoint-guidance +recording: live +header: + class: web-search-legacy-endpoint-guidance + pin: true + systemPromptSource: text-turn + toolSchemasSource: text-turn diff --git a/snapshots/session/web-search-legacy-endpoint-guidance/web-search-error-fixture.mjs b/snapshots/session/web-search-legacy-endpoint-guidance/web-search-error-fixture.mjs new file mode 100644 index 0000000000..3c0d9f5d29 --- /dev/null +++ b/snapshots/session/web-search-legacy-endpoint-guidance/web-search-error-fixture.mjs @@ -0,0 +1,64 @@ +/** Deterministic authentication failure for the search endpoint guidance snapshot. */ +import { applyLoopbackServerEffect } from '../loopback-fixture-server.mjs' + +/** Model-visible endpoint retained by the recorded session. */ +const RECORDED_ENDPOINT = 'http://127.0.0.1:43118/anthropic/v1/messages' +const RECORDED_URL = new URL(RECORDED_ENDPOINT) + +/** Cordis plugin name. */ +export const name = 'web-search-error-fixture' + +function requestUrl(input) { + if (typeof input === 'string') return input + if (input instanceof URL) return input.href + if (input instanceof Request) return input.url + return undefined +} + +function transportInput(input, transportEndpoint) { + const url = requestUrl(input) + if (url === RECORDED_ENDPOINT) { + return input instanceof Request ? new Request(transportEndpoint, input) : transportEndpoint + } + if (url === undefined) return input + let parsed + try { + parsed = new URL(url) + } catch { + return input + } + if (parsed.host === RECORDED_URL.host) { + throw new Error(`web-search-error-fixture: unexpected URL for recorded authority: ${url}`) + } + return input +} + +/** Start the local Messages endpoint and stop it with the plugin fiber. */ +export async function apply(ctx) { + let restoreFetch = () => {} + await applyLoopbackServerEffect(ctx, { + label: 'web-search-error-fixture', + requestListener: (request, response) => { + if (request.method === 'POST' && request.url === '/anthropic/v1/messages') { + response.writeHead(401, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: { message: 'invalid snapshot API key' } })) + return + } + response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) + response.end('not found') + }, + onListening: (address) => { + const transportEndpoint = `http://127.0.0.1:${String(address.port)}/anthropic/v1/messages` + const originalFetch = globalThis.fetch + const fixtureFetch = async (input, init) => originalFetch(transportInput(input, transportEndpoint), init) + globalThis.fetch = fixtureFetch + restoreFetch = () => { + if (globalThis.fetch !== fixtureFetch) { + throw new Error('web-search-error-fixture: global fetch owner changed before cleanup') + } + globalThis.fetch = originalFetch + } + }, + onCleanup: () => restoreFetch(), + }) +} diff --git a/snapshots/session/web-search-unconfigured/cordis.snapshot.yml b/snapshots/session/web-search-unconfigured/cordis.snapshot.yml index de57d66bab..09d678dbcd 100644 --- a/snapshots/session/web-search-unconfigured/cordis.snapshot.yml +++ b/snapshots/session/web-search-unconfigured/cordis.snapshot.yml @@ -44,7 +44,11 @@ - id: deepseek-v4-flash - id: deepseek-v4-pro +- id: web-deepseek-api + name: '@deepseek-ai/dsh-web-deepseek-api' + config: + searchApiKeyEnv: DSH_SNAPSHOT_UNSET_SEARCH_KEY + - id: web-search-deepseek - name: '@deepseek-ai/dsh-web-search-deepseek' config: apiKeyEnv: DSH_SNAPSHOT_UNSET_SEARCH_KEY diff --git a/snapshots/session/web-search-unconfigured/cordis.yml b/snapshots/session/web-search-unconfigured/cordis.yml index 89c4d4fd87..32edc94de2 100644 --- a/snapshots/session/web-search-unconfigured/cordis.yml +++ b/snapshots/session/web-search-unconfigured/cordis.yml @@ -1,6 +1,10 @@ # Live-recording patch: the search key reference names no credential, so the # model request withholds web_search while the rest of the tool surface stays. +- id: web-deepseek-api + name: '@deepseek-ai/dsh-web-deepseek-api' + config: + searchApiKeyEnv: DSH_SNAPSHOT_UNSET_SEARCH_KEY + - id: web-search-deepseek - name: '@deepseek-ai/dsh-web-search-deepseek' config: apiKeyEnv: DSH_SNAPSHOT_UNSET_SEARCH_KEY diff --git a/tsconfig.base.json b/tsconfig.base.json index 83cc28cd3e..8d5f3283d3 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -605,8 +605,6 @@ "@deepseek-ai/dsh-web-deepseek-api": ["./packages/web/web-deepseek-api/src"], "@deepseek-ai/dsh-web-fetch-http": ["./packages/web/web-fetch-http/src"], "@deepseek-ai/dsh-web-search-deepseek": ["./packages/web/web-search-deepseek/src"], - "@deepseek-ai/dsh-web-search-exa": ["./packages/web/web-search-exa/src"], - "@deepseek-ai/dsh-web-search-perplexity": ["./packages/web/web-search-perplexity/src"], "@deepseek-ai/dsh-webhook": ["./packages/experimental/webhook/src"], "@deepseek-ai/dsh-webhook-github": ["./packages/experimental/webhook-github/src"], "@deepseek-ai/dsh-win32-process": ["./packages/subprocess/win32-process/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 761e67e4b7..c42431ddbb 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -506,8 +506,6 @@ { "path": "./packages/compaction/compaction-tool-result-pruner" }, { "path": "./packages/compaction/compaction-image-offload" }, { "path": "./packages/web/web" }, - { "path": "./packages/web/web-search-exa" }, - { "path": "./packages/web/web-search-perplexity" }, { "path": "./packages/web/web-search-deepseek" }, { "path": "./packages/web/web-deepseek-api" }, { "path": "./packages/web/web-fetch-http" },