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.
- package/CHANGELOG.md +60 -52
- package/README.md +93 -68
- package/cordis.patch.yml +26 -0
- package/lib/brave.js +157 -0
- package/lib/client.js +248 -93
- package/lib/deepseek.js +148 -0
- package/lib/index.js +104 -223
- package/lib/shared.js +100 -0
- package/lib/tavily.js +132 -0
- package/package.json +16 -4
package/lib/deepseek.js
ADDED
|
@@ -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
|
-
*
|
|
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 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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
15
|
-
* seam's
|
|
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 {
|
|
27
|
-
import {
|
|
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
|
|
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;
|
|
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
|
-
/**
|
|
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}
|
|
40
|
+
/** Literal Tavily API key; prefer {@link apiKeyEnv}. */
|
|
50
41
|
apiKey: z.string().role("secret").default(""),
|
|
51
|
-
/** Credential reference
|
|
52
|
-
apiKeyEnv: z.string().role("credential-ref").default(
|
|
53
|
-
/** Tavily REST API base.
|
|
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;
|
|
56
|
-
maxResults: z.number().step(1).min(1).max(
|
|
57
|
-
/**
|
|
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
|
-
/**
|
|
52
|
+
/** Tavily topic: `general` (default) or `news`. */
|
|
62
53
|
topic: z.string().default("general"),
|
|
63
|
-
/**
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
131
|
+
/** Register the dispatched search provider with `ctx.web`. */
|
|
268
132
|
function apply(ctx, config) {
|
|
269
133
|
let current = () => config;
|
|
270
|
-
installSettingsSection(ctx,
|
|
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
|
|
140
|
+
ctx.web.registerSearchProvider(new PluginSearchProvider(() => resolveOptions(ctx, current())));
|
|
277
141
|
}
|
|
278
142
|
|
|
279
|
-
export {
|
|
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
|
+
}
|