dsh-web-search-plugin 0.1.1 → 0.1.2
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.
- package/CHANGELOG.md +47 -52
- package/README.md +77 -62
- package/cordis.patch.yml +20 -0
- package/lib/brave.js +157 -0
- package/lib/client.js +146 -59
- package/lib/index.js +79 -223
- package/lib/shared.js +100 -0
- package/lib/tavily.js +132 -0
- package/package.json +16 -4
package/lib/index.js
CHANGED
|
@@ -1,41 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* (`ctx.web`). Registers a `WebSearchProvider` under the stable id
|
|
4
|
-
*
|
|
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 Tavily or Brave according
|
|
5
|
+
* to the `provider` setting — so switching backends does not require a
|
|
6
|
+
* `web.searchProvider` patch change.
|
|
5
7
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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.
|
|
9
|
+
* Brave always needs a subscription token (`BRAVE_API_KEY` by default).
|
|
13
10
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* a function/namespace Cordis plugin (`inject: ['web']`) that registers its
|
|
19
|
-
* own settings section and never registers a model-facing tool.
|
|
11
|
+
* Neither backend writes custom session events. The community Brave plugin
|
|
12
|
+
* used to append `web/brave-search-request`, which is outside DSH's known
|
|
13
|
+
* event vocabulary and cannot be marked `ignorable`, so a cold load refused
|
|
14
|
+
* the conversation. Tool results already go through the seam's own events.
|
|
20
15
|
*
|
|
21
16
|
* @module dsh-web-search-plugin
|
|
22
17
|
*/
|
|
23
18
|
import z from "@deepseek-ai/schemastery";
|
|
24
|
-
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
25
19
|
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
20
|
+
import { MAX_RESULTS_CAP } from "./shared.js";
|
|
21
|
+
import { TAVILY_DEFAULT_API_KEY_ENV, TAVILY_DEFAULT_BASE_URL, TavilySearchProvider, resolveTavilyOptions } from "./tavily.js";
|
|
22
|
+
import { BRAVE_DEFAULT_API_KEY_ENV, BRAVE_DEFAULT_BASE_URL, BraveSearchProvider, resolveBraveOptions } from "./brave.js";
|
|
28
23
|
|
|
29
|
-
/** Stable id this
|
|
30
|
-
const
|
|
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;
|
|
24
|
+
/** Stable id this plugin registers under on `ctx.web`. */
|
|
25
|
+
const SEARCH_PROVIDER_ID = "dsh-web-search";
|
|
39
26
|
|
|
40
27
|
/** Cordis plugin name used by loader diagnostics. */
|
|
41
28
|
const name = "dsh-web-search-plugin";
|
|
@@ -44,236 +31,105 @@ const inject = ["web"];
|
|
|
44
31
|
|
|
45
32
|
/** Plugin config (all optional — `apply` fills defaults). */
|
|
46
33
|
const Config = z.object({
|
|
47
|
-
/**
|
|
34
|
+
/** Active backend: `tavily` (default) or `brave`. */
|
|
35
|
+
provider: z.string().default("tavily"),
|
|
36
|
+
/** Tavily auth mode: `keyless` (default) or `keyed`. */
|
|
48
37
|
mode: z.string().default("keyless"),
|
|
49
|
-
/** Literal Tavily API key; prefer {@link apiKeyEnv}
|
|
38
|
+
/** Literal Tavily API key; prefer {@link apiKeyEnv}. */
|
|
50
39
|
apiKey: z.string().role("secret").default(""),
|
|
51
|
-
/** Credential reference
|
|
52
|
-
apiKeyEnv: z.string().role("credential-ref").default(
|
|
53
|
-
/** Tavily REST API base.
|
|
40
|
+
/** Credential reference for keyed Tavily search. */
|
|
41
|
+
apiKeyEnv: z.string().role("credential-ref").default(TAVILY_DEFAULT_API_KEY_ENV),
|
|
42
|
+
/** Tavily REST API base. `/search` is appended. */
|
|
54
43
|
baseURL: z.string().default(TAVILY_DEFAULT_BASE_URL),
|
|
55
|
-
/** Upper bound on returned sources;
|
|
56
|
-
maxResults: z.number().step(1).min(1).max(
|
|
57
|
-
/**
|
|
44
|
+
/** Upper bound on returned sources; both backends accept 1..20. */
|
|
45
|
+
maxResults: z.number().step(1).min(1).max(MAX_RESULTS_CAP).default(8),
|
|
46
|
+
/** Tavily search depth: `basic` (default) or `advanced`. */
|
|
58
47
|
searchDepth: z.string().default("basic"),
|
|
59
48
|
/** Request Tavily's generated answer text; surfaced as the result `content`. */
|
|
60
49
|
includeAnswer: z.boolean().default(true),
|
|
61
|
-
/**
|
|
50
|
+
/** Tavily topic: `general` (default) or `news`. */
|
|
62
51
|
topic: z.string().default("general"),
|
|
63
|
-
/**
|
|
52
|
+
/** Literal Brave subscription token; prefer {@link braveApiKeyEnv}. */
|
|
53
|
+
braveApiKey: z.string().role("secret").default(""),
|
|
54
|
+
/** Credential reference for Brave search. */
|
|
55
|
+
braveApiKeyEnv: z.string().role("credential-ref").default(BRAVE_DEFAULT_API_KEY_ENV),
|
|
56
|
+
/** Brave Search API web-results endpoint. */
|
|
57
|
+
braveBaseURL: z.string().default(BRAVE_DEFAULT_BASE_URL),
|
|
58
|
+
/** Optional Brave `country` (ISO 2-letter, e.g. `cn`). */
|
|
59
|
+
country: z.string().default(""),
|
|
60
|
+
/** Optional Brave `search_lang` (e.g. `zh-hans`). */
|
|
61
|
+
searchLang: z.string().default(""),
|
|
62
|
+
/** Optional Brave `freshness` (`pd` / `pw` / `pm` / `py`). */
|
|
63
|
+
freshness: z.string().default(""),
|
|
64
|
+
/** Optional HTTP(S) proxy URL for Brave (falls back to HTTPS_PROXY / HTTP_PROXY). */
|
|
65
|
+
proxy: z.string().default("")
|
|
64
66
|
});
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
|
|
68
|
+
/** Settings namespace carrying this plugin's backend switch and options. */
|
|
69
|
+
const WEB_SEARCH_SETTINGS_NAMESPACE = settingsNamespace("dsh-web-search-plugin");
|
|
70
|
+
/** @deprecated Use {@link WEB_SEARCH_SETTINGS_NAMESPACE}. */
|
|
71
|
+
const WEB_SEARCH_TAVILY_SETTINGS_NAMESPACE = WEB_SEARCH_SETTINGS_NAMESPACE;
|
|
69
72
|
|
|
70
73
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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.
|
|
74
|
+
* One seam-facing provider whose `id` stays `dsh-web-search`. The active
|
|
75
|
+
* backend is read from the live settings section on every `available` /
|
|
76
|
+
* `search` call, so a card save takes effect on the next search.
|
|
81
77
|
*/
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
}
|
|
78
|
+
class PluginSearchProvider {
|
|
79
|
+
id = SEARCH_PROVIDER_ID;
|
|
105
80
|
|
|
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
81
|
constructor(resolveOptions) {
|
|
113
82
|
this.resolveOptions = resolveOptions;
|
|
83
|
+
this.tavily = new TavilySearchProvider(() => this.resolveOptions().tavily);
|
|
84
|
+
this.brave = new BraveSearchProvider(() => this.resolveOptions().brave);
|
|
114
85
|
}
|
|
115
86
|
|
|
116
|
-
id = TAVILY_PROVIDER_ID;
|
|
117
|
-
|
|
118
|
-
/** Cheap local usability check; must not make network calls. */
|
|
119
87
|
available() {
|
|
120
|
-
|
|
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);
|
|
88
|
+
return this.backend().available();
|
|
124
89
|
}
|
|
125
90
|
|
|
126
|
-
/** Run one search through the Tavily REST API. */
|
|
127
91
|
async search(request, signal) {
|
|
128
|
-
|
|
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
|
-
}
|
|
92
|
+
return this.backend().search(request, signal);
|
|
181
93
|
}
|
|
182
94
|
|
|
183
|
-
|
|
184
|
-
|
|
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");
|
|
95
|
+
backend() {
|
|
96
|
+
return this.resolveOptions().provider === "brave" ? this.brave : this.tavily;
|
|
201
97
|
}
|
|
202
98
|
}
|
|
203
99
|
|
|
204
|
-
/**
|
|
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
|
-
*/
|
|
100
|
+
/** Project one resolved section into options for both backends. */
|
|
246
101
|
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;
|
|
249
102
|
return {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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"
|
|
103
|
+
provider: config.provider === "brave" ? "brave" : "tavily",
|
|
104
|
+
tavily: resolveTavilyOptions(ctx, config),
|
|
105
|
+
brave: resolveBraveOptions(ctx, config)
|
|
264
106
|
};
|
|
265
107
|
}
|
|
266
108
|
|
|
267
|
-
/** Register the
|
|
109
|
+
/** Register the dispatched search provider with `ctx.web`. */
|
|
268
110
|
function apply(ctx, config) {
|
|
269
111
|
let current = () => config;
|
|
270
|
-
installSettingsSection(ctx,
|
|
112
|
+
installSettingsSection(ctx, WEB_SEARCH_SETTINGS_NAMESPACE, Config, config, {
|
|
271
113
|
setSource: (source) => {
|
|
272
114
|
current = source;
|
|
273
115
|
},
|
|
274
116
|
onChange: () => {}
|
|
275
117
|
});
|
|
276
|
-
ctx.web.registerSearchProvider(new
|
|
118
|
+
ctx.web.registerSearchProvider(new PluginSearchProvider(() => resolveOptions(ctx, current())));
|
|
277
119
|
}
|
|
278
120
|
|
|
279
|
-
export {
|
|
121
|
+
export {
|
|
122
|
+
BRAVE_DEFAULT_API_KEY_ENV,
|
|
123
|
+
BRAVE_DEFAULT_BASE_URL,
|
|
124
|
+
BraveSearchProvider,
|
|
125
|
+
Config,
|
|
126
|
+
SEARCH_PROVIDER_ID,
|
|
127
|
+
TAVILY_DEFAULT_API_KEY_ENV,
|
|
128
|
+
TAVILY_DEFAULT_BASE_URL,
|
|
129
|
+
TavilySearchProvider,
|
|
130
|
+
WEB_SEARCH_SETTINGS_NAMESPACE,
|
|
131
|
+
WEB_SEARCH_TAVILY_SETTINGS_NAMESPACE,
|
|
132
|
+
apply,
|
|
133
|
+
inject,
|
|
134
|
+
name
|
|
135
|
+
};
|
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
|
+
}
|
package/lib/tavily.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tavily REST search backend (`POST {baseURL}/search`).
|
|
3
|
+
* @module dsh-web-search-plugin/tavily
|
|
4
|
+
*/
|
|
5
|
+
import { WebError } from "@deepseek-ai/dsh-web";
|
|
6
|
+
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
|
|
7
|
+
import { MAX_RESULTS_CAP, USER_AGENT, isAbortError, resolveApiKey, resolveSecret, searchAborted, throwIfSearchAborted } from "./shared.js";
|
|
8
|
+
|
|
9
|
+
/** Default Tavily REST API base; `/search` is appended. */
|
|
10
|
+
export const TAVILY_DEFAULT_BASE_URL = "https://api.tavily.com";
|
|
11
|
+
/** Default credential reference resolved for every keyed search. */
|
|
12
|
+
export const TAVILY_DEFAULT_API_KEY_ENV = "TAVILY_API_KEY";
|
|
13
|
+
/** Environment variable naming this backend's endpoint override. */
|
|
14
|
+
const TAVILY_BASE_URL_ENV = "TAVILY_BASE_URL";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Normalize a standard Tavily search response into the seam's
|
|
18
|
+
* `WebSearchResult`. Sources are deduped by `url`; the provider-generated
|
|
19
|
+
* `answer` becomes `content` only when `includeAnswer` asked for it.
|
|
20
|
+
* @param json - the parsed Tavily response body.
|
|
21
|
+
* @param includeAnswer - whether the request requested `include_answer`.
|
|
22
|
+
* @returns the normalized search result.
|
|
23
|
+
*/
|
|
24
|
+
function mapTavilyResponse(json, includeAnswer) {
|
|
25
|
+
if (json === null || typeof json !== "object") throw new WebError("Tavily returned a non-object response body", "WEB_PROVIDER_ERROR");
|
|
26
|
+
const results = Array.isArray(json.results) ? json.results : [];
|
|
27
|
+
if (results.length === 0) throw new WebError("Tavily returned no results", "WEB_PROVIDER_ERROR");
|
|
28
|
+
const seen = /* @__PURE__ */ new Set();
|
|
29
|
+
const sources = [];
|
|
30
|
+
for (const item of results) {
|
|
31
|
+
if (item === null || typeof item !== "object") continue;
|
|
32
|
+
if (typeof item.url !== "string" || item.url.length === 0 || seen.has(item.url)) continue;
|
|
33
|
+
seen.add(item.url);
|
|
34
|
+
sources.push({
|
|
35
|
+
url: item.url,
|
|
36
|
+
...typeof item.title === "string" && item.title.length > 0 ? { title: item.title } : {},
|
|
37
|
+
...typeof item.content === "string" && item.content.length > 0 ? { snippet: item.content } : {},
|
|
38
|
+
...typeof item.published_date === "string" && item.published_date.length > 0 ? { publishedAt: item.published_date } : {}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
...includeAnswer === true && typeof json.answer === "string" && json.answer.length > 0 ? { content: json.answer } : {},
|
|
43
|
+
sources,
|
|
44
|
+
truncated: false
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The Tavily-backed search backend; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
|
|
49
|
+
export class TavilySearchProvider {
|
|
50
|
+
constructor(resolveOptions) {
|
|
51
|
+
this.resolveOptions = resolveOptions;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
available() {
|
|
55
|
+
const options = this.resolveOptions();
|
|
56
|
+
if (!URL.canParse(options.baseURL)) return false;
|
|
57
|
+
if (options.mode !== "keyed") return true;
|
|
58
|
+
return ((options.apiKey?.length ?? 0) > 0 || options.resolveApiKey !== void 0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async search(request, signal) {
|
|
62
|
+
const options = this.resolveOptions();
|
|
63
|
+
throwIfSearchAborted(signal);
|
|
64
|
+
let apiKey;
|
|
65
|
+
if (options.mode === "keyed") {
|
|
66
|
+
apiKey = await resolveApiKey(options, signal, `Tavily search has no API key for "${options.apiKeyEnv ?? TAVILY_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`);
|
|
67
|
+
throwIfSearchAborted(signal);
|
|
68
|
+
}
|
|
69
|
+
const endpoint = `${options.baseURL.replace(/\/+$/u, "")}/search`;
|
|
70
|
+
const body = {
|
|
71
|
+
query: request.query,
|
|
72
|
+
max_results: Math.min(request.maxResults ?? options.maxResults, MAX_RESULTS_CAP),
|
|
73
|
+
search_depth: options.searchDepth,
|
|
74
|
+
topic: options.topic,
|
|
75
|
+
include_answer: options.includeAnswer === true,
|
|
76
|
+
include_images: false
|
|
77
|
+
};
|
|
78
|
+
const headers = {
|
|
79
|
+
"content-type": "application/json",
|
|
80
|
+
"accept": "application/json",
|
|
81
|
+
"user-agent": USER_AGENT,
|
|
82
|
+
...options.mode === "keyless" ? { "x-tavily-access-mode": "keyless" } : { "authorization": `Bearer ${apiKey}` }
|
|
83
|
+
};
|
|
84
|
+
let response;
|
|
85
|
+
try {
|
|
86
|
+
response = await fetch(endpoint, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
redirect: "error",
|
|
89
|
+
headers,
|
|
90
|
+
body: JSON.stringify(body),
|
|
91
|
+
...signal !== void 0 ? { signal } : {}
|
|
92
|
+
});
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
95
|
+
throw new WebError(`Tavily search request failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
96
|
+
}
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
let message = `Tavily API error (HTTP ${response.status})`;
|
|
99
|
+
try {
|
|
100
|
+
const parsed = await response.json();
|
|
101
|
+
const detail = parsed?.detail?.error ?? parsed?.error ?? parsed?.message;
|
|
102
|
+
if (typeof detail === "string" && detail.length > 0) message = detail;
|
|
103
|
+
} catch {
|
|
104
|
+
/* non-JSON error body — keep the status-line message */
|
|
105
|
+
}
|
|
106
|
+
throw new WebError(message, "WEB_PROVIDER_ERROR");
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
return mapTavilyResponse(await response.json(), options.includeAnswer === true);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
112
|
+
if (error instanceof WebError) throw error;
|
|
113
|
+
throw new WebError(`Tavily returned an unprocessable response body: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Project the plugin section into Tavily backend options. */
|
|
119
|
+
export function resolveTavilyOptions(ctx, config) {
|
|
120
|
+
return {
|
|
121
|
+
mode: config.mode ?? "keyless",
|
|
122
|
+
...resolveSecret(ctx, {
|
|
123
|
+
literal: config.apiKey,
|
|
124
|
+
envName: config.apiKeyEnv ?? TAVILY_DEFAULT_API_KEY_ENV
|
|
125
|
+
}),
|
|
126
|
+
baseURL: config.baseURL ?? launchEnvironmentOf(ctx).get(TAVILY_BASE_URL_ENV)?.value ?? TAVILY_DEFAULT_BASE_URL,
|
|
127
|
+
maxResults: config.maxResults ?? 8,
|
|
128
|
+
searchDepth: config.searchDepth ?? "basic",
|
|
129
|
+
includeAnswer: config.includeAnswer ?? true,
|
|
130
|
+
topic: config.topic ?? "general"
|
|
131
|
+
};
|
|
132
|
+
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-web-search-plugin",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Tavily
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Tavily and Brave Search providers for the DeepSeek Harness web capability seam (ctx.web): switch engines from the settings card without a custom session event",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh-plugin",
|
|
7
7
|
"deepseek-harness",
|
|
8
8
|
"dsh",
|
|
9
9
|
"tavily",
|
|
10
|
+
"brave",
|
|
11
|
+
"brave-search",
|
|
10
12
|
"web-search",
|
|
11
13
|
"websearch",
|
|
12
14
|
"search",
|
|
@@ -16,7 +18,10 @@
|
|
|
16
18
|
"main": "lib/index.js",
|
|
17
19
|
"files": [
|
|
18
20
|
"lib",
|
|
19
|
-
"
|
|
21
|
+
"cordis.patch.yml",
|
|
22
|
+
"CHANGELOG.md",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
20
25
|
],
|
|
21
26
|
"engines": {
|
|
22
27
|
"node": ">=18"
|
|
@@ -28,9 +33,13 @@
|
|
|
28
33
|
"./client": {
|
|
29
34
|
"default": "./lib/client.js"
|
|
30
35
|
},
|
|
36
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
31
37
|
"./package.json": "./package.json"
|
|
32
38
|
},
|
|
33
39
|
"dsh": {
|
|
40
|
+
"bundle": {
|
|
41
|
+
"patch": "./cordis.patch.yml"
|
|
42
|
+
},
|
|
34
43
|
"client": {
|
|
35
44
|
"platform": "web",
|
|
36
45
|
"inject": [
|
|
@@ -44,7 +53,10 @@
|
|
|
44
53
|
}
|
|
45
54
|
},
|
|
46
55
|
"scripts": {
|
|
47
|
-
"check": "node --check lib/index.js && node --check lib/client.js"
|
|
56
|
+
"check": "node --check lib/index.js && node --check lib/client.js && node --check lib/shared.js && node --check lib/tavily.js && node --check lib/brave.js"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"undici": "^6.21.0"
|
|
48
60
|
},
|
|
49
61
|
"peerDependencies": {
|
|
50
62
|
"@deepseek-ai/dsh-web": "^0.1.0-rc.7",
|