dsh-web-search-plugin 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * DeepSeek search backend: the Anthropic-compatible Messages API with the
3
+ * native `web_search_20250305` server tool. Each search costs a model turn
4
+ * and returns structured `web_search_tool_result` blocks; absence of those
5
+ * blocks is an error rather than a prose-scraping fallback.
6
+ *
7
+ * This plugin re-hosts the `deepseek-official` provider so the DeepSeek
8
+ * configuration can sit beside Tavily and Brave. The bundle patch disables
9
+ * the in-box `web-search-deepseek` host plugin (its namespace feed the legacy
10
+ * "plugins → web search" card, and re-registering its provider would collide).
11
+ *
12
+ * @module dsh-web-search-plugin/deepseek
13
+ */
14
+ import { WebError } from "@deepseek-ai/dsh-web";
15
+ import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
16
+ import { MAX_RESULTS_CAP, USER_AGENT, isAbortError, resolveApiKey, resolveSecret, searchAborted, throwIfSearchAborted } from "./shared.js";
17
+
18
+ /** Default endpoint: DeepSeek's Anthropic-compatible API (`/messages` appended). */
19
+ export const DEEPSEEK_DEFAULT_BASE_URL = "https://api.deepseek.com/anthropic/v1";
20
+ /** Default credential reference for this backend. */
21
+ export const DEEPSEEK_DEFAULT_API_KEY_ENV = "DEEPSEEK_API_KEY";
22
+ /** Environment variable naming this backend's endpoint override. */
23
+ const DEEPSEEK_SEARCH_BASE_URL_ENV = "DEEPSEEK_SEARCH_BASE_URL";
24
+ const DEFAULT_MODEL = "deepseek-v4-flash";
25
+ const DEFAULT_API_VERSION = "2023-06-01";
26
+ const DEFAULT_MAX_TOKENS = 4096;
27
+ const DEFAULT_MAX_USES = 5;
28
+
29
+ /**
30
+ * Build a `url → cited_text` map from `text` blocks' `citations[]` (the snippet
31
+ * source for Anthropic `web_search_result` items, which carry url/title but no
32
+ * inline excerpt).
33
+ */
34
+ function citationSnippets(blocks) {
35
+ const map = /* @__PURE__ */ new Map();
36
+ for (const block of blocks) {
37
+ if (block.type !== "text") continue;
38
+ for (const cite of block.citations ?? []) if (cite.url != null && cite.url.length > 0 && cite.cited_text != null && cite.cited_text.length > 0 && !map.has(cite.url)) map.set(cite.url, cite.cited_text);
39
+ }
40
+ return map;
41
+ }
42
+
43
+ /** Map an Anthropic Messages response to a normalized search result. */
44
+ function mapAnthropicResponse(response) {
45
+ const blocks = response.content ?? [];
46
+ const resultBlocks = blocks.filter((block) => block.type === "web_search_tool_result");
47
+ if (resultBlocks.length === 0) throw new WebError("DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search", "WEB_PROVIDER_ERROR");
48
+ const snippets = citationSnippets(blocks);
49
+ const seen = /* @__PURE__ */ new Set();
50
+ const sources = [];
51
+ for (const block of resultBlocks) for (const item of block.content ?? []) {
52
+ if (item.type !== "web_search_result" || typeof item.url !== "string" || item.url.length === 0 || seen.has(item.url)) continue;
53
+ seen.add(item.url);
54
+ const snippet = snippets.get(item.url);
55
+ sources.push({
56
+ url: item.url,
57
+ ...typeof item.title === "string" && item.title.length > 0 ? { title: item.title } : {},
58
+ ...snippet !== void 0 ? { snippet } : {},
59
+ ...typeof item.page_age === "string" && item.page_age.length > 0 ? { publishedAt: item.page_age } : {}
60
+ });
61
+ }
62
+ return { sources, truncated: false };
63
+ }
64
+
65
+ /** The DeepSeek-backed search backend; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
66
+ export class DeepSeekSearchProvider {
67
+ constructor(resolveOptions) {
68
+ this.resolveOptions = resolveOptions;
69
+ }
70
+
71
+ available() {
72
+ const options = this.resolveOptions();
73
+ return ((options.apiKey?.length ?? 0) > 0 || options.resolveApiKey !== void 0)
74
+ && URL.canParse(options.baseURL)
75
+ && Number.isInteger(options.maxTokens) && options.maxTokens > 0
76
+ && Number.isInteger(options.maxUses) && options.maxUses > 0;
77
+ }
78
+
79
+ async search(request, signal) {
80
+ const options = this.resolveOptions();
81
+ const apiKey = await resolveApiKey(options, signal, `DeepSeek search has no API key for "${options.apiKeyEnv ?? DEEPSEEK_DEFAULT_API_KEY_ENV}"; store it through the credentials service, export it in the launching environment, or set a literal "deepseekApiKey" in the dsh-web-search-plugin config`);
82
+ throwIfSearchAborted(signal);
83
+ const endpoint = `${options.baseURL.replace(/\/+$/u, "")}/messages`;
84
+ const body = {
85
+ model: options.model,
86
+ max_tokens: options.maxTokens,
87
+ messages: [{
88
+ role: "user",
89
+ content: [{ type: "text", text: `Perform a web search for the query: ${request.query}` }]
90
+ }],
91
+ tools: [{ type: "web_search_20250305", name: "web_search", max_uses: options.maxUses }]
92
+ };
93
+ throwIfSearchAborted(signal);
94
+ let response;
95
+ try {
96
+ response = await fetch(endpoint, {
97
+ method: "POST",
98
+ redirect: "error",
99
+ headers: {
100
+ "x-api-key": apiKey,
101
+ "authorization": `Bearer ${apiKey}`,
102
+ "anthropic-version": options.apiVersion,
103
+ "content-type": "application/json",
104
+ "accept": "application/json",
105
+ "user-agent": USER_AGENT
106
+ },
107
+ body: JSON.stringify(body),
108
+ ...signal !== void 0 ? { signal } : {}
109
+ });
110
+ } catch (error) {
111
+ if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
112
+ throw new WebError(`DeepSeek search request failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
113
+ }
114
+ if (!response.ok) {
115
+ let message = `DeepSeek API error (HTTP ${response.status})`;
116
+ try {
117
+ const parsed = await response.json();
118
+ const detail = typeof parsed.error === "string" ? parsed.error : parsed.error?.message ?? parsed.message;
119
+ if (typeof detail === "string" && detail.length > 0) message = detail;
120
+ } catch {
121
+ /* non-JSON error body — keep the status-line message */
122
+ }
123
+ throw new WebError(message, "WEB_PROVIDER_ERROR");
124
+ }
125
+ try {
126
+ return mapAnthropicResponse(await response.json());
127
+ } catch (error) {
128
+ if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
129
+ if (error instanceof WebError) throw error;
130
+ throw new WebError(`DeepSeek returned an unprocessable response body: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
131
+ }
132
+ }
133
+ }
134
+
135
+ /** Project the plugin section into DeepSeek backend options. */
136
+ export function resolveDeepseekOptions(ctx, config) {
137
+ return {
138
+ ...resolveSecret(ctx, {
139
+ literal: config.deepseekApiKey,
140
+ envName: config.deepseekApiKeyEnv ?? DEEPSEEK_DEFAULT_API_KEY_ENV
141
+ }),
142
+ baseURL: config.deepseekBaseURL ?? launchEnvironmentOf(ctx).get(DEEPSEEK_SEARCH_BASE_URL_ENV)?.value ?? DEEPSEEK_DEFAULT_BASE_URL,
143
+ model: config.model ?? DEFAULT_MODEL,
144
+ apiVersion: config.apiVersion ?? DEFAULT_API_VERSION,
145
+ maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
146
+ maxUses: config.maxUses ?? DEFAULT_MAX_USES
147
+ };
148
+ }
package/lib/index.js CHANGED
@@ -1,41 +1,30 @@
1
1
  /**
2
- * Tavily-backed search provider for the DeepSeek Harness web capability seam
3
- * (`ctx.web`). Registers a `WebSearchProvider` under the stable id `tavily`
4
- * that calls the Tavily REST API (`POST {baseURL}/search`).
2
+ * Web-search provider plugin for the DeepSeek Harness web capability seam
3
+ * (`ctx.web`). Registers a single `WebSearchProvider` under the stable id
4
+ * `dsh-web-search` and dispatches each search to DeepSeek (official), Tavily,
5
+ * or Brave according to the `provider` setting — so switching backends does
6
+ * not require a `web.searchProvider` patch change.
5
7
  *
6
- * Two authentication modes are supported, switchable through this plugin's
7
- * settings section (`dsh-web-search-plugin`):
8
- * - `keyless`: free rate-limited access, no account or key. A single
9
- * `X-Tavily-Access-Mode: keyless` header activates it.
10
- * - `keyed`: a Tavily API key resolved through the credentials service
11
- * (`TAVILY_API_KEY` by default), the launching environment, or a literal
12
- * `apiKey` in the config; sent as a Bearer token.
8
+ * Tavily supports `keyless` (free, rate-limited) and `keyed` modes. Brave
9
+ * always needs a subscription token (`BRAVE_API_KEY` by default). DeepSeek
10
+ * uses the Anthropic-compatible Messages API native `web_search_20250305`
11
+ * tool and reuses `DEEPSEEK_API_KEY` (the in-box `web-search-deepseek` host
12
+ * plugin is disabled by the bundle patch).
13
13
  *
14
- * Responses follow the standard Tavily schema and are normalized into the
15
- * seam's `WebSearchResult` (`answer` -> `content`, `results[]` -> `sources[]`).
16
- *
17
- * The plugin mirrors the structure of `@deepseek-ai/dsh-web-search-deepseek`:
18
- * a function/namespace Cordis plugin (`inject: ['web']`) that registers its
19
- * own settings section and never registers a model-facing tool.
14
+ * No backend writes custom session events; tool results already go through
15
+ * the seam's own events.
20
16
  *
21
17
  * @module dsh-web-search-plugin
22
18
  */
23
19
  import z from "@deepseek-ai/schemastery";
24
- import { credentialRef } from "@deepseek-ai/dsh-credentials";
25
20
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
26
- import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
27
- import { WebError } from "@deepseek-ai/dsh-web";
21
+ import { MAX_RESULTS_CAP } from "./shared.js";
22
+ import { TAVILY_DEFAULT_API_KEY_ENV, TAVILY_DEFAULT_BASE_URL, TavilySearchProvider, resolveTavilyOptions } from "./tavily.js";
23
+ import { BRAVE_DEFAULT_API_KEY_ENV, BRAVE_DEFAULT_BASE_URL, BraveSearchProvider, resolveBraveOptions } from "./brave.js";
24
+ import { DEEPSEEK_DEFAULT_API_KEY_ENV, DEEPSEEK_DEFAULT_BASE_URL, DeepSeekSearchProvider, resolveDeepseekOptions } from "./deepseek.js";
28
25
 
29
- /** Stable id this provider registers under. */
30
- const TAVILY_PROVIDER_ID = "tavily";
31
- /** Default Tavily REST API base; `/search` is appended. */
32
- const TAVILY_DEFAULT_BASE_URL = "https://api.tavily.com";
33
- /** Default credential reference resolved for every keyed search. */
34
- const DEFAULT_API_KEY_ENV = "TAVILY_API_KEY";
35
- /** Attribution header sent on every request. */
36
- const USER_AGENT = "dsh-web-search-plugin/0.1.1";
37
- /** Tavily's hard cap on `max_results`. */
38
- const TAVILY_MAX_RESULTS_CAP = 20;
26
+ /** Stable id this plugin registers under on `ctx.web`. */
27
+ const SEARCH_PROVIDER_ID = "dsh-web-search";
39
28
 
40
29
  /** Cordis plugin name used by loader diagnostics. */
41
30
  const name = "dsh-web-search-plugin";
@@ -44,236 +33,128 @@ const inject = ["web"];
44
33
 
45
34
  /** Plugin config (all optional — `apply` fills defaults). */
46
35
  const Config = z.object({
47
- /** Authentication mode: `keyless` (default, free rate-limited) or `keyed`. */
36
+ /** Active backend: `deepseek-official`, `tavily` (default), or `brave`. */
37
+ provider: z.string().default("tavily"),
38
+ /** Tavily auth mode: `keyless` (default) or `keyed`. */
48
39
  mode: z.string().default("keyless"),
49
- /** Literal Tavily API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
40
+ /** Literal Tavily API key; prefer {@link apiKeyEnv}. */
50
41
  apiKey: z.string().role("secret").default(""),
51
- /** Credential reference resolved for each keyed search; defaults to `TAVILY_API_KEY`. */
52
- apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
53
- /** Tavily REST API base. Defaults to `https://api.tavily.com`. */
42
+ /** Credential reference for keyed Tavily search. */
43
+ apiKeyEnv: z.string().role("credential-ref").default(TAVILY_DEFAULT_API_KEY_ENV),
44
+ /** Tavily REST API base. `/search` is appended. */
54
45
  baseURL: z.string().default(TAVILY_DEFAULT_BASE_URL),
55
- /** Upper bound on returned sources; Tavily accepts 1..20. */
56
- maxResults: z.number().step(1).min(1).max(TAVILY_MAX_RESULTS_CAP).default(8),
57
- /** Search depth: `basic` (default) or `advanced`. */
46
+ /** Upper bound on returned sources; both backends accept 1..20. */
47
+ maxResults: z.number().step(1).min(1).max(MAX_RESULTS_CAP).default(8),
48
+ /** Tavily search depth: `basic` (default) or `advanced`. */
58
49
  searchDepth: z.string().default("basic"),
59
50
  /** Request Tavily's generated answer text; surfaced as the result `content`. */
60
51
  includeAnswer: z.boolean().default(true),
61
- /** Search topic: `general` (default) or `news`. */
52
+ /** Tavily topic: `general` (default) or `news`. */
62
53
  topic: z.string().default("general"),
63
- /** Settings namespace carrying this provider's mode, endpoint, and key reference. */
54
+ /** Literal Brave subscription token; prefer {@link braveApiKeyEnv}. */
55
+ braveApiKey: z.string().role("secret").default(""),
56
+ /** Credential reference for Brave search. */
57
+ braveApiKeyEnv: z.string().role("credential-ref").default(BRAVE_DEFAULT_API_KEY_ENV),
58
+ /** Brave Search API web-results endpoint. */
59
+ braveBaseURL: z.string().default(BRAVE_DEFAULT_BASE_URL),
60
+ /** Optional Brave `country` (ISO 2-letter, e.g. `cn`). */
61
+ country: z.string().default(""),
62
+ /** Optional Brave `search_lang` (e.g. `zh-hans`). */
63
+ searchLang: z.string().default(""),
64
+ /** Optional Brave `freshness` (`pd` / `pw` / `pm` / `py`). */
65
+ freshness: z.string().default(""),
66
+ /** Optional HTTP(S) proxy URL for Brave (falls back to HTTPS_PROXY / HTTP_PROXY). */
67
+ proxy: z.string().default(""),
68
+ /** Literal DeepSeek API key; prefer {@link deepseekApiKeyEnv}. */
69
+ deepseekApiKey: z.string().role("secret").default(""),
70
+ /** Credential reference for DeepSeek search. */
71
+ deepseekApiKeyEnv: z.string().role("credential-ref").default(DEEPSEEK_DEFAULT_API_KEY_ENV),
72
+ /** DeepSeek Anthropic-compatible Messages base URL. `/messages` is appended. */
73
+ deepseekBaseURL: z.string().default(DEEPSEEK_DEFAULT_BASE_URL),
74
+ /** Anthropic-format model name for the Messages request. */
75
+ model: z.string().default("deepseek-v4-flash"),
76
+ /** `anthropic-version` header value. */
77
+ apiVersion: z.string().default("2023-06-01"),
78
+ /** Upper bound on generated tokens for the Messages request. */
79
+ maxTokens: z.number().step(1).min(1).default(4096),
80
+ /** Maximum `web_search` server-tool uses per request. */
81
+ maxUses: z.number().step(1).min(1).default(5)
64
82
  });
65
- /** Settings namespace carrying this provider's mode, endpoint, and key reference. */
66
- const WEB_SEARCH_TAVILY_SETTINGS_NAMESPACE = settingsNamespace("dsh-web-search-plugin");
67
- /** Environment variable naming this provider's endpoint override. */
68
- const TAVILY_BASE_URL_ENV = "TAVILY_BASE_URL";
83
+
84
+ /** Settings namespace carrying this plugin's backend switch and options. */
85
+ const WEB_SEARCH_SETTINGS_NAMESPACE = settingsNamespace("dsh-web-search-plugin");
86
+ /** @deprecated Use {@link WEB_SEARCH_SETTINGS_NAMESPACE}. */
87
+ const WEB_SEARCH_TAVILY_SETTINGS_NAMESPACE = WEB_SEARCH_SETTINGS_NAMESPACE;
69
88
 
70
89
  /**
71
- * Normalize a standard Tavily search response into the seam's
72
- * `WebSearchResult`. Sources are deduped by `url` (Tavily can surface the same
73
- * page twice); the provider-generated `answer` becomes `content` only when the
74
- * `includeAnswer` option asked for it. The seam owns the final `maxResults`
75
- * truncation, so `truncated` is always `false` here.
76
- *
77
- * @param json - the parsed Tavily response body.
78
- * @param includeAnswer - whether the request requested `include_answer`.
79
- * @returns the normalized search result.
80
- * @throws {@link WebError} when the body is not a usable object.
90
+ * One seam-facing provider whose `id` stays `dsh-web-search`. The active
91
+ * backend is read from the live settings section on every `available` /
92
+ * `search` call, so a card save takes effect on the next search.
81
93
  */
82
- function mapTavilyResponse(json, includeAnswer) {
83
- if (json === null || typeof json !== "object") throw new WebError("Tavily returned a non-object response body", "WEB_PROVIDER_ERROR");
84
- const results = Array.isArray(json.results) ? json.results : [];
85
- if (results.length === 0) throw new WebError("Tavily returned no results", "WEB_PROVIDER_ERROR");
86
- const seen = /* @__PURE__ */ new Set();
87
- const sources = [];
88
- for (const item of results) {
89
- if (item === null || typeof item !== "object") continue;
90
- if (typeof item.url !== "string" || item.url.length === 0 || seen.has(item.url)) continue;
91
- seen.add(item.url);
92
- sources.push({
93
- url: item.url,
94
- ...typeof item.title === "string" && item.title.length > 0 ? { title: item.title } : {},
95
- ...typeof item.content === "string" && item.content.length > 0 ? { snippet: item.content } : {},
96
- ...typeof item.published_date === "string" && item.published_date.length > 0 ? { publishedAt: item.published_date } : {}
97
- });
98
- }
99
- return {
100
- ...includeAnswer === true && typeof json.answer === "string" && json.answer.length > 0 ? { content: json.answer } : {},
101
- sources,
102
- truncated: false
103
- };
104
- }
94
+ class PluginSearchProvider {
95
+ id = SEARCH_PROVIDER_ID;
105
96
 
106
- /** The Tavily-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
107
- class TavilySearchProvider {
108
- /**
109
- * @param resolveOptions - the options for the NEXT operation, snapshotted
110
- * once at each operation's entry so one search never mixes two sections.
111
- */
112
97
  constructor(resolveOptions) {
113
98
  this.resolveOptions = resolveOptions;
99
+ this.deepseek = new DeepSeekSearchProvider(() => this.resolveOptions().deepseek);
100
+ this.tavily = new TavilySearchProvider(() => this.resolveOptions().tavily);
101
+ this.brave = new BraveSearchProvider(() => this.resolveOptions().brave);
114
102
  }
115
103
 
116
- id = TAVILY_PROVIDER_ID;
117
-
118
- /** Cheap local usability check; must not make network calls. */
119
104
  available() {
120
- const options = this.resolveOptions();
121
- if (!URL.canParse(options.baseURL)) return false;
122
- if (options.mode !== "keyed") return true;
123
- return ((options.apiKey?.length ?? 0) > 0 || options.resolveApiKey !== void 0);
105
+ return this.backend().available();
124
106
  }
125
107
 
126
- /** Run one search through the Tavily REST API. */
127
108
  async search(request, signal) {
128
- const options = this.resolveOptions();
129
- throwIfSearchAborted(signal);
130
- let apiKey;
131
- if (options.mode === "keyed") {
132
- apiKey = await this.apiKey(options, signal);
133
- throwIfSearchAborted(signal);
134
- }
135
- const endpoint = `${options.baseURL.replace(/\/+$/u, "")}/search`;
136
- const body = {
137
- query: request.query,
138
- max_results: Math.min(request.maxResults ?? options.maxResults, TAVILY_MAX_RESULTS_CAP),
139
- search_depth: options.searchDepth,
140
- topic: options.topic,
141
- include_answer: options.includeAnswer === true,
142
- include_images: false
143
- };
144
- const headers = {
145
- "content-type": "application/json",
146
- "accept": "application/json",
147
- "user-agent": USER_AGENT,
148
- ...options.mode === "keyless" ? { "x-tavily-access-mode": "keyless" } : { "authorization": `Bearer ${apiKey}` }
149
- };
150
- let response;
151
- try {
152
- response = await fetch(endpoint, {
153
- method: "POST",
154
- redirect: "error",
155
- headers,
156
- body: JSON.stringify(body),
157
- ...signal !== void 0 ? { signal } : {}
158
- });
159
- } catch (error) {
160
- if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
161
- throw new WebError(`Tavily search request failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
162
- }
163
- if (!response.ok) {
164
- let message = `Tavily API error (HTTP ${response.status})`;
165
- try {
166
- const parsed = await response.json();
167
- const detail = parsed?.detail?.error ?? parsed?.error ?? parsed?.message;
168
- if (typeof detail === "string" && detail.length > 0) message = detail;
169
- } catch {
170
- /* non-JSON error body — keep the status-line message */
171
- }
172
- throw new WebError(message, "WEB_PROVIDER_ERROR");
173
- }
174
- try {
175
- return mapTavilyResponse(await response.json(), options.includeAnswer === true);
176
- } catch (error) {
177
- if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
178
- if (error instanceof WebError) throw error;
179
- throw new WebError(`Tavily returned an unprocessable response body: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
180
- }
109
+ return this.backend().search(request, signal);
181
110
  }
182
111
 
183
- /**
184
- * Resolve one operation's credential without retaining it on the provider.
185
- * @param options - the caller's snapshot; the certificate and the endpoint it is sent to come from one section.
186
- * @param signal - abort signal for the surrounding search.
187
- * @returns the resolved key.
188
- */
189
- async apiKey(options, signal) {
190
- throwIfSearchAborted(signal);
191
- if (options.apiKey !== void 0 && options.apiKey.length > 0) return options.apiKey;
192
- let resolved;
193
- try {
194
- resolved = await abortable(options.resolveApiKey?.() ?? Promise.resolve(void 0), signal);
195
- } catch (error) {
196
- if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
197
- throw new WebError(`Tavily search credential resolution failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
198
- }
199
- if (resolved !== void 0 && resolved.length > 0) return resolved;
200
- throw new WebError(`Tavily search has no API key for "${options.apiKeyEnv ?? DEFAULT_API_KEY_ENV}"; store it through the credentials service, export it in the launching environment, or set a literal "apiKey" in the dsh-web-search-plugin config`, "WEB_PROVIDER_CREDENTIAL_MISSING");
112
+ backend() {
113
+ const provider = this.resolveOptions().provider;
114
+ if (provider === "brave") return this.brave;
115
+ if (provider === "deepseek-official") return this.deepseek;
116
+ return this.tavily;
201
117
  }
202
118
  }
203
119
 
204
- /** Race a same-process asynchronous preflight against caller cancellation. */
205
- function abortable(operation, signal) {
206
- if (signal === void 0) return operation;
207
- if (signal.aborted) return Promise.reject(searchAborted(signal));
208
- return new Promise((resolve, reject) => {
209
- const onAbort = () => {
210
- reject(searchAborted(signal));
211
- };
212
- signal.addEventListener("abort", onAbort, { once: true });
213
- operation.then((value) => {
214
- signal.removeEventListener("abort", onAbort);
215
- resolve(value);
216
- }, (error) => {
217
- signal.removeEventListener("abort", onAbort);
218
- reject(new Error(String(error).replace(/^Error: /u, ""), { cause: error }));
219
- });
220
- });
221
- }
222
-
223
- /** Throw the provider's stable cancellation error when the caller already aborted. */
224
- function throwIfSearchAborted(signal) {
225
- if (signal?.aborted === true) throw searchAborted(signal);
226
- }
227
-
228
- /** Build the provider's stable cancellation error while retaining the caller's reason. */
229
- function searchAborted(signal, fallback) {
230
- return new WebError("Tavily search aborted", "WEB_ABORTED", { cause: signal?.aborted === true ? signal.reason : fallback });
231
- }
232
-
233
- /** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
234
- function isAbortError(error) {
235
- return error instanceof DOMException && error.name === "AbortError";
236
- }
237
-
238
- /**
239
- * Project one resolved section into the options the provider serves its next
240
- * search with. Environment fallbacks stay here rather than in the provider:
241
- * every value it reads is already fully defaulted.
242
- * @param ctx - plugin context supplying the credential and environment planes.
243
- * @param config - the currently authoritative section.
244
- * @returns options for one search.
245
- */
120
+ /** Project one resolved section into options for each backend. */
246
121
  function resolveOptions(ctx, config) {
247
- const apiKeyEnv = credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV);
248
- const literalApiKey = config.apiKey !== void 0 && config.apiKey.length > 0 ? config.apiKey : void 0;
122
+ const provider = config.provider === "brave" || config.provider === "deepseek-official" ? config.provider : "tavily";
249
123
  return {
250
- mode: config.mode ?? "keyless",
251
- ...literalApiKey === void 0 ? {} : { apiKey: literalApiKey },
252
- resolveApiKey: async () => {
253
- const credentials = ctx.get("credentials");
254
- if (credentials !== void 0) return (await credentials.resolve(apiKeyEnv))?.value;
255
- const ambient = launchEnvironmentOf(ctx).get(apiKeyEnv);
256
- return ambient !== void 0 && ambient.value.length > 0 ? ambient.value : void 0;
257
- },
258
- apiKeyEnv,
259
- baseURL: config.baseURL ?? launchEnvironmentOf(ctx).get(TAVILY_BASE_URL_ENV)?.value ?? TAVILY_DEFAULT_BASE_URL,
260
- maxResults: config.maxResults ?? 8,
261
- searchDepth: config.searchDepth ?? "basic",
262
- includeAnswer: config.includeAnswer ?? true,
263
- topic: config.topic ?? "general"
124
+ provider,
125
+ deepseek: resolveDeepseekOptions(ctx, config),
126
+ tavily: resolveTavilyOptions(ctx, config),
127
+ brave: resolveBraveOptions(ctx, config)
264
128
  };
265
129
  }
266
130
 
267
- /** Register the Tavily search provider with `ctx.web`. */
131
+ /** Register the dispatched search provider with `ctx.web`. */
268
132
  function apply(ctx, config) {
269
133
  let current = () => config;
270
- installSettingsSection(ctx, WEB_SEARCH_TAVILY_SETTINGS_NAMESPACE, Config, config, {
134
+ installSettingsSection(ctx, WEB_SEARCH_SETTINGS_NAMESPACE, Config, config, {
271
135
  setSource: (source) => {
272
136
  current = source;
273
137
  },
274
138
  onChange: () => {}
275
139
  });
276
- ctx.web.registerSearchProvider(new TavilySearchProvider(() => resolveOptions(ctx, current())));
140
+ ctx.web.registerSearchProvider(new PluginSearchProvider(() => resolveOptions(ctx, current())));
277
141
  }
278
142
 
279
- export { Config, TAVILY_DEFAULT_BASE_URL, TAVILY_PROVIDER_ID, TavilySearchProvider, WEB_SEARCH_TAVILY_SETTINGS_NAMESPACE, apply, inject, name };
143
+ export {
144
+ BRAVE_DEFAULT_API_KEY_ENV,
145
+ BRAVE_DEFAULT_BASE_URL,
146
+ BraveSearchProvider,
147
+ Config,
148
+ DEEPSEEK_DEFAULT_API_KEY_ENV,
149
+ DEEPSEEK_DEFAULT_BASE_URL,
150
+ DeepSeekSearchProvider,
151
+ SEARCH_PROVIDER_ID,
152
+ TAVILY_DEFAULT_API_KEY_ENV,
153
+ TAVILY_DEFAULT_BASE_URL,
154
+ TavilySearchProvider,
155
+ WEB_SEARCH_SETTINGS_NAMESPACE,
156
+ WEB_SEARCH_TAVILY_SETTINGS_NAMESPACE,
157
+ apply,
158
+ inject,
159
+ name
160
+ };
package/lib/shared.js ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Shared abort / credential helpers for the Tavily and Brave backends.
3
+ * @module dsh-web-search-plugin/shared
4
+ */
5
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
6
+ import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
7
+ import { WebError } from "@deepseek-ai/dsh-web";
8
+
9
+ /** Attribution header sent on every request. */
10
+ export const USER_AGENT = "dsh-web-search-plugin/0.1.2";
11
+ /** Shared cap: Tavily `max_results` and Brave `count` both accept 1..20. */
12
+ export const MAX_RESULTS_CAP = 20;
13
+
14
+ /**
15
+ * Resolve one secret without retaining it: literal config, credentials
16
+ * service, launching environment, then `process.env`.
17
+ * @param ctx - plugin context.
18
+ * @param config - section fields naming the literal and the env/credential ref.
19
+ * @returns the snapshot used by one search.
20
+ */
21
+ export function resolveSecret(ctx, config) {
22
+ const envName = config.envName;
23
+ const apiKeyEnv = credentialRef(envName);
24
+ const literal = config.literal !== void 0 && config.literal.length > 0 ? config.literal : void 0;
25
+ return {
26
+ ...literal === void 0 ? {} : { apiKey: literal },
27
+ apiKeyEnv,
28
+ resolveApiKey: async () => {
29
+ const credentials = ctx.get("credentials");
30
+ if (credentials !== void 0) {
31
+ const stored = (await credentials.resolve(apiKeyEnv))?.value;
32
+ if (stored !== void 0 && stored.length > 0) return stored;
33
+ }
34
+ const ambient = launchEnvironmentOf(ctx).get(apiKeyEnv);
35
+ if (ambient !== void 0 && ambient.value.length > 0) return ambient.value;
36
+ const raw = process.env[apiKeyEnv];
37
+ return typeof raw === "string" && raw.length > 0 ? raw : void 0;
38
+ }
39
+ };
40
+ }
41
+
42
+ /** Race a same-process asynchronous preflight against caller cancellation. */
43
+ export function abortable(operation, signal) {
44
+ if (signal === void 0) return operation;
45
+ if (signal.aborted) return Promise.reject(searchAborted(signal));
46
+ return new Promise((resolve, reject) => {
47
+ const onAbort = () => {
48
+ reject(searchAborted(signal));
49
+ };
50
+ signal.addEventListener("abort", onAbort, { once: true });
51
+ operation.then((value) => {
52
+ signal.removeEventListener("abort", onAbort);
53
+ resolve(value);
54
+ }, (error) => {
55
+ signal.removeEventListener("abort", onAbort);
56
+ reject(new Error(String(error).replace(/^Error: /u, ""), { cause: error }));
57
+ });
58
+ });
59
+ }
60
+
61
+ /** Throw the provider's stable cancellation error when the caller already aborted. */
62
+ export function throwIfSearchAborted(signal) {
63
+ if (signal?.aborted === true) throw searchAborted(signal);
64
+ }
65
+
66
+ /** Build the provider's stable cancellation error while retaining the caller's reason. */
67
+ export function searchAborted(signal, fallback) {
68
+ return new WebError("Search aborted", "WEB_ABORTED", { cause: signal?.aborted === true ? signal.reason : fallback });
69
+ }
70
+
71
+ /** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */
72
+ export function isAbortError(error) {
73
+ return error instanceof DOMException && error.name === "AbortError";
74
+ }
75
+
76
+ /** True for positive integers (result counts and request bounds). */
77
+ export function isPositiveInteger(value) {
78
+ return Number.isInteger(value) && value > 0;
79
+ }
80
+
81
+ /**
82
+ * Resolve one operation's credential without retaining it on the provider.
83
+ * @param options - the caller's snapshot.
84
+ * @param signal - abort signal for the surrounding search.
85
+ * @param missingMessage - thrown when no key can be resolved.
86
+ * @returns the resolved key.
87
+ */
88
+ export async function resolveApiKey(options, signal, missingMessage) {
89
+ throwIfSearchAborted(signal);
90
+ if (options.apiKey !== void 0 && options.apiKey.length > 0) return options.apiKey;
91
+ let resolved;
92
+ try {
93
+ resolved = await abortable(options.resolveApiKey?.() ?? Promise.resolve(void 0), signal);
94
+ } catch (error) {
95
+ if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
96
+ throw new WebError(`Search credential resolution failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
97
+ }
98
+ if (resolved !== void 0 && resolved.length > 0) return resolved;
99
+ throw new WebError(missingMessage, "WEB_PROVIDER_CREDENTIAL_MISSING");
100
+ }