dsh-web-search-plugin 0.4.1 → 0.5.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 +42 -1
- package/README.md +70 -66
- package/cordis.patch.yml +4 -3
- package/lib/client.js +173 -85
- package/lib/deepseek.js +71 -15
- package/lib/index.js +135 -77
- package/lib/providers.js +7 -7
- package/lib/rest.js +10 -5
- package/lib/shared.js +1 -1
- package/lib/tavily.js +2 -2
- package/lib/usage.js +53 -30
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +11 -8
package/lib/deepseek.js
CHANGED
|
@@ -4,16 +4,24 @@
|
|
|
4
4
|
* and returns structured `web_search_tool_result` blocks; absence of those
|
|
5
5
|
* blocks is an error rather than a prose-scraping fallback.
|
|
6
6
|
*
|
|
7
|
-
* This plugin
|
|
8
|
-
*
|
|
9
|
-
* the in-box `web-search-deepseek` host plugin
|
|
10
|
-
*
|
|
7
|
+
* This plugin implements DeepSeek as an internal backend so its configuration
|
|
8
|
+
* can sit beside the REST engines. The bundle patch disables
|
|
9
|
+
* the in-box `web-search-deepseek` host plugin so this bundle owns the selected
|
|
10
|
+
* search provider and its configuration. The seam-facing id registered by this
|
|
11
|
+
* plugin is `dsh-web-search`; `deepseek-official` is an internal backend choice.
|
|
12
|
+
*
|
|
13
|
+
* Authentication follows the official provider: a search started from a
|
|
14
|
+
* DeepSeek-account session authenticates with the account token
|
|
15
|
+
* (`x-dsh-auth-token`) when the account service allows this endpoint; every
|
|
16
|
+
* other search reuses `DEEPSEEK_API_KEY`. When a session initiator exists,
|
|
17
|
+
* the secret-free request is recorded as a
|
|
18
|
+
* `web/deepseek-search-llm-request` session event before dispatch.
|
|
11
19
|
*
|
|
12
20
|
* @module dsh-web-search-plugin/deepseek
|
|
13
21
|
*/
|
|
14
22
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
15
23
|
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
|
|
16
|
-
import { MAX_RESULTS_CAP, USER_AGENT, isAbortError, resolveApiKey, resolveSecret, searchAborted, throwIfSearchAborted } from "./shared.js";
|
|
24
|
+
import { MAX_RESULTS_CAP, USER_AGENT, abortable, isAbortError, resolveApiKey, resolveSecret, searchAborted, throwIfSearchAborted } from "./shared.js";
|
|
17
25
|
|
|
18
26
|
/** Default endpoint: DeepSeek's Anthropic-compatible API (`/messages` appended). */
|
|
19
27
|
export const DEEPSEEK_DEFAULT_BASE_URL = "https://api.deepseek.com/anthropic/v1";
|
|
@@ -21,6 +29,12 @@ export const DEEPSEEK_DEFAULT_BASE_URL = "https://api.deepseek.com/anthropic/v1"
|
|
|
21
29
|
export const DEEPSEEK_DEFAULT_API_KEY_ENV = "DEEPSEEK_API_KEY";
|
|
22
30
|
/** Environment variable naming this backend's endpoint override. */
|
|
23
31
|
const DEEPSEEK_SEARCH_BASE_URL_ENV = "DEEPSEEK_SEARCH_BASE_URL";
|
|
32
|
+
/**
|
|
33
|
+
* Provider route id `dsh-llm-deepseek-account` registers; the session's
|
|
34
|
+
* `request/context` records it per Session. Only such a session may spend
|
|
35
|
+
* account quota on auxiliary search.
|
|
36
|
+
*/
|
|
37
|
+
const ACCOUNT_PROVIDER = "deepseek-account";
|
|
24
38
|
const DEFAULT_MODEL = "deepseek-v4-flash";
|
|
25
39
|
const DEFAULT_API_VERSION = "2023-06-01";
|
|
26
40
|
const DEFAULT_MAX_TOKENS = 4096;
|
|
@@ -41,7 +55,7 @@ function citationSnippets(blocks) {
|
|
|
41
55
|
}
|
|
42
56
|
|
|
43
57
|
/** Map an Anthropic Messages response to a normalized search result. */
|
|
44
|
-
function mapAnthropicResponse(response) {
|
|
58
|
+
function mapAnthropicResponse(response, maxResults) {
|
|
45
59
|
const blocks = response.content ?? [];
|
|
46
60
|
const resultBlocks = blocks.filter((block) => block.type === "web_search_tool_result");
|
|
47
61
|
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");
|
|
@@ -59,7 +73,9 @@ function mapAnthropicResponse(response) {
|
|
|
59
73
|
...typeof item.page_age === "string" && item.page_age.length > 0 ? { publishedAt: item.page_age } : {}
|
|
60
74
|
});
|
|
61
75
|
}
|
|
62
|
-
return
|
|
76
|
+
return sources.length > maxResults
|
|
77
|
+
? { sources: sources.slice(0, maxResults), truncated: true }
|
|
78
|
+
: { sources, truncated: false };
|
|
63
79
|
}
|
|
64
80
|
|
|
65
81
|
/** The DeepSeek-backed search backend; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
|
|
@@ -70,7 +86,9 @@ export class DeepSeekSearchProvider {
|
|
|
70
86
|
|
|
71
87
|
available() {
|
|
72
88
|
const options = this.resolveOptions();
|
|
73
|
-
|
|
89
|
+
const hasKey = (options.apiKey?.length ?? 0) > 0 || options.resolveApiKey !== void 0;
|
|
90
|
+
const canUseAccount = options.resolveAccountToken !== void 0;
|
|
91
|
+
return (hasKey || canUseAccount)
|
|
74
92
|
&& URL.canParse(options.baseURL)
|
|
75
93
|
&& Number.isInteger(options.maxTokens) && options.maxTokens > 0
|
|
76
94
|
&& Number.isInteger(options.maxUses) && options.maxUses > 0;
|
|
@@ -78,9 +96,26 @@ export class DeepSeekSearchProvider {
|
|
|
78
96
|
|
|
79
97
|
async search(request, signal) {
|
|
80
98
|
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
99
|
throwIfSearchAborted(signal);
|
|
83
100
|
const endpoint = `${options.baseURL.replace(/\/+$/u, "")}/messages`;
|
|
101
|
+
// An account-authorized search spends account quota and must not need a
|
|
102
|
+
// stored API key; every other search resolves `DEEPSEEK_API_KEY`.
|
|
103
|
+
let accountToken;
|
|
104
|
+
try {
|
|
105
|
+
accountToken = await abortable(Promise.resolve().then(() => options.resolveAccountToken?.(endpoint)), signal);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
108
|
+
throw new WebError(`DeepSeek account token resolution failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
109
|
+
}
|
|
110
|
+
throwIfSearchAborted(signal);
|
|
111
|
+
let authHeaders;
|
|
112
|
+
if (typeof accountToken === "string" && accountToken.length > 0) {
|
|
113
|
+
authHeaders = { "x-dsh-auth-token": accountToken };
|
|
114
|
+
} else {
|
|
115
|
+
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`);
|
|
116
|
+
authHeaders = { "x-api-key": apiKey, "authorization": `Bearer ${apiKey}` };
|
|
117
|
+
}
|
|
118
|
+
throwIfSearchAborted(signal);
|
|
84
119
|
const body = {
|
|
85
120
|
model: options.model,
|
|
86
121
|
max_tokens: options.maxTokens,
|
|
@@ -90,6 +125,9 @@ export class DeepSeekSearchProvider {
|
|
|
90
125
|
}],
|
|
91
126
|
tools: [{ type: "web_search_20250305", name: "web_search", max_uses: options.maxUses }]
|
|
92
127
|
};
|
|
128
|
+
// Record the exact secret-free request before it leaves; a throw here
|
|
129
|
+
// prevents dispatch rather than logging an unrecorded model input.
|
|
130
|
+
options.recordRequest?.({ endpoint, apiVersion: options.apiVersion, body });
|
|
93
131
|
throwIfSearchAborted(signal);
|
|
94
132
|
let response;
|
|
95
133
|
try {
|
|
@@ -97,8 +135,7 @@ export class DeepSeekSearchProvider {
|
|
|
97
135
|
method: "POST",
|
|
98
136
|
redirect: "error",
|
|
99
137
|
headers: {
|
|
100
|
-
|
|
101
|
-
"authorization": `Bearer ${apiKey}`,
|
|
138
|
+
...authHeaders,
|
|
102
139
|
"anthropic-version": options.apiVersion,
|
|
103
140
|
"content-type": "application/json",
|
|
104
141
|
"accept": "application/json",
|
|
@@ -123,7 +160,8 @@ export class DeepSeekSearchProvider {
|
|
|
123
160
|
throw new WebError(message, "WEB_PROVIDER_ERROR");
|
|
124
161
|
}
|
|
125
162
|
try {
|
|
126
|
-
|
|
163
|
+
const maxResults = Math.min(request.maxResults ?? MAX_RESULTS_CAP, options.maxResults ?? 8, MAX_RESULTS_CAP);
|
|
164
|
+
return mapAnthropicResponse(await response.json(), maxResults);
|
|
127
165
|
} catch (error) {
|
|
128
166
|
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
129
167
|
if (error instanceof WebError) throw error;
|
|
@@ -132,17 +170,35 @@ export class DeepSeekSearchProvider {
|
|
|
132
170
|
}
|
|
133
171
|
}
|
|
134
172
|
|
|
135
|
-
/**
|
|
173
|
+
/**
|
|
174
|
+
* Project the plugin section into DeepSeek backend options.
|
|
175
|
+
*
|
|
176
|
+
* `available()` accepts a search that can authenticate *either* way: a stored
|
|
177
|
+
* API key, an account-authorized session, or both.
|
|
178
|
+
* @param ctx - plugin context supplying the credential and environment planes.
|
|
179
|
+
* @param config - one detached section snapshot.
|
|
180
|
+
* @returns options for one search.
|
|
181
|
+
*/
|
|
136
182
|
export function resolveDeepseekOptions(ctx, config) {
|
|
137
183
|
return {
|
|
138
184
|
...resolveSecret(ctx, {
|
|
139
185
|
literal: config.deepseekApiKey,
|
|
140
186
|
envName: config.deepseekApiKeyEnv ?? DEEPSEEK_DEFAULT_API_KEY_ENV
|
|
141
187
|
}),
|
|
142
|
-
|
|
188
|
+
// Only a DeepSeek-account session may spend account quota; every other
|
|
189
|
+
// session falls back to the API key resolved above.
|
|
190
|
+
resolveAccountToken: async (endpoint) => {
|
|
191
|
+
if (ctx.get("agents")?.currentInitiator()?.session.requestContext()?.provider !== ACCOUNT_PROVIDER) return void 0;
|
|
192
|
+
return await ctx.get("deepseekAccount")?.resolveToken(endpoint);
|
|
193
|
+
},
|
|
194
|
+
recordRequest: (request) => {
|
|
195
|
+
ctx.get("agents")?.currentInitiator()?.session.append("web/deepseek-search-llm-request", request);
|
|
196
|
+
},
|
|
197
|
+
baseURL: config.deepseekBaseURL || launchEnvironmentOf(ctx).get(DEEPSEEK_SEARCH_BASE_URL_ENV)?.value || DEEPSEEK_DEFAULT_BASE_URL,
|
|
143
198
|
model: config.model ?? DEFAULT_MODEL,
|
|
144
199
|
apiVersion: config.apiVersion ?? DEFAULT_API_VERSION,
|
|
145
200
|
maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
146
|
-
maxUses: config.maxUses ?? DEFAULT_MAX_USES
|
|
201
|
+
maxUses: config.maxUses ?? DEFAULT_MAX_USES,
|
|
202
|
+
maxResults: config.maxResults ?? 8
|
|
147
203
|
};
|
|
148
204
|
}
|
package/lib/index.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Web-search provider plugin for the DeepSeek Harness web capability seam
|
|
3
3
|
* (`ctx.web`). Registers a single `WebSearchProvider` under the stable id
|
|
4
|
-
* `dsh-web-search` and dispatches each search to
|
|
5
|
-
*
|
|
4
|
+
* `dsh-web-search` and dispatches each search to one of nine backends
|
|
5
|
+
* according to the `provider` setting — so switching backends does
|
|
6
6
|
* not require a `web.searchProvider` patch change.
|
|
7
7
|
*
|
|
8
8
|
* Tavily supports `keyless` (free, rate-limited) and `keyed` modes. Brave
|
|
9
9
|
* always needs a subscription token (`BRAVE_API_KEY` by default). DeepSeek
|
|
10
10
|
* uses the Anthropic-compatible Messages API native `web_search_20250305`
|
|
11
|
-
* tool and
|
|
12
|
-
* plugin is disabled by the bundle patch).
|
|
11
|
+
* tool and authenticates with an account token or `DEEPSEEK_API_KEY` (the
|
|
12
|
+
* in-box `web-search-deepseek` host plugin is disabled by the bundle patch).
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
* the
|
|
14
|
+
* DeepSeek records a secret-free model request as a session event before
|
|
15
|
+
* dispatch; the REST backends do not write custom session events.
|
|
16
|
+
*
|
|
17
|
+
* Adapted to DeepSeek Harness `0.2.x`: configuration is read from `volatile()`
|
|
18
|
+
* Config accessors instead of the `settings.installSection()` callback pair
|
|
19
|
+
* removed in `0.2.0`.
|
|
16
20
|
*
|
|
17
21
|
* @module dsh-web-search-plugin
|
|
18
22
|
*/
|
|
@@ -35,86 +39,93 @@ const inject = ["web"];
|
|
|
35
39
|
/** Settings namespace carrying this plugin's backend switch and options. */
|
|
36
40
|
const WEB_SEARCH_SETTINGS_NAMESPACE = "dsh-web-search-plugin";
|
|
37
41
|
|
|
38
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Plugin config. Every field is `volatile()`: DeepSeek Harness `0.2.x` saves
|
|
44
|
+
* settings into the active Profile's plugin configuration and only reloads a
|
|
45
|
+
* running instance when a *non*-volatile field changes, so a volatile field can
|
|
46
|
+
* be edited from the settings form without restarting the plugin. The previous
|
|
47
|
+
* `settings.installSection()` + `setSource` mechanism was removed in `0.2.0`,
|
|
48
|
+
* which makes these accessors the only way to read live configuration.
|
|
49
|
+
*/
|
|
39
50
|
const Config = z.object({
|
|
40
|
-
/** Active backend: `deepseek-official` (default), `tavily`, `brave`, `serper`, `serpapi`, `exa`, or `
|
|
41
|
-
provider: z.string().default("deepseek-official"),
|
|
51
|
+
/** Active backend: `deepseek-official` (default), `tavily`, `brave`, `serper`, `serpapi`, `exa`, `searxng`, `scavio`, or `firecrawl`. */
|
|
52
|
+
provider: z.string().default("deepseek-official").volatile(),
|
|
42
53
|
/** Tavily auth mode: `keyless` (default) or `keyed`. */
|
|
43
|
-
mode: z.string().default("keyless"),
|
|
54
|
+
mode: z.string().default("keyless").volatile(),
|
|
44
55
|
/** Literal Tavily API key; prefer {@link apiKeyEnv}. */
|
|
45
|
-
apiKey: z.string().role("secret").default(""),
|
|
56
|
+
apiKey: z.string().role("secret").default("").volatile(),
|
|
46
57
|
/** Credential reference for keyed Tavily search. */
|
|
47
|
-
apiKeyEnv: z.string().role("credential-ref").default(TAVILY_DEFAULT_API_KEY_ENV),
|
|
58
|
+
apiKeyEnv: z.string().role("credential-ref").default(TAVILY_DEFAULT_API_KEY_ENV).volatile(),
|
|
48
59
|
/** Tavily REST API base. `/search` is appended. */
|
|
49
|
-
baseURL: z.string().default(
|
|
60
|
+
baseURL: z.string().default("").volatile(),
|
|
50
61
|
/** Upper bound on returned sources; both backends accept 1..20. */
|
|
51
|
-
maxResults: z.number().step(1).min(1).max(MAX_RESULTS_CAP).default(8),
|
|
62
|
+
maxResults: z.number().step(1).min(1).max(MAX_RESULTS_CAP).default(8).volatile(),
|
|
52
63
|
/** Tavily search depth: `basic` (default) or `advanced`. */
|
|
53
|
-
searchDepth: z.string().default("basic"),
|
|
64
|
+
searchDepth: z.string().default("basic").volatile(),
|
|
54
65
|
/** Request Tavily's generated answer text; surfaced as the result `content`. */
|
|
55
|
-
includeAnswer: z.boolean().default(true),
|
|
66
|
+
includeAnswer: z.boolean().default(true).volatile(),
|
|
56
67
|
/** Tavily topic: `general` (default) or `news`. */
|
|
57
|
-
topic: z.string().default("general"),
|
|
68
|
+
topic: z.string().default("general").volatile(),
|
|
58
69
|
/** Literal Brave subscription token; prefer {@link braveApiKeyEnv}. */
|
|
59
|
-
braveApiKey: z.string().role("secret").default(""),
|
|
70
|
+
braveApiKey: z.string().role("secret").default("").volatile(),
|
|
60
71
|
/** Credential reference for Brave search. */
|
|
61
|
-
braveApiKeyEnv: z.string().role("credential-ref").default(BRAVE_DEFAULT_API_KEY_ENV),
|
|
72
|
+
braveApiKeyEnv: z.string().role("credential-ref").default(BRAVE_DEFAULT_API_KEY_ENV).volatile(),
|
|
62
73
|
/** Brave Search API web-results endpoint. */
|
|
63
|
-
braveBaseURL: z.string().default(BRAVE_DEFAULT_BASE_URL),
|
|
74
|
+
braveBaseURL: z.string().default(BRAVE_DEFAULT_BASE_URL).volatile(),
|
|
64
75
|
/** Optional Brave `country` (ISO 2-letter, e.g. `cn`). */
|
|
65
|
-
country: z.string().default(""),
|
|
76
|
+
country: z.string().default("").volatile(),
|
|
66
77
|
/** Optional Brave `search_lang` (e.g. `zh-hans`). */
|
|
67
|
-
searchLang: z.string().default(""),
|
|
78
|
+
searchLang: z.string().default("").volatile(),
|
|
68
79
|
/** Optional Brave `freshness` (`pd` / `pw` / `pm` / `py`). */
|
|
69
|
-
freshness: z.string().default(""),
|
|
80
|
+
freshness: z.string().default("").volatile(),
|
|
70
81
|
/** Optional HTTP(S) proxy URL for Brave; blank inherits DSH's global proxy policy. */
|
|
71
|
-
proxy: z.string().default(""),
|
|
82
|
+
proxy: z.string().default("").volatile(),
|
|
72
83
|
/** Literal Serper API key; prefer {@link serperApiKeyEnv}. */
|
|
73
|
-
serperApiKey: z.string().role("secret").default(""),
|
|
84
|
+
serperApiKey: z.string().role("secret").default("").volatile(),
|
|
74
85
|
/** Credential reference for Serper search. */
|
|
75
|
-
serperApiKeyEnv: z.string().role("credential-ref").default("SERPER_API_KEY"),
|
|
86
|
+
serperApiKeyEnv: z.string().role("credential-ref").default("SERPER_API_KEY").volatile(),
|
|
76
87
|
/** Serper endpoint base. `/search` is appended. */
|
|
77
|
-
serperBaseURL: z.string().default("https://google.serper.dev"),
|
|
88
|
+
serperBaseURL: z.string().default("https://google.serper.dev").volatile(),
|
|
78
89
|
/** Literal SerpApi API key; prefer {@link serpapiApiKeyEnv}. */
|
|
79
|
-
serpapiApiKey: z.string().role("secret").default(""),
|
|
90
|
+
serpapiApiKey: z.string().role("secret").default("").volatile(),
|
|
80
91
|
/** Credential reference for SerpApi search. */
|
|
81
|
-
serpapiApiKeyEnv: z.string().role("credential-ref").default("SERPAPI_API_KEY"),
|
|
92
|
+
serpapiApiKeyEnv: z.string().role("credential-ref").default("SERPAPI_API_KEY").volatile(),
|
|
82
93
|
/** SerpApi endpoint base. `/search.json` is appended. */
|
|
83
|
-
serpapiBaseURL: z.string().default("https://serpapi.com"),
|
|
94
|
+
serpapiBaseURL: z.string().default("https://serpapi.com").volatile(),
|
|
84
95
|
/** Literal Exa API key; prefer {@link exaApiKeyEnv}. */
|
|
85
|
-
exaApiKey: z.string().role("secret").default(""),
|
|
96
|
+
exaApiKey: z.string().role("secret").default("").volatile(),
|
|
86
97
|
/** Credential reference for Exa search. */
|
|
87
|
-
exaApiKeyEnv: z.string().role("credential-ref").default("EXA_API_KEY"),
|
|
98
|
+
exaApiKeyEnv: z.string().role("credential-ref").default("EXA_API_KEY").volatile(),
|
|
88
99
|
/** Exa endpoint base. `/search` is appended. */
|
|
89
|
-
exaBaseURL: z.string().default("https://api.exa.ai"),
|
|
100
|
+
exaBaseURL: z.string().default("https://api.exa.ai").volatile(),
|
|
90
101
|
/** Optional SearXNG instance base URL (self-hosted). `/search` is appended. */
|
|
91
|
-
searxngBaseURL: z.string().default("https://searx.be"),
|
|
102
|
+
searxngBaseURL: z.string().default("https://searx.be").volatile(),
|
|
92
103
|
/** Literal Scavio API key; prefer {@link scavioApiKeyEnv}. */
|
|
93
|
-
scavioApiKey: z.string().role("secret").default(""),
|
|
104
|
+
scavioApiKey: z.string().role("secret").default("").volatile(),
|
|
94
105
|
/** Credential reference for Scavio search. */
|
|
95
|
-
scavioApiKeyEnv: z.string().role("credential-ref").default("SCAVIO_API_KEY"),
|
|
106
|
+
scavioApiKeyEnv: z.string().role("credential-ref").default("SCAVIO_API_KEY").volatile(),
|
|
96
107
|
/** Scavio endpoint base. `/api/v2/google` is appended. */
|
|
97
|
-
scavioBaseURL: z.string().default("https://api.scavio.dev"),
|
|
108
|
+
scavioBaseURL: z.string().default("https://api.scavio.dev").volatile(),
|
|
98
109
|
/** Literal Firecrawl API key; prefer {@link firecrawlApiKeyEnv}. */
|
|
99
|
-
firecrawlApiKey: z.string().role("secret").default(""),
|
|
110
|
+
firecrawlApiKey: z.string().role("secret").default("").volatile(),
|
|
100
111
|
/** Credential reference for Firecrawl search. */
|
|
101
|
-
firecrawlApiKeyEnv: z.string().role("credential-ref").default("FIRECRAWL_API_KEY"),
|
|
112
|
+
firecrawlApiKeyEnv: z.string().role("credential-ref").default("FIRECRAWL_API_KEY").volatile(),
|
|
102
113
|
/** Firecrawl endpoint base. `/v2/search` is appended. */
|
|
103
|
-
firecrawlBaseURL: z.string().default("https://api.firecrawl.dev"),
|
|
114
|
+
firecrawlBaseURL: z.string().default("https://api.firecrawl.dev").volatile(),
|
|
104
115
|
/** Literal DeepSeek API key; prefer {@link deepseekApiKeyEnv}. */
|
|
105
|
-
deepseekApiKey: z.string().role("secret").default(""),
|
|
116
|
+
deepseekApiKey: z.string().role("secret").default("").volatile(),
|
|
106
117
|
/** Credential reference for DeepSeek search. */
|
|
107
|
-
deepseekApiKeyEnv: z.string().role("credential-ref").default(DEEPSEEK_DEFAULT_API_KEY_ENV),
|
|
118
|
+
deepseekApiKeyEnv: z.string().role("credential-ref").default(DEEPSEEK_DEFAULT_API_KEY_ENV).volatile(),
|
|
108
119
|
/** DeepSeek Anthropic-compatible Messages base URL. `/messages` is appended. */
|
|
109
|
-
deepseekBaseURL: z.string().default(
|
|
120
|
+
deepseekBaseURL: z.string().default("").volatile(),
|
|
110
121
|
/** Anthropic-format model name for the Messages request. */
|
|
111
|
-
model: z.string().default("deepseek-v4-flash"),
|
|
122
|
+
model: z.string().default("deepseek-v4-flash").volatile(),
|
|
112
123
|
/** `anthropic-version` header value. */
|
|
113
|
-
apiVersion: z.string().default("2023-06-01"),
|
|
124
|
+
apiVersion: z.string().default("2023-06-01").volatile(),
|
|
114
125
|
/** Upper bound on generated tokens for the Messages request. */
|
|
115
|
-
maxTokens: z.number().step(1).min(1).default(4096),
|
|
126
|
+
maxTokens: z.number().step(1).min(1).default(4096).volatile(),
|
|
116
127
|
/** Maximum `web_search` server-tool uses per request. */
|
|
117
|
-
maxUses: z.number().step(1).min(1).default(5)
|
|
128
|
+
maxUses: z.number().step(1).min(1).default(5).volatile()
|
|
118
129
|
});
|
|
119
130
|
|
|
120
131
|
/**
|
|
@@ -128,18 +139,10 @@ class PluginSearchProvider {
|
|
|
128
139
|
constructor(resolveOptions, usage) {
|
|
129
140
|
this.resolveOptions = resolveOptions;
|
|
130
141
|
this.deepseek = new DeepSeekSearchProvider(() => this.resolveOptions().deepseek);
|
|
131
|
-
this.tavily = new TavilySearchProvider(() => ({
|
|
132
|
-
...this.resolveOptions().tavily,
|
|
133
|
-
onUsage: (credits) => usage?.recordTavilyCredits(credits)
|
|
134
|
-
}));
|
|
135
|
-
this.brave = new BraveSearchProvider(() => ({
|
|
136
|
-
...this.resolveOptions().brave,
|
|
137
|
-
onRateLimit: (headers) => usage?.recordBraveHeaders(headers)
|
|
138
|
-
}));
|
|
139
142
|
this.rest = new Map();
|
|
140
143
|
for (const providerId of BUILTIN_REST_IDS) {
|
|
141
144
|
this.rest.set(providerId, new RestSearchProvider(providerId, () => this.resolveOptions().rest[providerId], {
|
|
142
|
-
onUsage: providerId === "tavily" ? (credits) => usage?.recordTavilyCredits(credits) : void 0,
|
|
145
|
+
onUsage: providerId === "tavily" ? (credits, apiKey) => usage?.recordTavilyCredits(credits, apiKey) : void 0,
|
|
143
146
|
onRateLimit: providerId === "brave" ? (headers) => usage?.recordBraveHeaders(headers) : void 0
|
|
144
147
|
}));
|
|
145
148
|
}
|
|
@@ -168,12 +171,13 @@ class PluginSearchProvider {
|
|
|
168
171
|
function resolveOptions(ctx, config) {
|
|
169
172
|
const isBuiltIn = config.provider === "tavily" || config.provider === "brave" || config.provider === "deepseek-official" || BUILTIN_REST_IDS.includes(config.provider);
|
|
170
173
|
const provider = isBuiltIn ? config.provider : "deepseek-official";
|
|
174
|
+
const tavily = provider === "tavily" ? resolveTavilyOptions(ctx, config) : void 0;
|
|
171
175
|
// Per-provider literal key / optional endpoint override, used when the
|
|
172
176
|
// metadata row's default is not enough (self-hosted SearXNG, proxied
|
|
173
177
|
// providers, custom key refs).
|
|
174
178
|
const keySourceByProvider = {
|
|
175
179
|
brave: { literal: config.braveApiKey, envName: config.braveApiKeyEnv ?? BRAVE_DEFAULT_API_KEY_ENV, baseURL: config.braveBaseURL ?? "", proxy: config.proxy ?? "" },
|
|
176
|
-
tavily: { literal: config.apiKey, envName: config.apiKeyEnv ?? TAVILY_DEFAULT_API_KEY_ENV, baseURL:
|
|
180
|
+
tavily: { literal: config.apiKey, envName: config.apiKeyEnv ?? TAVILY_DEFAULT_API_KEY_ENV, baseURL: tavily?.baseURL ?? "" },
|
|
177
181
|
serper: { literal: config.serperApiKey, envName: config.serperApiKeyEnv ?? "SERPER_API_KEY", baseURL: config.serperBaseURL ?? "" },
|
|
178
182
|
serpapi: { literal: config.serpapiApiKey, envName: config.serpapiApiKeyEnv ?? "SERPAPI_API_KEY", baseURL: config.serpapiBaseURL ?? "" },
|
|
179
183
|
exa: { literal: config.exaApiKey, envName: config.exaApiKeyEnv ?? "EXA_API_KEY", baseURL: config.exaBaseURL ?? "" },
|
|
@@ -190,7 +194,7 @@ function resolveOptions(ctx, config) {
|
|
|
190
194
|
includeAnswer: config.includeAnswer
|
|
191
195
|
};
|
|
192
196
|
const rest = {};
|
|
193
|
-
for (const providerId of
|
|
197
|
+
for (const providerId of provider === "deepseek-official" ? [] : [provider]) {
|
|
194
198
|
const row = findProvider(providerId);
|
|
195
199
|
if (row === void 0) continue;
|
|
196
200
|
const source = keySourceByProvider[providerId] ?? {};
|
|
@@ -207,9 +211,9 @@ function resolveOptions(ctx, config) {
|
|
|
207
211
|
}
|
|
208
212
|
return {
|
|
209
213
|
provider,
|
|
210
|
-
deepseek: resolveDeepseekOptions(ctx, config),
|
|
211
|
-
tavily
|
|
212
|
-
brave: resolveBraveOptions(ctx, config),
|
|
214
|
+
deepseek: provider === "deepseek-official" ? resolveDeepseekOptions(ctx, config) : void 0,
|
|
215
|
+
tavily,
|
|
216
|
+
brave: provider === "brave" ? resolveBraveOptions(ctx, config) : void 0,
|
|
213
217
|
rest
|
|
214
218
|
};
|
|
215
219
|
}
|
|
@@ -246,18 +250,74 @@ function sendJson(res, statusCode, data) {
|
|
|
246
250
|
res.end(body);
|
|
247
251
|
}
|
|
248
252
|
|
|
249
|
-
/**
|
|
253
|
+
/**
|
|
254
|
+
* Read the live configuration section.
|
|
255
|
+
*
|
|
256
|
+
* Every Config field is `volatile()`, so `field.get()` always yields the value
|
|
257
|
+
* the settings form last wrote — replacing the `installSection` / `setSource`
|
|
258
|
+
* pair this plugin used on Harness `0.1.x`. Snapshotted once per operation by
|
|
259
|
+
* each backend's `resolveOptions`, so one search never mixes two sections.
|
|
260
|
+
* @param config - the plugin's volatile Config accessors.
|
|
261
|
+
* @returns one detached plain section.
|
|
262
|
+
*/
|
|
263
|
+
function readSection(config) {
|
|
264
|
+
return {
|
|
265
|
+
provider: config.provider.get(),
|
|
266
|
+
mode: config.mode.get(),
|
|
267
|
+
apiKey: config.apiKey.get(),
|
|
268
|
+
apiKeyEnv: config.apiKeyEnv.get(),
|
|
269
|
+
baseURL: config.baseURL.get(),
|
|
270
|
+
maxResults: config.maxResults.get(),
|
|
271
|
+
searchDepth: config.searchDepth.get(),
|
|
272
|
+
includeAnswer: config.includeAnswer.get(),
|
|
273
|
+
topic: config.topic.get(),
|
|
274
|
+
braveApiKey: config.braveApiKey.get(),
|
|
275
|
+
braveApiKeyEnv: config.braveApiKeyEnv.get(),
|
|
276
|
+
braveBaseURL: config.braveBaseURL.get(),
|
|
277
|
+
country: config.country.get(),
|
|
278
|
+
searchLang: config.searchLang.get(),
|
|
279
|
+
freshness: config.freshness.get(),
|
|
280
|
+
proxy: config.proxy.get(),
|
|
281
|
+
serperApiKey: config.serperApiKey.get(),
|
|
282
|
+
serperApiKeyEnv: config.serperApiKeyEnv.get(),
|
|
283
|
+
serperBaseURL: config.serperBaseURL.get(),
|
|
284
|
+
serpapiApiKey: config.serpapiApiKey.get(),
|
|
285
|
+
serpapiApiKeyEnv: config.serpapiApiKeyEnv.get(),
|
|
286
|
+
serpapiBaseURL: config.serpapiBaseURL.get(),
|
|
287
|
+
exaApiKey: config.exaApiKey.get(),
|
|
288
|
+
exaApiKeyEnv: config.exaApiKeyEnv.get(),
|
|
289
|
+
exaBaseURL: config.exaBaseURL.get(),
|
|
290
|
+
searxngBaseURL: config.searxngBaseURL.get(),
|
|
291
|
+
scavioApiKey: config.scavioApiKey.get(),
|
|
292
|
+
scavioApiKeyEnv: config.scavioApiKeyEnv.get(),
|
|
293
|
+
scavioBaseURL: config.scavioBaseURL.get(),
|
|
294
|
+
firecrawlApiKey: config.firecrawlApiKey.get(),
|
|
295
|
+
firecrawlApiKeyEnv: config.firecrawlApiKeyEnv.get(),
|
|
296
|
+
firecrawlBaseURL: config.firecrawlBaseURL.get(),
|
|
297
|
+
deepseekApiKey: config.deepseekApiKey.get(),
|
|
298
|
+
deepseekApiKeyEnv: config.deepseekApiKeyEnv.get(),
|
|
299
|
+
deepseekBaseURL: config.deepseekBaseURL.get(),
|
|
300
|
+
model: config.model.get(),
|
|
301
|
+
apiVersion: config.apiVersion.get(),
|
|
302
|
+
maxTokens: config.maxTokens.get(),
|
|
303
|
+
maxUses: config.maxUses.get()
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Register the dispatched search provider with `ctx.web`.
|
|
309
|
+
*
|
|
310
|
+
* Harness `0.2.x` saves plugin settings into the active Profile's plugin
|
|
311
|
+
* configuration and exposes them through `settings.describe()`; a plugin with
|
|
312
|
+
* no custom page gets an auto-generated one for free. This plugin keeps that
|
|
313
|
+
* default (it never calls `settings.configure({ auto: false })`) and edits the
|
|
314
|
+
* `dsh-web-search-plugin` namespace through the browser card in
|
|
315
|
+
* `lib/client.js`.
|
|
316
|
+
* @param ctx - plugin context.
|
|
317
|
+
* @param config - volatile Config accessors supplied by the Loader.
|
|
318
|
+
*/
|
|
250
319
|
function apply(ctx, config) {
|
|
251
|
-
|
|
252
|
-
ctx.inject(["settings"], (settingsCtx) => {
|
|
253
|
-
settingsCtx.settings.installSection(ctx, WEB_SEARCH_SETTINGS_NAMESPACE, Config, config, {
|
|
254
|
-
setSource: (source) => {
|
|
255
|
-
current = source;
|
|
256
|
-
},
|
|
257
|
-
onChange: () => {}
|
|
258
|
-
});
|
|
259
|
-
});
|
|
260
|
-
const live = () => resolveOptions(ctx, current());
|
|
320
|
+
const live = () => resolveOptions(ctx, readSection(config));
|
|
261
321
|
const usage = new UsageTracker({
|
|
262
322
|
getTavilyOptions: () => live().tavily
|
|
263
323
|
});
|
|
@@ -265,7 +325,7 @@ function apply(ctx, config) {
|
|
|
265
325
|
let cancelled = false;
|
|
266
326
|
void usage.hydrate().then(() => {
|
|
267
327
|
if (cancelled) return;
|
|
268
|
-
if (live().provider === "tavily" && live().tavily.mode === "keyed") return usage.refreshTavily(
|
|
328
|
+
if (live().provider === "tavily" && live().tavily.mode === "keyed") return usage.refreshTavily();
|
|
269
329
|
}).catch(() => {});
|
|
270
330
|
return () => {
|
|
271
331
|
cancelled = true;
|
|
@@ -293,9 +353,7 @@ function apply(ctx, config) {
|
|
|
293
353
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
294
354
|
const force = req.method === "POST" || url.searchParams.get("force") === "1" || url.searchParams.get("force") === "true";
|
|
295
355
|
const options = live();
|
|
296
|
-
|
|
297
|
-
const missingIdentity = tavilyState.plan == null && tavilyState.limit == null;
|
|
298
|
-
if ((force || missingIdentity) && options.provider === "tavily" && options.tavily.mode === "keyed") {
|
|
356
|
+
if (options.provider === "tavily" && options.tavily.mode === "keyed") {
|
|
299
357
|
try {
|
|
300
358
|
if (req.method === "POST") await readJsonBody(req).catch(() => ({}));
|
|
301
359
|
await usage.refreshTavily(undefined, force);
|
|
@@ -308,7 +366,7 @@ function apply(ctx, config) {
|
|
|
308
366
|
res.end();
|
|
309
367
|
return;
|
|
310
368
|
}
|
|
311
|
-
sendJson(res, 200, usage.serialize(options.provider, options.tavily
|
|
369
|
+
sendJson(res, 200, usage.serialize(options.provider, options.tavily?.mode));
|
|
312
370
|
}
|
|
313
371
|
}), "dsh-web-search-plugin: usage route");
|
|
314
372
|
});
|
package/lib/providers.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Static metadata table for every built-in REST web-search provider.
|
|
3
3
|
*
|
|
4
|
-
* This module is intentionally **data, not code**:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* This module is intentionally **data, not code**: a new REST provider needs
|
|
5
|
+
* one row here, plus configuration and a browser selector entry, with zero
|
|
6
|
+
* per-provider execution code. The generic backend in
|
|
7
7
|
* `lib/rest.js` reads a row and performs `method` / `path` / `queryParam` /
|
|
8
8
|
* `countParam` / `params` / `auth` / `response` / `hooks`.
|
|
9
9
|
*
|
|
@@ -118,13 +118,13 @@ export const REST_PROVIDERS = [
|
|
|
118
118
|
path: "/search",
|
|
119
119
|
queryIn: "body",
|
|
120
120
|
queryParam: "query",
|
|
121
|
-
countParam: "
|
|
121
|
+
countParam: "numResults",
|
|
122
122
|
auth: { type: "bearer" },
|
|
123
123
|
keyRequired: true,
|
|
124
124
|
apiKeyRef: "EXA_API_KEY",
|
|
125
125
|
response: "exa",
|
|
126
126
|
params: [
|
|
127
|
-
{ key: "contents", value:
|
|
127
|
+
{ key: "contents", value: { highlights: true }, when: "always" }
|
|
128
128
|
],
|
|
129
129
|
officialUrl: "https://dashboard.exa.ai/",
|
|
130
130
|
hooks: []
|
|
@@ -138,7 +138,7 @@ export const REST_PROVIDERS = [
|
|
|
138
138
|
path: "/search",
|
|
139
139
|
queryIn: "query",
|
|
140
140
|
queryParam: "q",
|
|
141
|
-
countParam: "
|
|
141
|
+
countParam: "",
|
|
142
142
|
auth: { type: "none" },
|
|
143
143
|
keyRequired: false,
|
|
144
144
|
apiKeyRef: "",
|
|
@@ -185,7 +185,7 @@ export const REST_PROVIDERS = [
|
|
|
185
185
|
apiKeyRef: "FIRECRAWL_API_KEY",
|
|
186
186
|
response: "firecrawl",
|
|
187
187
|
params: [
|
|
188
|
-
{ key: "sources", value: "web", when: "always" }
|
|
188
|
+
{ key: "sources", value: ["web"], when: "always" }
|
|
189
189
|
],
|
|
190
190
|
officialUrl: "https://firecrawl.dev/",
|
|
191
191
|
hooks: []
|
package/lib/rest.js
CHANGED
|
@@ -28,9 +28,11 @@ function str(value) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/** Resolve a `params[]` entry to `{ key, value }` or `null` (skip). */
|
|
31
|
-
/** Keep
|
|
31
|
+
/** Keep JSON booleans and arrays in their native form. */
|
|
32
32
|
function paramValue(value) {
|
|
33
33
|
if (typeof value === "boolean") return value;
|
|
34
|
+
if (Array.isArray(value)) return value;
|
|
35
|
+
if (value !== null && typeof value === "object") return value;
|
|
34
36
|
return value === void 0 ? "" : String(value);
|
|
35
37
|
}
|
|
36
38
|
|
|
@@ -171,9 +173,9 @@ export class RestSearchProvider {
|
|
|
171
173
|
const method = options.method === "GET" ? "GET" : "POST";
|
|
172
174
|
const queryIn = options.queryIn === "query" ? "query" : "body";
|
|
173
175
|
const queryParam = str(options.queryParam) || "query";
|
|
174
|
-
const countParam = str(options.countParam) || "max_results";
|
|
176
|
+
const countParam = options.countParam === "" ? "" : str(options.countParam) || "max_results";
|
|
175
177
|
const responseHint = str(options.response) || "tavily";
|
|
176
|
-
const count = Math.min(request.maxResults ?? options.maxResults ?? 8, MAX_RESULTS_CAP);
|
|
178
|
+
const count = Math.min(request.maxResults ?? MAX_RESULTS_CAP, options.maxResults ?? 8, MAX_RESULTS_CAP);
|
|
177
179
|
|
|
178
180
|
let apiKey;
|
|
179
181
|
if (auth.type !== "none") {
|
|
@@ -259,9 +261,12 @@ export class RestSearchProvider {
|
|
|
259
261
|
const json = await httpResponse.json();
|
|
260
262
|
if (responseHint === "tavily" && typeof this.hooks.onUsage === "function") {
|
|
261
263
|
const credits = Number(json?.usage?.credits);
|
|
262
|
-
if (Number.isFinite(credits) && credits >= 0) this.hooks.onUsage(credits);
|
|
264
|
+
if (Number.isFinite(credits) && credits >= 0) this.hooks.onUsage(credits, apiKey);
|
|
263
265
|
}
|
|
264
|
-
|
|
266
|
+
const result = mapCustomResponse(json, responseHint);
|
|
267
|
+
return result.sources.length > count
|
|
268
|
+
? { ...result, sources: result.sources.slice(0, count), truncated: true }
|
|
269
|
+
: result;
|
|
265
270
|
} catch (error) {
|
|
266
271
|
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
267
272
|
if (error instanceof WebError) throw error;
|
package/lib/shared.js
CHANGED
|
@@ -7,7 +7,7 @@ import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
|
|
|
7
7
|
import { WebError } from "@deepseek-ai/dsh-web";
|
|
8
8
|
|
|
9
9
|
/** Attribution header sent on every request. */
|
|
10
|
-
export const USER_AGENT = "dsh-web-search-plugin/0.
|
|
10
|
+
export const USER_AGENT = "dsh-web-search-plugin/0.5.0";
|
|
11
11
|
/** Shared cap: Tavily `max_results` and Brave `count` both accept 1..20. */
|
|
12
12
|
export const MAX_RESULTS_CAP = 20;
|
|
13
13
|
|
package/lib/tavily.js
CHANGED
|
@@ -71,7 +71,7 @@ export class TavilySearchProvider {
|
|
|
71
71
|
const keyed = options.mode === "keyed";
|
|
72
72
|
const body = {
|
|
73
73
|
query: request.query,
|
|
74
|
-
max_results: Math.min(request.maxResults ?? options.maxResults, MAX_RESULTS_CAP),
|
|
74
|
+
max_results: Math.min(request.maxResults ?? MAX_RESULTS_CAP, options.maxResults ?? 8, MAX_RESULTS_CAP),
|
|
75
75
|
search_depth: options.searchDepth,
|
|
76
76
|
topic: options.topic,
|
|
77
77
|
include_answer: options.includeAnswer === true,
|
|
@@ -131,7 +131,7 @@ export function resolveTavilyOptions(ctx, config) {
|
|
|
131
131
|
literal: config.apiKey,
|
|
132
132
|
envName: config.apiKeyEnv ?? TAVILY_DEFAULT_API_KEY_ENV
|
|
133
133
|
}),
|
|
134
|
-
baseURL: config.baseURL
|
|
134
|
+
baseURL: config.baseURL || launchEnvironmentOf(ctx).get(TAVILY_BASE_URL_ENV)?.value || TAVILY_DEFAULT_BASE_URL,
|
|
135
135
|
maxResults: config.maxResults ?? 8,
|
|
136
136
|
searchDepth: config.searchDepth ?? "basic",
|
|
137
137
|
includeAnswer: config.includeAnswer ?? true,
|