dsh-web-search-plugin 0.4.0 → 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/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 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).
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 { sources, truncated: false };
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
- return ((options.apiKey?.length ?? 0) > 0 || options.resolveApiKey !== void 0)
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
- "x-api-key": apiKey,
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
- return mapAnthropicResponse(await response.json());
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
- /** Project the plugin section into DeepSeek backend options. */
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
- baseURL: config.deepseekBaseURL ?? launchEnvironmentOf(ctx).get(DEEPSEEK_SEARCH_BASE_URL_ENV)?.value ?? DEEPSEEK_DEFAULT_BASE_URL,
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,23 +1,26 @@
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 DeepSeek (official), Tavily,
5
- * or Brave according to the `provider` setting — so switching backends does
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 reuses `DEEPSEEK_API_KEY` (the in-box `web-search-deepseek` host
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
- * No backend writes custom session events; tool results already go through
15
- * the seam's own events.
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
  */
19
23
  import z from "@deepseek-ai/schemastery";
20
- import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
21
24
  import { MAX_RESULTS_CAP } from "./shared.js";
22
25
  import { TAVILY_DEFAULT_API_KEY_ENV, TAVILY_DEFAULT_BASE_URL, TavilySearchProvider, resolveTavilyOptions } from "./tavily.js";
23
26
  import { BRAVE_DEFAULT_API_KEY_ENV, BRAVE_DEFAULT_BASE_URL, BraveSearchProvider, resolveBraveOptions } from "./brave.js";
@@ -36,86 +39,93 @@ const inject = ["web"];
36
39
  /** Settings namespace carrying this plugin's backend switch and options. */
37
40
  const WEB_SEARCH_SETTINGS_NAMESPACE = "dsh-web-search-plugin";
38
41
 
39
- /** Plugin config (all optional — `apply` fills defaults). */
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
+ */
40
50
  const Config = z.object({
41
- /** Active backend: `deepseek-official` (default), `tavily`, `brave`, `serper`, `serpapi`, `exa`, or `searxng`. */
42
- 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(),
43
53
  /** Tavily auth mode: `keyless` (default) or `keyed`. */
44
- mode: z.string().default("keyless"),
54
+ mode: z.string().default("keyless").volatile(),
45
55
  /** Literal Tavily API key; prefer {@link apiKeyEnv}. */
46
- apiKey: z.string().role("secret").default(""),
56
+ apiKey: z.string().role("secret").default("").volatile(),
47
57
  /** Credential reference for keyed Tavily search. */
48
- 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(),
49
59
  /** Tavily REST API base. `/search` is appended. */
50
- baseURL: z.string().default(TAVILY_DEFAULT_BASE_URL),
60
+ baseURL: z.string().default("").volatile(),
51
61
  /** Upper bound on returned sources; both backends accept 1..20. */
52
- 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(),
53
63
  /** Tavily search depth: `basic` (default) or `advanced`. */
54
- searchDepth: z.string().default("basic"),
64
+ searchDepth: z.string().default("basic").volatile(),
55
65
  /** Request Tavily's generated answer text; surfaced as the result `content`. */
56
- includeAnswer: z.boolean().default(true),
66
+ includeAnswer: z.boolean().default(true).volatile(),
57
67
  /** Tavily topic: `general` (default) or `news`. */
58
- topic: z.string().default("general"),
68
+ topic: z.string().default("general").volatile(),
59
69
  /** Literal Brave subscription token; prefer {@link braveApiKeyEnv}. */
60
- braveApiKey: z.string().role("secret").default(""),
70
+ braveApiKey: z.string().role("secret").default("").volatile(),
61
71
  /** Credential reference for Brave search. */
62
- 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(),
63
73
  /** Brave Search API web-results endpoint. */
64
- braveBaseURL: z.string().default(BRAVE_DEFAULT_BASE_URL),
74
+ braveBaseURL: z.string().default(BRAVE_DEFAULT_BASE_URL).volatile(),
65
75
  /** Optional Brave `country` (ISO 2-letter, e.g. `cn`). */
66
- country: z.string().default(""),
76
+ country: z.string().default("").volatile(),
67
77
  /** Optional Brave `search_lang` (e.g. `zh-hans`). */
68
- searchLang: z.string().default(""),
78
+ searchLang: z.string().default("").volatile(),
69
79
  /** Optional Brave `freshness` (`pd` / `pw` / `pm` / `py`). */
70
- freshness: z.string().default(""),
71
- /** Optional HTTP(S) proxy URL for Brave (falls back to HTTPS_PROXY / HTTP_PROXY). */
72
- proxy: z.string().default(""),
80
+ freshness: z.string().default("").volatile(),
81
+ /** Optional HTTP(S) proxy URL for Brave; blank inherits DSH's global proxy policy. */
82
+ proxy: z.string().default("").volatile(),
73
83
  /** Literal Serper API key; prefer {@link serperApiKeyEnv}. */
74
- serperApiKey: z.string().role("secret").default(""),
84
+ serperApiKey: z.string().role("secret").default("").volatile(),
75
85
  /** Credential reference for Serper search. */
76
- serperApiKeyEnv: z.string().role("credential-ref").default("SERPER_API_KEY"),
86
+ serperApiKeyEnv: z.string().role("credential-ref").default("SERPER_API_KEY").volatile(),
77
87
  /** Serper endpoint base. `/search` is appended. */
78
- serperBaseURL: z.string().default("https://google.serper.dev"),
88
+ serperBaseURL: z.string().default("https://google.serper.dev").volatile(),
79
89
  /** Literal SerpApi API key; prefer {@link serpapiApiKeyEnv}. */
80
- serpapiApiKey: z.string().role("secret").default(""),
90
+ serpapiApiKey: z.string().role("secret").default("").volatile(),
81
91
  /** Credential reference for SerpApi search. */
82
- serpapiApiKeyEnv: z.string().role("credential-ref").default("SERPAPI_API_KEY"),
92
+ serpapiApiKeyEnv: z.string().role("credential-ref").default("SERPAPI_API_KEY").volatile(),
83
93
  /** SerpApi endpoint base. `/search.json` is appended. */
84
- serpapiBaseURL: z.string().default("https://serpapi.com"),
94
+ serpapiBaseURL: z.string().default("https://serpapi.com").volatile(),
85
95
  /** Literal Exa API key; prefer {@link exaApiKeyEnv}. */
86
- exaApiKey: z.string().role("secret").default(""),
96
+ exaApiKey: z.string().role("secret").default("").volatile(),
87
97
  /** Credential reference for Exa search. */
88
- exaApiKeyEnv: z.string().role("credential-ref").default("EXA_API_KEY"),
98
+ exaApiKeyEnv: z.string().role("credential-ref").default("EXA_API_KEY").volatile(),
89
99
  /** Exa endpoint base. `/search` is appended. */
90
- exaBaseURL: z.string().default("https://api.exa.ai"),
100
+ exaBaseURL: z.string().default("https://api.exa.ai").volatile(),
91
101
  /** Optional SearXNG instance base URL (self-hosted). `/search` is appended. */
92
- searxngBaseURL: z.string().default("https://searx.be"),
102
+ searxngBaseURL: z.string().default("https://searx.be").volatile(),
93
103
  /** Literal Scavio API key; prefer {@link scavioApiKeyEnv}. */
94
- scavioApiKey: z.string().role("secret").default(""),
104
+ scavioApiKey: z.string().role("secret").default("").volatile(),
95
105
  /** Credential reference for Scavio search. */
96
- scavioApiKeyEnv: z.string().role("credential-ref").default("SCAVIO_API_KEY"),
106
+ scavioApiKeyEnv: z.string().role("credential-ref").default("SCAVIO_API_KEY").volatile(),
97
107
  /** Scavio endpoint base. `/api/v2/google` is appended. */
98
- scavioBaseURL: z.string().default("https://api.scavio.dev"),
108
+ scavioBaseURL: z.string().default("https://api.scavio.dev").volatile(),
99
109
  /** Literal Firecrawl API key; prefer {@link firecrawlApiKeyEnv}. */
100
- firecrawlApiKey: z.string().role("secret").default(""),
110
+ firecrawlApiKey: z.string().role("secret").default("").volatile(),
101
111
  /** Credential reference for Firecrawl search. */
102
- firecrawlApiKeyEnv: z.string().role("credential-ref").default("FIRECRAWL_API_KEY"),
112
+ firecrawlApiKeyEnv: z.string().role("credential-ref").default("FIRECRAWL_API_KEY").volatile(),
103
113
  /** Firecrawl endpoint base. `/v2/search` is appended. */
104
- firecrawlBaseURL: z.string().default("https://api.firecrawl.dev"),
114
+ firecrawlBaseURL: z.string().default("https://api.firecrawl.dev").volatile(),
105
115
  /** Literal DeepSeek API key; prefer {@link deepseekApiKeyEnv}. */
106
- deepseekApiKey: z.string().role("secret").default(""),
116
+ deepseekApiKey: z.string().role("secret").default("").volatile(),
107
117
  /** Credential reference for DeepSeek search. */
108
- 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(),
109
119
  /** DeepSeek Anthropic-compatible Messages base URL. `/messages` is appended. */
110
- deepseekBaseURL: z.string().default(DEEPSEEK_DEFAULT_BASE_URL),
120
+ deepseekBaseURL: z.string().default("").volatile(),
111
121
  /** Anthropic-format model name for the Messages request. */
112
- model: z.string().default("deepseek-v4-flash"),
122
+ model: z.string().default("deepseek-v4-flash").volatile(),
113
123
  /** `anthropic-version` header value. */
114
- apiVersion: z.string().default("2023-06-01"),
124
+ apiVersion: z.string().default("2023-06-01").volatile(),
115
125
  /** Upper bound on generated tokens for the Messages request. */
116
- maxTokens: z.number().step(1).min(1).default(4096),
126
+ maxTokens: z.number().step(1).min(1).default(4096).volatile(),
117
127
  /** Maximum `web_search` server-tool uses per request. */
118
- maxUses: z.number().step(1).min(1).default(5)
128
+ maxUses: z.number().step(1).min(1).default(5).volatile()
119
129
  });
120
130
 
121
131
  /**
@@ -129,18 +139,10 @@ class PluginSearchProvider {
129
139
  constructor(resolveOptions, usage) {
130
140
  this.resolveOptions = resolveOptions;
131
141
  this.deepseek = new DeepSeekSearchProvider(() => this.resolveOptions().deepseek);
132
- this.tavily = new TavilySearchProvider(() => ({
133
- ...this.resolveOptions().tavily,
134
- onUsage: (credits) => usage?.recordTavilyCredits(credits)
135
- }));
136
- this.brave = new BraveSearchProvider(() => ({
137
- ...this.resolveOptions().brave,
138
- onRateLimit: (headers) => usage?.recordBraveHeaders(headers)
139
- }));
140
142
  this.rest = new Map();
141
143
  for (const providerId of BUILTIN_REST_IDS) {
142
144
  this.rest.set(providerId, new RestSearchProvider(providerId, () => this.resolveOptions().rest[providerId], {
143
- onUsage: providerId === "tavily" ? (credits) => usage?.recordTavilyCredits(credits) : void 0,
145
+ onUsage: providerId === "tavily" ? (credits, apiKey) => usage?.recordTavilyCredits(credits, apiKey) : void 0,
144
146
  onRateLimit: providerId === "brave" ? (headers) => usage?.recordBraveHeaders(headers) : void 0
145
147
  }));
146
148
  }
@@ -169,12 +171,13 @@ class PluginSearchProvider {
169
171
  function resolveOptions(ctx, config) {
170
172
  const isBuiltIn = config.provider === "tavily" || config.provider === "brave" || config.provider === "deepseek-official" || BUILTIN_REST_IDS.includes(config.provider);
171
173
  const provider = isBuiltIn ? config.provider : "deepseek-official";
174
+ const tavily = provider === "tavily" ? resolveTavilyOptions(ctx, config) : void 0;
172
175
  // Per-provider literal key / optional endpoint override, used when the
173
176
  // metadata row's default is not enough (self-hosted SearXNG, proxied
174
177
  // providers, custom key refs).
175
178
  const keySourceByProvider = {
176
- brave: { literal: config.braveApiKey, envName: config.braveApiKeyEnv ?? BRAVE_DEFAULT_API_KEY_ENV, baseURL: config.braveBaseURL ?? "", proxy: config.proxy || launchEnvironmentOf(ctx).get("HTTPS_PROXY")?.value || launchEnvironmentOf(ctx).get("HTTP_PROXY")?.value || "" },
177
- tavily: { literal: config.apiKey, envName: config.apiKeyEnv ?? TAVILY_DEFAULT_API_KEY_ENV, baseURL: config.baseURL ?? "" },
179
+ brave: { literal: config.braveApiKey, envName: config.braveApiKeyEnv ?? BRAVE_DEFAULT_API_KEY_ENV, baseURL: config.braveBaseURL ?? "", proxy: config.proxy ?? "" },
180
+ tavily: { literal: config.apiKey, envName: config.apiKeyEnv ?? TAVILY_DEFAULT_API_KEY_ENV, baseURL: tavily?.baseURL ?? "" },
178
181
  serper: { literal: config.serperApiKey, envName: config.serperApiKeyEnv ?? "SERPER_API_KEY", baseURL: config.serperBaseURL ?? "" },
179
182
  serpapi: { literal: config.serpapiApiKey, envName: config.serpapiApiKeyEnv ?? "SERPAPI_API_KEY", baseURL: config.serpapiBaseURL ?? "" },
180
183
  exa: { literal: config.exaApiKey, envName: config.exaApiKeyEnv ?? "EXA_API_KEY", baseURL: config.exaBaseURL ?? "" },
@@ -191,7 +194,7 @@ function resolveOptions(ctx, config) {
191
194
  includeAnswer: config.includeAnswer
192
195
  };
193
196
  const rest = {};
194
- for (const providerId of BUILTIN_REST_IDS) {
197
+ for (const providerId of provider === "deepseek-official" ? [] : [provider]) {
195
198
  const row = findProvider(providerId);
196
199
  if (row === void 0) continue;
197
200
  const source = keySourceByProvider[providerId] ?? {};
@@ -208,9 +211,9 @@ function resolveOptions(ctx, config) {
208
211
  }
209
212
  return {
210
213
  provider,
211
- deepseek: resolveDeepseekOptions(ctx, config),
212
- tavily: resolveTavilyOptions(ctx, config),
213
- 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,
214
217
  rest
215
218
  };
216
219
  }
@@ -247,18 +250,74 @@ function sendJson(res, statusCode, data) {
247
250
  res.end(body);
248
251
  }
249
252
 
250
- /** Register the dispatched search provider with `ctx.web`. */
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
+ */
251
319
  function apply(ctx, config) {
252
- let current = () => config;
253
- ctx.inject(["settings"], (settingsCtx) => {
254
- settingsCtx.settings.installSection(ctx, WEB_SEARCH_SETTINGS_NAMESPACE, Config, config, {
255
- setSource: (source) => {
256
- current = source;
257
- },
258
- onChange: () => {}
259
- });
260
- });
261
- const live = () => resolveOptions(ctx, current());
320
+ const live = () => resolveOptions(ctx, readSection(config));
262
321
  const usage = new UsageTracker({
263
322
  getTavilyOptions: () => live().tavily
264
323
  });
@@ -266,7 +325,7 @@ function apply(ctx, config) {
266
325
  let cancelled = false;
267
326
  void usage.hydrate().then(() => {
268
327
  if (cancelled) return;
269
- if (live().provider === "tavily" && live().tavily.mode === "keyed") return usage.refreshTavily(undefined, true);
328
+ if (live().provider === "tavily" && live().tavily.mode === "keyed") return usage.refreshTavily();
270
329
  }).catch(() => {});
271
330
  return () => {
272
331
  cancelled = true;
@@ -294,9 +353,7 @@ function apply(ctx, config) {
294
353
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
295
354
  const force = req.method === "POST" || url.searchParams.get("force") === "1" || url.searchParams.get("force") === "true";
296
355
  const options = live();
297
- const tavilyState = usage.snapshot().tavily;
298
- const missingIdentity = tavilyState.plan == null && tavilyState.limit == null;
299
- if ((force || missingIdentity) && options.provider === "tavily" && options.tavily.mode === "keyed") {
356
+ if (options.provider === "tavily" && options.tavily.mode === "keyed") {
300
357
  try {
301
358
  if (req.method === "POST") await readJsonBody(req).catch(() => ({}));
302
359
  await usage.refreshTavily(undefined, force);
@@ -309,7 +366,7 @@ function apply(ctx, config) {
309
366
  res.end();
310
367
  return;
311
368
  }
312
- sendJson(res, 200, usage.serialize(options.provider, options.tavily.mode));
369
+ sendJson(res, 200, usage.serialize(options.provider, options.tavily?.mode));
313
370
  }
314
371
  }), "dsh-web-search-plugin: usage route");
315
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**: adding a new REST provider
5
- * means adding one row here (plus an optional official "get a key" link in the
6
- * settings card), with zero per-provider execution code. The generic backend in
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: "num_results",
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: "text", when: "always" }
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: "count",
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 the param's native type: booleans stay booleans (JSON true/false). */
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
- return mapCustomResponse(json, responseHint);
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.4.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 ?? launchEnvironmentOf(ctx).get(TAVILY_BASE_URL_ENV)?.value ?? TAVILY_DEFAULT_BASE_URL,
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,