diff --git a/.scratch/deepseek-search-provider/implementation-report.md b/.scratch/deepseek-search-provider/implementation-report.md new file mode 100644 index 0000000..80adf0a --- /dev/null +++ b/.scratch/deepseek-search-provider/implementation-report.md @@ -0,0 +1,141 @@ +# DeepSeek search provider implementation report + +## Scope + +- Repository: `guionai/web` +- Branch: `deepseek-search-provider` +- Fixed point: `987f664b1d43d2b3325849a8ed818c7332bf8808` +- Implementation commit: `6826d3ead70eafaca21965b125041dc95ef63a7a` (`feat(search): add explicit DeepSeek provider`) +- Delivery boundary: the complete `deepseek-search-provider` spec and tickets + 01, 02, and 03. Code review and deployment were excluded. + +The tickets were implemented in dependency order: the shared Core adapter and +selection seam first, then CLI/MCP/Pi and DSH, followed by the server-local +HTTP selection and OpenAPI contract. + +## Review repair batch + +- Repair commit: `e7ef2fc` (`fix(search): tighten DeepSeek review contracts`). +- The review-again gate classified these as local contract/test repairs: the + runtime method and risk surface are unchanged, so focused verification was + sufficient and a second broad code review was not required. +- Removed the three DeepSeek packed-artifact source-string assertions from the + artifact test. Existing runtime, provider, and rendered-settings tests remain + the behavior coverage for those contracts. +- DeepSeek's seven endpoint/model/version/token/tool protocol constants are now + implementation-private in web-core. The Core fixture asserts the expected + protocol through test-local constants instead of importing production + implementation details. +- DSH alpha.3 deployed-entrypoint acceptance was unavailable on this host. The + documented path `/home/neil/.local/share/dsh-runtime/node_modules/@deepseek-ai/dsh/lib/bin.js` + failed the availability check (`test -f` returned `unavailable`), and the + direct probe `node --expose-internals /home/neil/.local/share/dsh-runtime/node_modules/@deepseek-ai/dsh/lib/bin.js --help` + failed with `MODULE_NOT_FOUND`. Consequently, no Host/browser acceptance was + run, and no host state or credentials were created or modified. + +## Ticket outcomes + +### 01 — Add explicit DeepSeek search outside DSH + +- Core recognizes `deepseek` only when explicitly selected and requires + `DEEPSEEK_API_KEY`; Exa/Brave implicit selection remains unchanged, so a + DeepSeek key alone never changes the default. +- The adapter posts one fixed Anthropic-compatible Messages request to + `https://api.deepseek.com/anthropic/v1/messages` using + `deepseek-v4-flash`, `max_tokens: 4096`, the fixed auxiliary search prompt, + and `web_search_20250305` with `max_uses: 5`. The endpoint/model/tool details + are not host request fields. +- Only structured `web_search_tool_result` / `web_search_result` blocks are + mapped. URL-keyed `cited_text` excerpts become snippets, duplicate URLs are + removed, and prose-only responses are provider errors. Requests use the + existing bounded timeout/cancellation and secret-safe response handling. +- CLI and MCP flags, Pi's existing provider environment mechanism, runtime + credentials, and MCP error redaction recognize DeepSeek. Their search inputs + remain provider-neutral where applicable. +- README and host documentation describe explicit selection, normalized + results, and the one auxiliary model-call cost. + +### 02 — Add DeepSeek to DSH provider settings + +- DSH's live provider union/picker includes `deepseek` and its label. +- The settings client manages the namespaced write-only + `GUIONAI_DSH_WEB_DEEPSEEK_API_KEY` credential alongside Exa and Brave. The + UI has no DeepSeek endpoint field; the existing endpoint control remains + explicitly a Kepos Bridge setting. +- The host adapter resolves only the selected DeepSeek credential for each + operation and forwards `provider: "deepseek"` through Core. Kepos-only tool + registration and non-DeepSeek provider behavior remain unchanged. +- DSH host, browser-client, packed-artifact, settings, and provider-fake tests + cover selection, DeepSeek metadata-only status, the existing write/remove + flow, and artifact presence without live credentials. +- The DSH README and package metadata document the picker/key workflow, no + DeepSeek endpoint input, normalized results, and call cost. + +### 03 — Select DeepSeek for the personal HTTP service + +- With no `WEB_SEARCH_PROVIDER`, HTTP retains its Bridge-first policy and one + Exa retry, including the existing Exa startup requirement. +- With server-local `WEB_SEARCH_PROVIDER=deepseek`, startup requires a + non-empty `DEEPSEEK_API_KEY`; each request calls DeepSeek exactly once and + never falls back to Bridge or Exa. HTTP clients still send only + `{ "query": "..." }`. +- HTTP validation and generated OpenAPI include the `DeepSeek` response label + without adding a request provider field. Fakes cover selected success, + failure/no-fallback, startup key validation, cancellation treatment, and + unchanged default behavior. +- The HTTP reference, README, glossary, and ADR document the server-local + selection, credential boundary, normalized contract, and no-fallback rule. + +## Verification + +All implementation and review-repair checks completed successfully: + +- Focused `pnpm exec vitest run packages/web-core/test/search.test.ts + packages/dsh-web/test/artifact.test.ts` — 2 files and 14 tests passed. + +- `pnpm format:check` and explicit Prettier checks for the changed Markdown + references; `git diff --check`. +- `pnpm typecheck`. +- `pnpm test` — 19 test files and 132 tests passed. The existing missing DSH + primitive source-map warning was non-fatal. +- `pnpm build` — all four workspace packages built and generated + `packages/web/dist/openapi.yaml` with the DeepSeek response enum. +- `pnpm test:release` — release version synchronization fixtures passed. +- `pnpm test:pack` — packed-installation/host-loading checks passed for + `@guionai/web`, `@guionai/pi-web`, and `@guionai/dsh-web`. + +No live DeepSeek service, credentials, production services, or persistent host +state were used. + +## Changed paths and size + +Against the fixed point, excluding generated `dist` output, lockfiles, and +this report: + +- Product code: 209 additions and 12 deletions (221 changed lines) across + Core, CLI, MCP, Pi, DSH, and HTTP. +- Tests: 372 additions and 2 deletions (374 changed lines), including Core + request/mapping/error seams, adapter forwarding, DSH credential/UI checks, + HTTP selection, and OpenAPI assertions. +- Documentation/configuration: 71 additions and 37 deletions (108 changed + lines), including README, HTTP reference, glossary, ADR, DSH README, and + package metadata. +- Total: 652 additions and 51 deletions (703 changed lines), within the spec's + 580–970 total-line estimate. Product code is below its 260–410 estimate + because the implementation reuses the existing bounded-request, normalized + result, host-selection, settings, and artifact seams; the tests and + documentation remain within their estimated ranges. + +## Remaining concerns + +- A live DeepSeek capability/account probe remains intentionally deferred by + the spec; tests use injected local fetch fixtures only. +- Public or multi-tenant HTTP hardening remains out of scope as documented by + ADR 0001 and the existing deferred security note. +- A second broad code review and deployment were intentionally not run; the + review-again gate classified this batch as local repairs. + +## Acceptance result + +Tickets 01, 02, and 03 and the complete `deepseek-search-provider` spec are +implemented and verified. diff --git a/CONTEXT.md b/CONTEXT.md index e7fdedd..5cd9d1c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -13,8 +13,13 @@ One of Guion Web's read-only capabilities: Search, Fetch, Links, Documentation R _Avoid_: Tool, endpoint **Search**: -The general web-retrieval Research Operation. In the Personal Web Service, it uses Kepos Bridge first and transparently falls back to Exa only when the Bridge is unavailable; its response identifies the provider that supplied results. -_Avoid_: Bridge search, provider-selected search +The general web-retrieval Research Operation. CLI, MCP, Pi, and DSH can +explicitly select Exa, Brave, DeepSeek, or Kepos Bridge. In the Personal Web +Service, it uses Kepos Bridge first and transparently falls back to Exa only +when the Bridge is unavailable unless the operator sets +`WEB_SEARCH_PROVIDER=deepseek`; that server-local mode calls DeepSeek only and +has no fallback. Its response identifies the provider that supplied results. +_Avoid_: Bridge search, HTTP provider parameter **Bridge Data Operation**: A typed, Bridge-only lookup for weather, sports, finance, or time. It has no Exa fallback because Exa search is not an equivalent result source. @@ -45,8 +50,9 @@ _Avoid_: Checked-in OpenAPI file, independently versioned schema **HTTP Service**: The Hono-based `/v1` JSON API shipped by `web serve` and the GHCR image. It -uses server-local credentials and Bridge Route configuration; clients do not -select providers or submit a generic Bridge command. Its page-reading routes use +uses server-local credentials, Bridge Route, and optional DeepSeek provider +configuration; clients do not select providers or submit a generic Bridge +command. Its page-reading routes use the same `render: "http" | "browser"`, `full`, and `section_id` contract; the browser executable name appears only in operator setup. _Avoid_: Remote MCP, public service diff --git a/README.md b/README.md index c43369f..71bce84 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Guion Web -Guion Web is a Node.js web research toolkit. It provides Exa, Brave, or a -managed Kepos Bridge search endpoint, +Guion Web is a Node.js web research toolkit. It provides Exa, Brave, DeepSeek, +or a managed Kepos Bridge search endpoint, Context7 library documentation lookup, Sourcegraph public code search, page-link discovery, and two page-rendering modes through a CLI, stdio MCP server, personal HTTP service, Pi extension, and DeepSeek Harness (DSH) integration: HTTP @@ -21,17 +21,22 @@ npm install --global @guionai/web npx @guionai/web --help ``` -Search needs one provider credential for Exa or Brave. If both are present, Exa -is selected by default; select a provider explicitly with `--provider exa`, -`--provider brave`, or `--provider kepos-bridge`. Kepos Bridge uses the bundled -default route unless it runs in DSH, whose live settings card can override the -route. +Search needs one provider credential for Exa, Brave, or DeepSeek. If Exa and +Brave are both present, Exa is selected by default; DeepSeek is never selected +implicitly. Select a provider explicitly with `--provider exa`, +`--provider brave`, `--provider deepseek`, or `--provider kepos-bridge`. +DeepSeek makes one auxiliary model call using its native web-search tool and +returns the same normalized ranked URL/title/snippet results as the other +providers. Kepos Bridge uses the bundled default route unless it runs in DSH, +whose live settings card can override the route. Context7 works anonymously when its key is absent. -The HTTP service always requires a non-empty `EXA_API_KEY`: it tries the -server-local Kepos Bridge route first and retries Exa once when Bridge fails. -HTTP clients cannot select a provider, pass credentials, or override the Bridge -route per request. Set `KEPOS_BRIDGE_ENDPOINT` to replace the default route +The HTTP service always uses the Bridge-to-Exa policy by default and requires a +non-empty `EXA_API_KEY` for its retry. Set the server-local +`WEB_SEARCH_PROVIDER=deepseek` to select DeepSeek instead; this requires a +non-empty `DEEPSEEK_API_KEY` and does not fall back to Bridge or Exa when the +DeepSeek request fails. HTTP clients cannot select a provider, pass credentials, +or override the Bridge route per request. Set `KEPOS_BRIDGE_ENDPOINT` to replace the default route (`http://codex-bridge.localhost:17480/codex/web-search`); it must be a complete HTTP(S) URL without credentials, query, or fragment. @@ -39,10 +44,14 @@ HTTP(S) URL without credentials, query, or fragment. export EXA_API_KEY="..." # or export BRAVE_API_KEY="..." +# for explicit DeepSeek selection in CLI, MCP, Pi, or DSH +export DEEPSEEK_API_KEY="..." # optional, for authenticated Context7 requests export CONTEXT7_API_KEY="..." # optional complete Bridge route for `web serve` export KEPOS_BRIDGE_ENDPOINT="http://127.0.0.1:8787/codex/web-search" +# HTTP/Pi: select DeepSeek server-side (HTTP clients still send {"query":"..."}) +export WEB_SEARCH_PROVIDER="deepseek" ``` Do not put credentials in command arguments or commit them. The CLI reads these @@ -51,7 +60,9 @@ application configuration path. ## Personal HTTP service -Run the service with the server-local environment above: +Run the service with the server-local environment above. Leave +`WEB_SEARCH_PROVIDER` unset for Bridge-to-Exa; set it to `deepseek` for the +DeepSeek-only path: ```bash web serve --host 0.0.0.0 --port 8787 @@ -65,16 +76,19 @@ docker run --rm -p 8787:8787 \ Every HTTP operation is a versioned JSON `POST` route. Request and response schemas are generated into `openapi.yaml` from the same route definitions: -| Route | Request | Purpose | -| ------------ | --------------------------------------------------------- | -------------------------------------------------------- | -| `/v1/search` | `{ "query": "..." }` | Kepos Bridge search with one Exa retry on Bridge failure | -| `/v1/fetch` | `{ "url", "section_id?", "full?", "render?", "waitMs?" }` | Fetch Markdown | -| `/v1/links` | `{ "url", "limit?", "render?", "waitMs?" }` | List page HTTP(S) links | +| Route | Request | Purpose | +| ------------ | --------------------------------------------------------- | --------------------------------------------------------------- | +| `/v1/search` | `{ "query": "..." }` | Server-selected search: Bridge→Exa by default, or DeepSeek only | +| `/v1/fetch` | `{ "url", "section_id?", "full?", "render?", "waitMs?" }` | Fetch Markdown | +| `/v1/links` | `{ "url", "limit?", "render?", "waitMs?" }` | List page HTTP(S) links | The complete human-readable contract is in the [HTTP service reference](docs/http-service.md). Search keeps a successful empty Bridge result, retries Exa exactly once for a -non-cancellation Bridge failure, and reports the provider in its response. +non-cancellation Bridge failure when no server provider is selected, and +reports the provider in its response. DeepSeek selection is server-local and +has no automatic fallback. The request remains `{ "query": "..." }` in every +case. Weather, sports, finance, and time are not exposed because the configured providers do not offer contract-equivalent official typed data APIs. Invalid JSON bodies, unknown fields, and invalid typed values are rejected before an upstream call. @@ -104,6 +118,7 @@ document on stdout, which is useful for automation. ```bash web search --provider exa -- "Node AbortSignal" +web search --provider deepseek -- "Node AbortSignal" web search --provider kepos-bridge -- "Node AbortSignal" web fetch https://example.com/article web fetch https://example.com/article --section introduction @@ -129,6 +144,7 @@ Run the stdio server with the same credential environment: web mcp # Pin search selection for the lifetime of this MCP process: web mcp --provider brave +web mcp --provider deepseek web mcp --provider kepos-bridge ``` @@ -155,8 +171,10 @@ an integer `waitMs` when its host provides that optional executable. from the original page DOM. Set `WEB_SEARCH_PROVIDER=kepos-bridge` before starting Pi to select the -credential-free Kepos Bridge provider. Pi uses the bundled default Bridge route; -only DSH exposes a route setting. +credential-free Kepos Bridge provider, or `WEB_SEARCH_PROVIDER=deepseek` with +`DEEPSEEK_API_KEY` for explicit DeepSeek search. Pi uses the bundled default +Bridge route; only DSH exposes a route setting. DeepSeek is never selected by +the presence of its key alone. ## DSH @@ -167,10 +185,11 @@ dsh plugin --profile web add @guionai/dsh-web ``` The included profile patch routes stock PTC web search through the selected Exa, -Brave, or Kepos Bridge provider. Its settings UI stores provider selection and +Brave, DeepSeek, or Kepos Bridge provider. Its settings UI stores provider selection and the complete non-secret Kepos Bridge route (default `http://codex-bridge.localhost:17480/codex/web-search`) and manages namespaced write-only -credentials. Selecting Kepos Bridge additionally exposes `web_weather`, +credentials, including a write-only DeepSeek API key. DeepSeek uses the same +provider picker/key workflow and exposes no DeepSeek endpoint field. Selecting Kepos Bridge additionally exposes `web_weather`, `web_sports`, `web_finance`, and `web_time`; these tools are removed when another provider is selected. Fetch, link discovery, documentation, and Sourcegraph tools also run in-process. The host DSH packages and React are peers supplied by DSH. diff --git a/docs/adr/0001-containerized-rest-api.md b/docs/adr/0001-containerized-rest-api.md index f23e4e8..01f7480 100644 --- a/docs/adr/0001-containerized-rest-api.md +++ b/docs/adr/0001-containerized-rest-api.md @@ -4,4 +4,4 @@ Guion Web will add a self-hosted, single-user HTTP service in a portable contain ## Consequences -The service's provider credentials and Bridge Route are server-local configuration; clients cannot choose a provider or supply a Bridge Route per request. Search tries Kepos Bridge and uses Exa only when the Bridge is operationally unavailable; it does not fall back for cancellation, malformed client input, or an empty result set. A failed Bridge attempt is retried through Exa exactly once, and a successful empty Bridge response is returned unchanged. Typed Bridge operations are intentionally not exposed: Exa has no equivalent official weather, sports, or time API, and its premium finance integration is not contract-compatible. Fetch and Links use `render: "http"` by default or explicit `render: "browser"` with a required `waitMs`; the operator-installed executable remains an implementation detail. The shared page-reading module owns automatic navigation trees, complete `full` extraction, and `section_id` continuation. `openapi.yaml` is generated from the release build rather than manually maintained or independently versioned; the standalone [HTTP service reference](../http-service.md) is its human-readable companion. Public or multi-tenant deployment hardening is deliberately deferred in `.scratch/defered/public-http-service-security.md`. +The service's provider credentials, Bridge Route, and optional `WEB_SEARCH_PROVIDER=deepseek` selection are server-local configuration; clients cannot choose a provider or supply a Bridge Route per request. With no provider selection, search tries Kepos Bridge and uses Exa only when the Bridge is operationally unavailable; it does not fall back for cancellation, malformed client input, or an empty result set. A failed Bridge attempt is retried through Exa exactly once, and a successful empty Bridge response is returned unchanged. When DeepSeek is selected, the service calls only DeepSeek and never falls back. DeepSeek performs one auxiliary model call internally and returns the same normalized result contract; its Messages/tool protocol is not exposed to HTTP callers. Typed Bridge operations are intentionally not exposed: Exa has no equivalent official weather, sports, or time API, and its premium finance integration is not contract-compatible. Fetch and Links use `render: "http"` by default or explicit `render: "browser"` with a required `waitMs`; the operator-installed executable remains an implementation detail. The shared page-reading module owns automatic navigation trees, complete `full` extraction, and `section_id` continuation. `openapi.yaml` is generated from the release build rather than manually maintained or independently versioned; the standalone [HTTP service reference](../http-service.md) is its human-readable companion. Public or multi-tenant deployment hardening is deliberately deferred in `.scratch/defered/public-http-service-security.md`. diff --git a/docs/http-service.md b/docs/http-service.md index 0eb78de..4735d66 100644 --- a/docs/http-service.md +++ b/docs/http-service.md @@ -21,10 +21,12 @@ Request: `query` is a non-empty string. The service chooses providers server-side; a caller cannot select a provider, supply credentials, or override the Bridge -route. Search tries the server-local Kepos Bridge first. A successful empty -Bridge result is returned as-is. If Bridge fails for a non-cancellation reason, -the service retries Exa once. The service requires a non-empty `EXA_API_KEY` -at startup because Exa is the fallback provider. +route. With no `WEB_SEARCH_PROVIDER`, search tries the server-local Kepos +Bridge first. A successful empty Bridge result is returned as-is. If Bridge +fails for a non-cancellation reason, the service retries Exa once and requires +a non-empty `EXA_API_KEY` at startup. Set the server-local +`WEB_SEARCH_PROVIDER=deepseek` to require `DEEPSEEK_API_KEY` and call DeepSeek +only; that path has no Bridge or Exa fallback. Response `200`: @@ -42,7 +44,7 @@ Response `200`: } ``` -`provider` is `"Kepos Bridge"` or `"Exa"`; each result has string `title`, +`provider` is `"Kepos Bridge"`, `"Exa"`, or `"DeepSeek"`; each result has string `title`, `link`, and `snippet` fields plus an integer `position`. ### `POST /v1/fetch` @@ -147,8 +149,11 @@ response bodies. ## Configuration and OpenAPI -Credentials and the optional `KEPOS_BRIDGE_ENDPOINT` are server-local -environment variables. They are not accepted in request bodies. The route +Credentials, the optional `KEPOS_BRIDGE_ENDPOINT`, and the optional +server-local `WEB_SEARCH_PROVIDER=deepseek` selection are environment +variables. They are not accepted in request bodies. DeepSeek performs one +auxiliary model call per search; callers receive only normalized results and do +not need to know the Messages/tool wire protocol. The route schemas in [`packages/web/src/http.ts`](../packages/web/src/http.ts) are the machine-readable source of truth. The build generates a version-matched OpenAPI 3.1 artifact at `packages/web/dist/openapi.yaml`; releases attach that diff --git a/packages/dsh-web/README.md b/packages/dsh-web/README.md index e870ee1..95c97c2 100644 --- a/packages/dsh-web/README.md +++ b/packages/dsh-web/README.md @@ -13,12 +13,16 @@ PTC preset. It leaves the root `tool-web` row disabled and routes stock PTC's batched `web_search` through the Guion provider seam. Provider selection is explicit and persists in the `guionai-web` settings -namespace. Exa and Brave API keys use namespaced write-only DSH credentials; +namespace. Exa, Brave, and DeepSeek API keys use namespaced write-only DSH credentials; settings expose only configured/source/writable metadata. The credential-free `kepos-bridge` provider uses the complete non-secret route configured in the card (default `http://codex-bridge.localhost:17480/codex/web-search`). While it is selected, the package registers `web_weather`, `web_sports`, `web_finance`, and `web_time`; -switching to Exa or Brave removes those four schemas. +switching to Exa, Brave, or DeepSeek removes those four schemas. DeepSeek uses +the same provider picker and write-only `DEEPSEEK_API_KEY` credential workflow +as the other hosted providers, makes one auxiliary model call per search, and +returns normalized ranked sources. It has no endpoint input; the endpoint +field in this card is for Kepos Bridge only. The route is a complete absolute `http:` or `https:` URL; credentials, query strings, and fragments are rejected and its path is used exactly as entered. diff --git a/packages/dsh-web/package.json b/packages/dsh-web/package.json index 34d4c71..1703c70 100644 --- a/packages/dsh-web/package.json +++ b/packages/dsh-web/package.json @@ -1,7 +1,7 @@ { "name": "@guionai/dsh-web", "version": "0.1.0", - "description": "DeepSeek Harness 0.1.2-alpha.3 provider for Exa, Brave, and Kepos Bridge web research with HTTP and optional browser page-rendering modes.", + "description": "DeepSeek Harness 0.1.2-alpha.3 provider for Exa, Brave, DeepSeek, and Kepos Bridge web research with HTTP and optional browser page-rendering modes.", "homepage": "https://github.com/guionai/web#readme", "bugs": { "url": "https://github.com/guionai/web/issues" diff --git a/packages/dsh-web/src/client.ts b/packages/dsh-web/src/client.ts index d39d111..80a91dd 100644 --- a/packages/dsh-web/src/client.ts +++ b/packages/dsh-web/src/client.ts @@ -16,6 +16,7 @@ import { BRAVE_CREDENTIAL_REF, CREDENTIAL_REFS, DEFAULT_KEPOS_BRIDGE_ENDPOINT, + DEEPSEEK_CREDENTIAL_REF, EXA_CREDENTIAL_REF, PROVIDER_LABELS, PROVIDERS, @@ -641,7 +642,9 @@ function SettingsCard({ ? "Exa API key" : ref === BRAVE_CREDENTIAL_REF ? "Brave API key" - : "Context7 API key"; + : ref === DEEPSEEK_CREDENTIAL_REF + ? "DeepSeek API key" + : "Context7 API key"; const current = status[ref] ?? { configured: false, writable: false, diff --git a/packages/dsh-web/src/contract.ts b/packages/dsh-web/src/contract.ts index f40306f..2eeb147 100644 --- a/packages/dsh-web/src/contract.ts +++ b/packages/dsh-web/src/contract.ts @@ -7,23 +7,27 @@ export { validateKeposBridgeEndpoint, } from "@guionai/web-core/kepos-bridge"; -export const PROVIDERS = ["exa", "brave", "kepos-bridge"] as const; +export const PROVIDERS = ["exa", "brave", "deepseek", "kepos-bridge"] as const; export type SearchProviderName = (typeof PROVIDERS)[number]; export const PROVIDER_LABELS: Record = { exa: "Exa", brave: "Brave", + deepseek: "DeepSeek", "kepos-bridge": "Kepos Bridge", }; /** DSH-owned references; these names never identify a value in settings. */ export const EXA_CREDENTIAL_REF = "GUIONAI_DSH_WEB_EXA_API_KEY" as const; export const BRAVE_CREDENTIAL_REF = "GUIONAI_DSH_WEB_BRAVE_API_KEY" as const; +export const DEEPSEEK_CREDENTIAL_REF = + "GUIONAI_DSH_WEB_DEEPSEEK_API_KEY" as const; export const CONTEXT7_CREDENTIAL_REF = "GUIONAI_DSH_WEB_CONTEXT7_API_KEY" as const; export const CREDENTIAL_REFS = [ EXA_CREDENTIAL_REF, BRAVE_CREDENTIAL_REF, + DEEPSEEK_CREDENTIAL_REF, CONTEXT7_CREDENTIAL_REF, ] as const; diff --git a/packages/dsh-web/src/provider.ts b/packages/dsh-web/src/provider.ts index dd121d5..4c9adae 100644 --- a/packages/dsh-web/src/provider.ts +++ b/packages/dsh-web/src/provider.ts @@ -13,6 +13,7 @@ import type { WebSearchProvider, WebSearchResult } from "@deepseek-ai/dsh-web"; import { BRAVE_CREDENTIAL_REF, + DEEPSEEK_CREDENTIAL_REF, EXA_CREDENTIAL_REF, SEARCH_PROVIDER_ID, type SearchProviderName, @@ -39,6 +40,11 @@ function credentialFor(provider: SearchProviderName): return { ref: credentialRef(EXA_CREDENTIAL_REF), field: "exaApiKey" }; case "brave": return { ref: credentialRef(BRAVE_CREDENTIAL_REF), field: "braveApiKey" }; + case "deepseek": + return { + ref: credentialRef(DEEPSEEK_CREDENTIAL_REF), + field: "deepseekApiKey", + }; case "kepos-bridge": return undefined; } diff --git a/packages/dsh-web/test/client-render.test.ts b/packages/dsh-web/test/client-render.test.ts index 0dae92d..368bcc3 100644 --- a/packages/dsh-web/test/client-render.test.ts +++ b/packages/dsh-web/test/client-render.test.ts @@ -70,6 +70,8 @@ describe("DSH settings client rendered contract", () => { expect(html).toContain(`id="${describedBy}"`); expect(html).toContain('value="kepos-bridge"'); expect(html).toContain('value="https://bridge.example.test/route"'); + expect(html).toContain("DeepSeek API key"); + expect(html).not.toContain("DeepSeek endpoint"); } finally { if (previousDocument === undefined) delete (globalThis as any).document; else (globalThis as any).document = previousDocument; diff --git a/packages/dsh-web/test/client.test.ts b/packages/dsh-web/test/client.test.ts index dc76969..b7d3cf2 100644 --- a/packages/dsh-web/test/client.test.ts +++ b/packages/dsh-web/test/client.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { BRAVE_CREDENTIAL_REF, CONTEXT7_CREDENTIAL_REF, + DEEPSEEK_CREDENTIAL_REF, EXA_CREDENTIAL_REF, SETTINGS_NAMESPACE, } from "../src/contract.js"; @@ -187,6 +188,7 @@ describe("DSH settings client credential surface", () => { const status = await describeCredentialStatus(fixture.credentials, [ EXA_CREDENTIAL_REF, BRAVE_CREDENTIAL_REF, + DEEPSEEK_CREDENTIAL_REF, CONTEXT7_CREDENTIAL_REF, ]); expect(status).toEqual({ @@ -200,6 +202,11 @@ describe("DSH settings client credential surface", () => { source: "file", writable: true, }, + [DEEPSEEK_CREDENTIAL_REF]: { + configured: false, + source: "file", + writable: true, + }, [CONTEXT7_CREDENTIAL_REF]: { configured: false, source: "file", diff --git a/packages/dsh-web/test/package.test.ts b/packages/dsh-web/test/package.test.ts index 8d2d7bc..8506384 100644 --- a/packages/dsh-web/test/package.test.ts +++ b/packages/dsh-web/test/package.test.ts @@ -30,6 +30,12 @@ describe("DSH Web package composition", () => { provider: "exa", keposBridgeEndpoint: "https://bridge.example.test/route", }); + expect( + SettingsSchema({ + provider: "deepseek", + keposBridgeEndpoint: DEFAULT_KEPOS_BRIDGE_ENDPOINT, + }), + ).toMatchObject({ provider: "deepseek" }); expect(() => SettingsSchema({ provider: "kepos-bridge", diff --git a/packages/dsh-web/test/provider.test.ts b/packages/dsh-web/test/provider.test.ts index 39f303b..ece317b 100644 --- a/packages/dsh-web/test/provider.test.ts +++ b/packages/dsh-web/test/provider.test.ts @@ -3,6 +3,7 @@ import { createWebOperations, type WebOperations } from "@guionai/web-core"; import { BRAVE_CREDENTIAL_REF, + DEEPSEEK_CREDENTIAL_REF, EXA_CREDENTIAL_REF, SEARCH_PROVIDER_ID, } from "../src/contract.js"; @@ -13,6 +14,37 @@ function withOperations(overrides: Partial): WebOperations { } describe("Guion DSH search provider", () => { + it("resolves only the namespaced DeepSeek key and forwards explicit selection", async () => { + let received: unknown; + const provider = createGuionSearchProvider({ + getProvider: () => "deepseek", + getKeposBridgeEndpoint: () => "http://fixture.test/route", + credentials: { + resolve: async (ref) => { + expect(ref).toBe(DEEPSEEK_CREDENTIAL_REF); + return { value: "deepseek-secret", source: "file" }; + }, + }, + operations: withOperations({ + search: async (input) => { + received = input; + return { provider: "DeepSeek", results: [] }; + }, + }), + }); + + await expect(provider.search({ query: "latest" })).resolves.toEqual({ + sources: [], + truncated: false, + }); + expect(received).toEqual({ + query: "latest", + provider: "deepseek", + credentials: { deepseekApiKey: "deepseek-secret" }, + signal: undefined, + }); + }); + it("routes each stock PTC query through the live selected provider and resolved credential", async () => { let received: unknown; const provider = createGuionSearchProvider({ diff --git a/packages/pi-web/src/tool.ts b/packages/pi-web/src/tool.ts index 3e25e00..4e29082 100644 --- a/packages/pi-web/src/tool.ts +++ b/packages/pi-web/src/tool.ts @@ -227,6 +227,7 @@ function environmentCredentials(): WebCredentials { return { exaApiKey: process.env.EXA_API_KEY, braveApiKey: process.env.BRAVE_API_KEY, + deepseekApiKey: process.env.DEEPSEEK_API_KEY, ...(Object.hasOwn(process.env, "CONTEXT7_API_KEY") ? { context7ApiKey: process.env.CONTEXT7_API_KEY } : {}), diff --git a/packages/pi-web/test/extension.test.ts b/packages/pi-web/test/extension.test.ts index b12c332..99add5f 100644 --- a/packages/pi-web/test/extension.test.ts +++ b/packages/pi-web/test/extension.test.ts @@ -379,6 +379,37 @@ describe("pi-web extension", () => { ); }); + it("forwards explicit DeepSeek selection for every batched query", async () => { + const search = vi.fn(async () => ({ + provider: "DeepSeek" as const, + results: [], + })); + const tool = webSearchTool({ + operations: operations({ search }), + provider: "deepseek", + credentials: () => ({ deepseekApiKey: "deepseek-secret" }), + }); + + await call(tool, { queries: ["one", "two"] }); + + expect(search).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + provider: "deepseek", + query: "one", + credentials: { deepseekApiKey: "deepseek-secret" }, + }), + ); + expect(search).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + provider: "deepseek", + query: "two", + credentials: { deepseekApiKey: "deepseek-secret" }, + }), + ); + }); + it("delegates every capability in-process and propagates caller cancellation", async () => { const abortable = (signal: AbortSignal | undefined) => new Promise((_resolve, reject) => { diff --git a/packages/web-core/src/index.ts b/packages/web-core/src/index.ts index 0da03b6..c3de6d8 100644 --- a/packages/web-core/src/index.ts +++ b/packages/web-core/src/index.ts @@ -105,15 +105,24 @@ export { const EXA_BASE_URL = "https://api.exa.ai"; const BRAVE_BASE_URL = "https://api.search.brave.com/res/v1"; +/** DeepSeek's Anthropic-compatible API root. The provider appends /messages. */ +const DEEPSEEK_DEFAULT_BASE_URL = "https://api.deepseek.com/anthropic/v1"; +const DEEPSEEK_DEFAULT_MODEL = "deepseek-v4-flash"; +const DEEPSEEK_DEFAULT_API_VERSION = "2023-06-01"; +const DEEPSEEK_DEFAULT_MAX_TOKENS = 4096; +const DEEPSEEK_DEFAULT_MAX_USES = 5; +const DEEPSEEK_SEARCH_TOOL_TYPE = "web_search_20250305"; +const DEEPSEEK_SEARCH_TOOL_NAME = "web_search"; const DEFAULT_TIMEOUT_MS = 30_000; const MAX_RESULTS = 10; -export type SearchProvider = "exa" | "brave" | "kepos-bridge"; -export type ProviderLabel = "Exa" | "Brave" | "Kepos Bridge"; +export type SearchProvider = "exa" | "brave" | "deepseek" | "kepos-bridge"; +export type ProviderLabel = "Exa" | "Brave" | "DeepSeek" | "Kepos Bridge"; export type SearchCredentials = { exaApiKey?: string; braveApiKey?: string; + deepseekApiKey?: string; }; export type WebCredentials = SearchCredentials & Context7Credentials; @@ -182,7 +191,9 @@ export async function search(input: SearchInput): Promise { ? searchExa(input) : provider === "brave" ? searchBrave(input) - : searchKeposBridge(input)); + : provider === "deepseek" + ? searchDeepSeek(input) + : searchKeposBridge(input)); throwIfAborted(input.signal); return result; } catch (error) { @@ -203,6 +214,7 @@ export function selectProvider( explicitProvider !== undefined && explicitProvider !== "exa" && explicitProvider !== "brave" && + explicitProvider !== "deepseek" && explicitProvider !== "kepos-bridge" ) { throw new Error( @@ -229,6 +241,17 @@ export function selectProvider( } return "brave"; } + if (explicitProvider === "deepseek") { + if ( + credentials.deepseekApiKey === undefined || + credentials.deepseekApiKey === "" + ) { + throw new Error( + "DEEPSEEK_API_KEY is required when --provider deepseek is selected", + ); + } + return "deepseek"; + } if (explicitProvider === "kepos-bridge") return "kepos-bridge"; if (credentials.exaApiKey === "") { @@ -251,7 +274,9 @@ function providerLabel(provider: SearchProvider): ProviderLabel { ? "Exa" : provider === "brave" ? "Brave" - : "Kepos Bridge"; + : provider === "deepseek" + ? "DeepSeek" + : "Kepos Bridge"; } export function formatSearchResults(results: SearchResult[]): string { @@ -346,6 +371,123 @@ async function searchBrave(input: SearchInput): Promise { }; } +/** + * Performs one DeepSeek web-search tool call through its Anthropic-compatible + * Messages endpoint. The request shape is intentionally fixed: DeepSeek's + * server-side web-search tool chooses and ranks the sources, while this + * adapter only normalizes the structured result blocks and their citations. + */ +async function searchDeepSeek(input: SearchInput): Promise { + const base = (input.endpoints?.deepseek ?? DEEPSEEK_DEFAULT_BASE_URL).replace( + /\/$/, + "", + ); + const data = await providerRequest( + input, + `${base}/messages`, + { + method: "POST", + redirect: "error", + headers: { + "x-api-key": input.credentials.deepseekApiKey!, + authorization: `Bearer ${input.credentials.deepseekApiKey!}`, + "anthropic-version": DEEPSEEK_DEFAULT_API_VERSION, + "content-type": "application/json", + accept: "application/json", + "user-agent": "deepseek-harness/0.0.1", + }, + body: JSON.stringify({ + model: DEEPSEEK_DEFAULT_MODEL, + max_tokens: DEEPSEEK_DEFAULT_MAX_TOKENS, + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: `Perform a web search for the query: ${input.query}`, + }, + ], + }, + ], + tools: [ + { + type: DEEPSEEK_SEARCH_TOOL_TYPE, + name: DEEPSEEK_SEARCH_TOOL_NAME, + max_uses: DEEPSEEK_DEFAULT_MAX_USES, + }, + ], + }), + }, + "DeepSeek", + ); + return mapDeepSeekResponse(data, input.maxResults); +} + +/** Maps DeepSeek's structured web-search blocks into the provider-neutral API. */ +export function mapDeepSeekResponse( + value: unknown, + maxResults?: number, +): SearchResponse { + const response = asRecord(value, "DeepSeek"); + const blocks = response.content === undefined ? [] : response.content; + if (!Array.isArray(blocks)) + throw new Error("deepseek search: malformed response"); + + const resultBlocks = blocks.filter( + (block) => isRecord(block) && block.type === "web_search_tool_result", + ); + if (resultBlocks.length === 0) + throw new Error( + "deepseek search: response contained no web_search_tool_result blocks", + ); + + const citationByUrl = new Map(); + for (const block of blocks) { + if (!isRecord(block) || block.type !== "text") continue; + const citations = block.citations; + if (!Array.isArray(citations)) continue; + for (const citation of citations) { + if (!isRecord(citation)) continue; + const url = citation.url; + const citedText = citation.cited_text; + if ( + typeof url === "string" && + url.length > 0 && + typeof citedText === "string" && + citedText.length > 0 && + !citationByUrl.has(url) + ) { + citationByUrl.set(url, citedText); + } + } + } + + const seenUrls = new Set(); + const results: SearchResult[] = []; + for (const block of resultBlocks) { + const items = (block as Record).content; + if (!Array.isArray(items)) continue; + for (const item of items) { + if (!isRecord(item) || item.type !== "web_search_result") continue; + const url = item.url; + if (typeof url !== "string" || url.length === 0 || seenUrls.has(url)) + continue; + seenUrls.add(url); + results.push({ + title: stringValue(item.title), + link: url, + snippet: citationByUrl.get(url) ?? "", + position: results.length + 1, + }); + } + } + + const limit = + maxResults === undefined ? MAX_RESULTS : normalizeMaxResults(maxResults); + return { provider: "DeepSeek", results: results.slice(0, limit) }; +} + async function searchKeposBridge(input: SearchInput): Promise { const endpoint = input.keposBridgeEndpoint ?? @@ -472,3 +614,7 @@ function asArray( function stringValue(value: unknown): string { return typeof value === "string" ? value : ""; } + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/web-core/test/search.test.ts b/packages/web-core/test/search.test.ts index 8b7b266..655d7be 100644 --- a/packages/web-core/test/search.test.ts +++ b/packages/web-core/test/search.test.ts @@ -7,6 +7,15 @@ import { selectProvider, } from "../src/index.js"; +const deepseekProtocol = { + apiVersion: "2023-06-01", + model: "deepseek-v4-flash", + maxTokens: 4096, + maxUses: 5, + toolType: "web_search_20250305", + toolName: "web_search", +} as const; + const exaFixture = { results: [ { @@ -106,6 +115,178 @@ describe("search providers migrated from Organon fixtures", () => { }); }); + it("sends the fixed Anthropic-compatible DeepSeek request and maps citations", async () => { + let url = ""; + let headers: Headers | undefined; + let body: unknown; + const result = await search({ + query: "deep sea robots", + provider: "deepseek", + credentials: { deepseekApiKey: "test-deepseek-key" }, + endpoints: { deepseek: "http://fixture.test/anthropic/v1" }, + fetch: async (receivedURL, init) => { + url = String(receivedURL); + headers = new Headers(init?.headers); + body = JSON.parse(String(init?.body)); + return response({ + id: "message_fixture", + content: [ + { + type: "web_search_tool_result", + content: [ + { + type: "web_search_result", + title: "One", + url: "https://example.test/one", + }, + { + type: "web_search_result", + title: "Duplicate", + url: "https://example.test/one", + }, + { + type: "web_search_result", + title: "Two", + url: "https://example.test/two", + }, + ], + }, + { + type: "text", + text: "Sources", + citations: [ + { + type: "web_search_result_location", + url: "https://example.test/one", + cited_text: "first excerpt", + }, + { + url: "https://example.test/one", + cited_text: "ignored duplicate excerpt", + }, + { + url: "https://example.test/two", + cited_text: "second excerpt", + }, + ], + }, + ], + }); + }, + }); + + expect(url).toBe("http://fixture.test/anthropic/v1/messages"); + expect(headers?.get("x-api-key")).toBe("test-deepseek-key"); + expect(headers?.get("authorization")).toBe("Bearer test-deepseek-key"); + expect(headers?.get("anthropic-version")).toBe(deepseekProtocol.apiVersion); + expect(body).toEqual({ + model: deepseekProtocol.model, + max_tokens: deepseekProtocol.maxTokens, + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: "Perform a web search for the query: deep sea robots", + }, + ], + }, + ], + tools: [ + { + type: deepseekProtocol.toolType, + name: deepseekProtocol.toolName, + max_uses: deepseekProtocol.maxUses, + }, + ], + }); + expect(result).toEqual({ + provider: "DeepSeek", + results: [ + { + title: "One", + link: "https://example.test/one", + snippet: "first excerpt", + position: 1, + }, + { + title: "Two", + link: "https://example.test/two", + snippet: "second excerpt", + position: 2, + }, + ], + }); + }); + + it("requires a structured DeepSeek result block and never parses prose", async () => { + await expect( + search({ + query: "missing sources", + provider: "deepseek", + credentials: { deepseekApiKey: "key" }, + fetch: async () => + response({ + content: [ + { + type: "text", + text: "https://example.test/prose should not become a result", + }, + ], + }), + }), + ).rejects.toThrow(/DeepSeek provider/); + }); + + it("keeps DeepSeek credentials out of status errors", async () => { + const secret = "deepseek-secret"; + await expect( + search({ + query: "secret-safe", + provider: "deepseek", + credentials: { deepseekApiKey: secret }, + fetch: async () => + new Response(`${secret} remote diagnostic`, { status: 401 }), + }), + ).rejects.toThrow("deepseek search: HTTP 401"); + await expect( + search({ + query: "secret-safe", + provider: "deepseek", + credentials: { deepseekApiKey: secret }, + fetch: async () => + new Response(`${secret} remote diagnostic`, { status: 401 }), + }), + ).rejects.not.toThrow(secret); + }); + + it("propagates caller cancellation without a provider fallback", async () => { + const controller = new AbortController(); + let started!: () => void; + const startedPromise = new Promise((resolve) => { + started = resolve; + }); + const pending = search({ + query: "cancelled", + provider: "deepseek", + credentials: { deepseekApiKey: "key" }, + signal: controller.signal, + fetch: async (_url, init) => + new Promise((_resolve, reject) => { + started(); + init?.signal?.addEventListener( + "abort", + () => reject(new DOMException("aborted", "AbortError")), + { once: true }, + ); + }), + }); + await startedPromise; + controller.abort(); + await expect(pending).rejects.toThrow("Operation aborted"); + }); + it("preserves explicit provider, fallback, and empty-key behavior", () => { expect( selectProvider(undefined, { exaApiKey: "exa", braveApiKey: "brave" }), @@ -127,6 +308,15 @@ describe("search providers migrated from Organon fixtures", () => { "web search requires EXA_API_KEY or BRAVE_API_KEY", ); expect(selectProvider("kepos-bridge", {})).toBe("kepos-bridge"); + expect(selectProvider("deepseek", { deepseekApiKey: "deepseek" })).toBe( + "deepseek", + ); + expect(() => selectProvider("deepseek", {})).toThrow( + "DEEPSEEK_API_KEY is required when --provider deepseek is selected", + ); + expect(() => + selectProvider(undefined, { deepseekApiKey: "deepseek" }), + ).toThrow("web search requires EXA_API_KEY or BRAVE_API_KEY"); }); it("sends one Kepos query, maps only usable text results, and honors maxResults", async () => { diff --git a/packages/web/src/http.ts b/packages/web/src/http.ts index 422a357..462aff7 100644 --- a/packages/web/src/http.ts +++ b/packages/web/src/http.ts @@ -33,6 +33,8 @@ export type HttpServiceState = { operations: WebOperations; credentials: WebCredentials; keposBridgeEndpoint: string; + /** Server-local override; undefined keeps the Bridge-to-Exa default. */ + searchProvider?: "deepseek"; }; export type HttpError = { @@ -62,7 +64,7 @@ const SearchResultSchema = z const SearchResponseSchema = z .object({ - provider: z.enum(["Exa", "Kepos Bridge"]), + provider: z.enum(["Exa", "DeepSeek", "Kepos Bridge"]), results: z.array(SearchResultSchema), }) .strict() @@ -163,7 +165,7 @@ const searchRoute = createRoute({ operationId: "search", summary: "Search the web", description: - "Search through the server-local Kepos Bridge, retrying once through Exa when Bridge is unavailable.", + "Search through the server-local Kepos Bridge with one Exa retry by default, or select DeepSeek with WEB_SEARCH_PROVIDER=deepseek.", request: jsonRequest(SearchRequestSchema), responses: { 200: jsonResponse(SearchResponseSchema, "Search results."), @@ -272,6 +274,7 @@ export function createHttpOpenAPIDocument(version = "0.1.0") { const app = createHttpApp({ credentials: { exaApiKey: "build-placeholder" }, keposBridgeEndpoint: DEFAULT_KEPOS_BRIDGE_ENDPOINT, + environment: {}, validateStartup: false, }); return app.getOpenAPI31Document({ @@ -290,8 +293,25 @@ export function resolveHttpServiceState( ): HttpServiceState { const environment = dependencies.environment ?? process.env; const credentials = resolveCredentials(dependencies.credentials, environment); + const configuredProvider = environment.WEB_SEARCH_PROVIDER; + if (configuredProvider !== undefined && configuredProvider !== "deepseek") { + throw new Error( + `unsupported HTTP search provider ${JSON.stringify(configuredProvider)}; only deepseek is supported`, + ); + } + const searchProvider = + configuredProvider === "deepseek" ? ("deepseek" as const) : undefined; if (dependencies.validateStartup !== false) { - if ( + if (searchProvider === "deepseek") { + if ( + typeof credentials.deepseekApiKey !== "string" || + credentials.deepseekApiKey.trim().length === 0 + ) { + throw new Error( + "DEEPSEEK_API_KEY is required and must be non-empty when WEB_SEARCH_PROVIDER=deepseek", + ); + } + } else if ( typeof credentials.exaApiKey !== "string" || credentials.exaApiKey.trim().length === 0 ) { @@ -308,6 +328,7 @@ export function resolveHttpServiceState( operations: dependencies.operations ?? webCoreModule.createWebOperations(), credentials, keposBridgeEndpoint: validateKeposBridgeEndpoint(endpoint), + ...(searchProvider === undefined ? {} : { searchProvider }), }; } @@ -371,6 +392,16 @@ async function searchWithFallback( signal: AbortSignal, ): Promise { throwIfAborted(signal); + if (state.searchProvider === "deepseek") { + const result = await state.operations.search({ + query, + provider: "deepseek", + credentials: state.credentials, + signal, + }); + throwIfAborted(signal); + return parseResponse(SearchResponseSchema, result); + } try { const result = await state.operations.search({ query, diff --git a/packages/web/src/mcp.ts b/packages/web/src/mcp.ts index e3e0f08..23f5526 100644 --- a/packages/web/src/mcp.ts +++ b/packages/web/src/mcp.ts @@ -189,7 +189,10 @@ const sgraphInputSchema = schema({ const searchOutputSchema = schema({ type: "object", properties: { - provider: { type: "string", enum: ["Exa", "Brave", "Kepos Bridge"] }, + provider: { + type: "string", + enum: ["Exa", "Brave", "DeepSeek", "Kepos Bridge"], + }, results: { type: "array", items: { @@ -426,7 +429,7 @@ export function createMcpCommand( .description("Serve typed web tools over stdio MCP") .option( "--provider ", - "Search provider: exa, brave, or kepos-bridge", + "Search provider: exa, brave, deepseek, or kepos-bridge", ) .action((options: { provider?: string }) => { const provider = options.provider; @@ -527,6 +530,7 @@ function redactError(error: unknown, credentials: WebCredentials): string { for (const secret of [ credentials.exaApiKey, credentials.braveApiKey, + credentials.deepseekApiKey, credentials.context7ApiKey, ]) { if (secret) message = message.replaceAll(secret, "[redacted]"); diff --git a/packages/web/src/program.ts b/packages/web/src/program.ts index f72773b..6007c88 100644 --- a/packages/web/src/program.ts +++ b/packages/web/src/program.ts @@ -74,7 +74,7 @@ function createSearchCommand(dependencies: ProgramDependencies): Command { .option("--json", "Output the structured result as JSON") .option( "--provider ", - "Search provider: exa, brave, or kepos-bridge", + "Search provider: exa, brave, deepseek, or kepos-bridge", ) .action( async (query: string, options: { json?: boolean; provider?: string }) => { diff --git a/packages/web/src/runtime.ts b/packages/web/src/runtime.ts index e11ccc5..587ca8b 100644 --- a/packages/web/src/runtime.ts +++ b/packages/web/src/runtime.ts @@ -8,6 +8,7 @@ export function credentialsFromEnvironment( return { exaApiKey: environment.EXA_API_KEY, braveApiKey: environment.BRAVE_API_KEY, + deepseekApiKey: environment.DEEPSEEK_API_KEY, ...(Object.hasOwn(environment, "CONTEXT7_API_KEY") ? { context7ApiKey: environment.CONTEXT7_API_KEY } : {}), diff --git a/packages/web/test/http.test.ts b/packages/web/test/http.test.ts index f0ca31a..e442bc6 100644 --- a/packages/web/test/http.test.ts +++ b/packages/web/test/http.test.ts @@ -140,6 +140,72 @@ describe("personal HTTP service", () => { }); }); + it("selects DeepSeek from server-local configuration without fallback", async () => { + const ops = operations({ + search: vi.fn(async () => ({ + provider: "DeepSeek" as const, + results: [], + })), + }); + const app = createHttpApp({ + ...dependencies(), + operations: ops, + credentials: { deepseekApiKey: "deepseek-secret" }, + environment: { WEB_SEARCH_PROVIDER: "deepseek" }, + }); + + const result = await json(app, "/v1/search", { query: "selected" }); + + expect(result.response.status).toBe(200); + expect(result.body).toEqual({ provider: "DeepSeek", results: [] }); + expect(ops.search).toHaveBeenCalledTimes(1); + expect(ops.search).toHaveBeenCalledWith({ + query: "selected", + provider: "deepseek", + credentials: { deepseekApiKey: "deepseek-secret" }, + signal: expect.any(AbortSignal), + }); + }); + + it("returns a DeepSeek failure without trying another provider", async () => { + const ops = operations({ + search: vi.fn(async () => { + throw new Error("deepseek unavailable"); + }), + }); + const app = createHttpApp({ + ...dependencies(), + operations: ops, + credentials: { deepseekApiKey: "deepseek-secret" }, + environment: { WEB_SEARCH_PROVIDER: "deepseek" }, + }); + + const result = await json(app, "/v1/search", { query: "failure" }); + + expect(result.response.status).toBe(502); + expect(result.body).toEqual({ + code: "upstream_error", + message: "search failed", + }); + expect(ops.search).toHaveBeenCalledTimes(1); + }); + + it("does not start HTTP DeepSeek mode without its server-local key", () => { + expect(() => + createHttpApp({ + credentials: { exaApiKey: "exa-secret" }, + environment: { WEB_SEARCH_PROVIDER: "deepseek" }, + }), + ).toThrow("DEEPSEEK_API_KEY"); + }); + + it("does not expose an HTTP request provider field", () => { + const document = createHttpOpenAPIDocument(); + const schema = (document.components?.schemas as any).SearchRequest; + expect(schema.properties.provider).toBeUndefined(); + expect(schema.additionalProperties).toBe(false); + }); + it("forwards fetch with its explicit rendered-fetch contract", async () => { const ops = operations({ fetch: vi.fn(async (input) => ({ @@ -289,7 +355,7 @@ describe("personal HTTP service", () => { expect( (document.components?.schemas as any).SearchResponse.properties.provider .enum, - ).toEqual(["Exa", "Kepos Bridge"]); + ).toEqual(["Exa", "DeepSeek", "Kepos Bridge"]); const fetchRequest = (document.components?.schemas as any).FetchRequest; expect(fetchRequest.properties.render.enum).toEqual(["http", "browser"]); expect(fetchRequest.properties.tree).toBeUndefined(); diff --git a/packages/web/test/mcp.test.ts b/packages/web/test/mcp.test.ts index 5bf6636..dbfad30 100644 --- a/packages/web/test/mcp.test.ts +++ b/packages/web/test/mcp.test.ts @@ -273,7 +273,7 @@ describe("web stdio MCP adapter", () => { }); expect(searchProperties.provider).toEqual({ type: "string", - enum: ["Exa", "Brave", "Kepos Bridge"], + enum: ["Exa", "Brave", "DeepSeek", "Kepos Bridge"], }); }); @@ -567,6 +567,19 @@ describe("web stdio MCP adapter", () => { expect.objectContaining({ provider: "kepos-bridge" }), ); }); + + it("forwards explicit DeepSeek selection while keeping the tool input query-only", async () => { + const { client, operations } = await connect(webService(), "deepseek"); + + await client.callTool({ name: "search", arguments: { query: "deepseek" } }); + + expect(operations.search).toHaveBeenCalledWith( + expect.objectContaining({ provider: "deepseek", query: "deepseek" }), + ); + const listed = await client.listTools(); + const search = listed.tools.find((tool) => tool.name === "search"); + expect(search?.inputSchema.properties).not.toHaveProperty("provider"); + }); }); function deferred() { diff --git a/packages/web/test/program.test.ts b/packages/web/test/program.test.ts index e061736..4cae55a 100644 --- a/packages/web/test/program.test.ts +++ b/packages/web/test/program.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { FetchCapabilityError } from "@guionai/web-core"; import { createProgram } from "../src/program.js"; import { runCli } from "../src/runner.js"; +import { credentialsFromEnvironment } from "../src/runtime.js"; const result = { provider: "Brave" as const, @@ -49,6 +50,12 @@ function setup() { } describe("web search Commander adapter", () => { + it("loads the DeepSeek key from the host environment without changing selection", () => { + expect( + credentialsFromEnvironment({ DEEPSEEK_API_KEY: "deepseek-key" }), + ).toMatchObject({ deepseekApiKey: "deepseek-key" }); + }); + it("passes an explicit provider and writes concise human output to stdout", async () => { const { program, operations, output } = setup(); await program.parseAsync(["search", "tree sitter", "--provider", "brave"], { @@ -67,6 +74,20 @@ describe("web search Commander adapter", () => { }); }); + it("passes explicit DeepSeek selection without adding request fields", async () => { + const { program, operations } = setup(); + await program.parseAsync( + ["search", "deepseek query", "--provider", "deepseek", "--json"], + { from: "user" }, + ); + + expect(operations.search).toHaveBeenCalledWith({ + query: "deepseek query", + provider: "deepseek", + credentials: { braveApiKey: "fixture-key" }, + }); + }); + it("accepts a flag-like query after -- and emits exactly one JSON document", async () => { const { program, operations, output } = setup(); await program.parseAsync(