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/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,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 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
  */
@@ -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
- /** 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
+ */
39
50
  const Config = z.object({
40
- /** Active backend: `deepseek-official` (default), `tavily`, `brave`, `serper`, `serpapi`, `exa`, or `searxng`. */
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(TAVILY_DEFAULT_BASE_URL),
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(DEEPSEEK_DEFAULT_BASE_URL),
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: config.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 BUILTIN_REST_IDS) {
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: resolveTavilyOptions(ctx, config),
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
- /** 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
+ */
250
319
  function apply(ctx, config) {
251
- let current = () => config;
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(undefined, true);
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
- const tavilyState = usage.snapshot().tavily;
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.mode));
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**: 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.1";
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,