dsh-web-search-plugin 0.1.2 → 0.2.1

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.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * DeepSeek search backend: the Anthropic-compatible Messages API with the
3
+ * native `web_search_20250305` server tool. Each search costs a model turn
4
+ * and returns structured `web_search_tool_result` blocks; absence of those
5
+ * blocks is an error rather than a prose-scraping fallback.
6
+ *
7
+ * This plugin re-hosts the `deepseek-official` provider so the DeepSeek
8
+ * configuration can sit beside Tavily and Brave. The bundle patch disables
9
+ * the in-box `web-search-deepseek` host plugin (its namespace feed the legacy
10
+ * "plugins → web search" card, and re-registering its provider would collide).
11
+ *
12
+ * @module dsh-web-search-plugin/deepseek
13
+ */
14
+ import { WebError } from "@deepseek-ai/dsh-web";
15
+ import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
16
+ import { MAX_RESULTS_CAP, USER_AGENT, isAbortError, resolveApiKey, resolveSecret, searchAborted, throwIfSearchAborted } from "./shared.js";
17
+
18
+ /** Default endpoint: DeepSeek's Anthropic-compatible API (`/messages` appended). */
19
+ export const DEEPSEEK_DEFAULT_BASE_URL = "https://api.deepseek.com/anthropic/v1";
20
+ /** Default credential reference for this backend. */
21
+ export const DEEPSEEK_DEFAULT_API_KEY_ENV = "DEEPSEEK_API_KEY";
22
+ /** Environment variable naming this backend's endpoint override. */
23
+ const DEEPSEEK_SEARCH_BASE_URL_ENV = "DEEPSEEK_SEARCH_BASE_URL";
24
+ const DEFAULT_MODEL = "deepseek-v4-flash";
25
+ const DEFAULT_API_VERSION = "2023-06-01";
26
+ const DEFAULT_MAX_TOKENS = 4096;
27
+ const DEFAULT_MAX_USES = 5;
28
+
29
+ /**
30
+ * Build a `url → cited_text` map from `text` blocks' `citations[]` (the snippet
31
+ * source for Anthropic `web_search_result` items, which carry url/title but no
32
+ * inline excerpt).
33
+ */
34
+ function citationSnippets(blocks) {
35
+ const map = /* @__PURE__ */ new Map();
36
+ for (const block of blocks) {
37
+ if (block.type !== "text") continue;
38
+ for (const cite of block.citations ?? []) if (cite.url != null && cite.url.length > 0 && cite.cited_text != null && cite.cited_text.length > 0 && !map.has(cite.url)) map.set(cite.url, cite.cited_text);
39
+ }
40
+ return map;
41
+ }
42
+
43
+ /** Map an Anthropic Messages response to a normalized search result. */
44
+ function mapAnthropicResponse(response) {
45
+ const blocks = response.content ?? [];
46
+ const resultBlocks = blocks.filter((block) => block.type === "web_search_tool_result");
47
+ if (resultBlocks.length === 0) throw new WebError("DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search", "WEB_PROVIDER_ERROR");
48
+ const snippets = citationSnippets(blocks);
49
+ const seen = /* @__PURE__ */ new Set();
50
+ const sources = [];
51
+ for (const block of resultBlocks) for (const item of block.content ?? []) {
52
+ if (item.type !== "web_search_result" || typeof item.url !== "string" || item.url.length === 0 || seen.has(item.url)) continue;
53
+ seen.add(item.url);
54
+ const snippet = snippets.get(item.url);
55
+ sources.push({
56
+ url: item.url,
57
+ ...typeof item.title === "string" && item.title.length > 0 ? { title: item.title } : {},
58
+ ...snippet !== void 0 ? { snippet } : {},
59
+ ...typeof item.page_age === "string" && item.page_age.length > 0 ? { publishedAt: item.page_age } : {}
60
+ });
61
+ }
62
+ return { sources, truncated: false };
63
+ }
64
+
65
+ /** The DeepSeek-backed search backend; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
66
+ export class DeepSeekSearchProvider {
67
+ constructor(resolveOptions) {
68
+ this.resolveOptions = resolveOptions;
69
+ }
70
+
71
+ available() {
72
+ const options = this.resolveOptions();
73
+ return ((options.apiKey?.length ?? 0) > 0 || options.resolveApiKey !== void 0)
74
+ && URL.canParse(options.baseURL)
75
+ && Number.isInteger(options.maxTokens) && options.maxTokens > 0
76
+ && Number.isInteger(options.maxUses) && options.maxUses > 0;
77
+ }
78
+
79
+ async search(request, signal) {
80
+ const options = this.resolveOptions();
81
+ const apiKey = await resolveApiKey(options, signal, `DeepSeek search has no API key for "${options.apiKeyEnv ?? DEEPSEEK_DEFAULT_API_KEY_ENV}"; store it through the credentials service, export it in the launching environment, or set a literal "deepseekApiKey" in the dsh-web-search-plugin config`);
82
+ throwIfSearchAborted(signal);
83
+ const endpoint = `${options.baseURL.replace(/\/+$/u, "")}/messages`;
84
+ const body = {
85
+ model: options.model,
86
+ max_tokens: options.maxTokens,
87
+ messages: [{
88
+ role: "user",
89
+ content: [{ type: "text", text: `Perform a web search for the query: ${request.query}` }]
90
+ }],
91
+ tools: [{ type: "web_search_20250305", name: "web_search", max_uses: options.maxUses }]
92
+ };
93
+ throwIfSearchAborted(signal);
94
+ let response;
95
+ try {
96
+ response = await fetch(endpoint, {
97
+ method: "POST",
98
+ redirect: "error",
99
+ headers: {
100
+ "x-api-key": apiKey,
101
+ "authorization": `Bearer ${apiKey}`,
102
+ "anthropic-version": options.apiVersion,
103
+ "content-type": "application/json",
104
+ "accept": "application/json",
105
+ "user-agent": USER_AGENT
106
+ },
107
+ body: JSON.stringify(body),
108
+ ...signal !== void 0 ? { signal } : {}
109
+ });
110
+ } catch (error) {
111
+ if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
112
+ throw new WebError(`DeepSeek search request failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
113
+ }
114
+ if (!response.ok) {
115
+ let message = `DeepSeek API error (HTTP ${response.status})`;
116
+ try {
117
+ const parsed = await response.json();
118
+ const detail = typeof parsed.error === "string" ? parsed.error : parsed.error?.message ?? parsed.message;
119
+ if (typeof detail === "string" && detail.length > 0) message = detail;
120
+ } catch {
121
+ /* non-JSON error body — keep the status-line message */
122
+ }
123
+ throw new WebError(message, "WEB_PROVIDER_ERROR");
124
+ }
125
+ try {
126
+ return mapAnthropicResponse(await response.json());
127
+ } catch (error) {
128
+ if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
129
+ if (error instanceof WebError) throw error;
130
+ throw new WebError(`DeepSeek returned an unprocessable response body: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
131
+ }
132
+ }
133
+ }
134
+
135
+ /** Project the plugin section into DeepSeek backend options. */
136
+ export function resolveDeepseekOptions(ctx, config) {
137
+ return {
138
+ ...resolveSecret(ctx, {
139
+ literal: config.deepseekApiKey,
140
+ envName: config.deepseekApiKeyEnv ?? DEEPSEEK_DEFAULT_API_KEY_ENV
141
+ }),
142
+ baseURL: config.deepseekBaseURL ?? launchEnvironmentOf(ctx).get(DEEPSEEK_SEARCH_BASE_URL_ENV)?.value ?? DEEPSEEK_DEFAULT_BASE_URL,
143
+ model: config.model ?? DEFAULT_MODEL,
144
+ apiVersion: config.apiVersion ?? DEFAULT_API_VERSION,
145
+ maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
146
+ maxUses: config.maxUses ?? DEFAULT_MAX_USES
147
+ };
148
+ }
package/lib/index.js CHANGED
@@ -1,17 +1,18 @@
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 Tavily or Brave according
5
- * to the `provider` setting — so switching backends does not require a
6
- * `web.searchProvider` patch change.
4
+ * `dsh-web-search` and dispatches each search to DeepSeek (official), Tavily,
5
+ * or Brave according to the `provider` setting — so switching backends does
6
+ * not require a `web.searchProvider` patch change.
7
7
  *
8
- * Tavily supports `keyless` (free, rate-limited) and `keyed` modes.
9
- * Brave always needs a subscription token (`BRAVE_API_KEY` by default).
8
+ * Tavily supports `keyless` (free, rate-limited) and `keyed` modes. Brave
9
+ * always needs a subscription token (`BRAVE_API_KEY` by default). DeepSeek
10
+ * uses the Anthropic-compatible Messages API native `web_search_20250305`
11
+ * tool and reuses `DEEPSEEK_API_KEY` (the in-box `web-search-deepseek` host
12
+ * plugin is disabled by the bundle patch).
10
13
  *
11
- * Neither backend writes custom session events. The community Brave plugin
12
- * used to append `web/brave-search-request`, which is outside DSH's known
13
- * event vocabulary and cannot be marked `ignorable`, so a cold load refused
14
- * the conversation. Tool results already go through the seam's own events.
14
+ * No backend writes custom session events; tool results already go through
15
+ * the seam's own events.
15
16
  *
16
17
  * @module dsh-web-search-plugin
17
18
  */
@@ -20,6 +21,8 @@ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-sett
20
21
  import { MAX_RESULTS_CAP } from "./shared.js";
21
22
  import { TAVILY_DEFAULT_API_KEY_ENV, TAVILY_DEFAULT_BASE_URL, TavilySearchProvider, resolveTavilyOptions } from "./tavily.js";
22
23
  import { BRAVE_DEFAULT_API_KEY_ENV, BRAVE_DEFAULT_BASE_URL, BraveSearchProvider, resolveBraveOptions } from "./brave.js";
24
+ import { DEEPSEEK_DEFAULT_API_KEY_ENV, DEEPSEEK_DEFAULT_BASE_URL, DeepSeekSearchProvider, resolveDeepseekOptions } from "./deepseek.js";
25
+ import { TAVILY_USAGE_POLL_MS, UsageTracker } from "./usage.js";
23
26
 
24
27
  /** Stable id this plugin registers under on `ctx.web`. */
25
28
  const SEARCH_PROVIDER_ID = "dsh-web-search";
@@ -31,7 +34,7 @@ const inject = ["web"];
31
34
 
32
35
  /** Plugin config (all optional — `apply` fills defaults). */
33
36
  const Config = z.object({
34
- /** Active backend: `tavily` (default) or `brave`. */
37
+ /** Active backend: `deepseek-official`, `tavily` (default), or `brave`. */
35
38
  provider: z.string().default("tavily"),
36
39
  /** Tavily auth mode: `keyless` (default) or `keyed`. */
37
40
  mode: z.string().default("keyless"),
@@ -62,7 +65,21 @@ const Config = z.object({
62
65
  /** Optional Brave `freshness` (`pd` / `pw` / `pm` / `py`). */
63
66
  freshness: z.string().default(""),
64
67
  /** Optional HTTP(S) proxy URL for Brave (falls back to HTTPS_PROXY / HTTP_PROXY). */
65
- proxy: z.string().default("")
68
+ proxy: z.string().default(""),
69
+ /** Literal DeepSeek API key; prefer {@link deepseekApiKeyEnv}. */
70
+ deepseekApiKey: z.string().role("secret").default(""),
71
+ /** Credential reference for DeepSeek search. */
72
+ deepseekApiKeyEnv: z.string().role("credential-ref").default(DEEPSEEK_DEFAULT_API_KEY_ENV),
73
+ /** DeepSeek Anthropic-compatible Messages base URL. `/messages` is appended. */
74
+ deepseekBaseURL: z.string().default(DEEPSEEK_DEFAULT_BASE_URL),
75
+ /** Anthropic-format model name for the Messages request. */
76
+ model: z.string().default("deepseek-v4-flash"),
77
+ /** `anthropic-version` header value. */
78
+ apiVersion: z.string().default("2023-06-01"),
79
+ /** Upper bound on generated tokens for the Messages request. */
80
+ maxTokens: z.number().step(1).min(1).default(4096),
81
+ /** Maximum `web_search` server-tool uses per request. */
82
+ maxUses: z.number().step(1).min(1).default(5)
66
83
  });
67
84
 
68
85
  /** Settings namespace carrying this plugin's backend switch and options. */
@@ -78,10 +95,17 @@ const WEB_SEARCH_TAVILY_SETTINGS_NAMESPACE = WEB_SEARCH_SETTINGS_NAMESPACE;
78
95
  class PluginSearchProvider {
79
96
  id = SEARCH_PROVIDER_ID;
80
97
 
81
- constructor(resolveOptions) {
98
+ constructor(resolveOptions, usage) {
82
99
  this.resolveOptions = resolveOptions;
83
- this.tavily = new TavilySearchProvider(() => this.resolveOptions().tavily);
84
- this.brave = new BraveSearchProvider(() => this.resolveOptions().brave);
100
+ this.deepseek = new DeepSeekSearchProvider(() => this.resolveOptions().deepseek);
101
+ this.tavily = new TavilySearchProvider(() => ({
102
+ ...this.resolveOptions().tavily,
103
+ onUsage: (credits) => usage?.recordTavilyCredits(credits)
104
+ }));
105
+ this.brave = new BraveSearchProvider(() => ({
106
+ ...this.resolveOptions().brave,
107
+ onRateLimit: (headers) => usage?.recordBraveHeaders(headers)
108
+ }));
85
109
  }
86
110
 
87
111
  available() {
@@ -93,19 +117,56 @@ class PluginSearchProvider {
93
117
  }
94
118
 
95
119
  backend() {
96
- return this.resolveOptions().provider === "brave" ? this.brave : this.tavily;
120
+ const provider = this.resolveOptions().provider;
121
+ if (provider === "brave") return this.brave;
122
+ if (provider === "deepseek-official") return this.deepseek;
123
+ return this.tavily;
97
124
  }
98
125
  }
99
126
 
100
- /** Project one resolved section into options for both backends. */
127
+ /** Project one resolved section into options for each backend. */
101
128
  function resolveOptions(ctx, config) {
129
+ const provider = config.provider === "brave" || config.provider === "deepseek-official" ? config.provider : "tavily";
102
130
  return {
103
- provider: config.provider === "brave" ? "brave" : "tavily",
131
+ provider,
132
+ deepseek: resolveDeepseekOptions(ctx, config),
104
133
  tavily: resolveTavilyOptions(ctx, config),
105
134
  brave: resolveBraveOptions(ctx, config)
106
135
  };
107
136
  }
108
137
 
138
+ /** JSON body helper for the usage refresh POST. */
139
+ function readJsonBody(req) {
140
+ return new Promise((resolve, reject) => {
141
+ let body = "";
142
+ req.on("data", (chunk) => {
143
+ body += chunk;
144
+ if (body.length > 1e6) {
145
+ req.destroy();
146
+ reject(new Error("Payload too large"));
147
+ }
148
+ });
149
+ req.on("end", () => {
150
+ try {
151
+ resolve(body ? JSON.parse(body) : {});
152
+ } catch {
153
+ reject(new Error("Invalid JSON"));
154
+ }
155
+ });
156
+ req.on("error", reject);
157
+ });
158
+ }
159
+
160
+ function sendJson(res, statusCode, data) {
161
+ const body = JSON.stringify(data);
162
+ res.writeHead(statusCode, {
163
+ "Content-Type": "application/json; charset=utf-8",
164
+ "Cache-Control": "no-store",
165
+ "Content-Length": Buffer.byteLength(body)
166
+ });
167
+ res.end(body);
168
+ }
169
+
109
170
  /** Register the dispatched search provider with `ctx.web`. */
110
171
  function apply(ctx, config) {
111
172
  let current = () => config;
@@ -115,7 +176,61 @@ function apply(ctx, config) {
115
176
  },
116
177
  onChange: () => {}
117
178
  });
118
- ctx.web.registerSearchProvider(new PluginSearchProvider(() => resolveOptions(ctx, current())));
179
+ const live = () => resolveOptions(ctx, current());
180
+ const usage = new UsageTracker({
181
+ getTavilyOptions: () => live().tavily
182
+ });
183
+ ctx.effect(() => {
184
+ let cancelled = false;
185
+ void usage.hydrate().then(() => {
186
+ if (cancelled) return;
187
+ if (live().provider === "tavily" && live().tavily.mode === "keyed") return usage.refreshTavily(undefined, true);
188
+ }).catch(() => {});
189
+ return () => {
190
+ cancelled = true;
191
+ usage.dispose();
192
+ };
193
+ }, "dsh-web-search-plugin: usage hydrate");
194
+ ctx.effect(() => {
195
+ const tick = () => {
196
+ if (live().provider === "tavily" && live().tavily.mode === "keyed") void usage.refreshTavily().catch(() => {});
197
+ };
198
+ const timer = setInterval(tick, TAVILY_USAGE_POLL_MS);
199
+ return () => clearInterval(timer);
200
+ }, "dsh-web-search-plugin: tavily usage poll");
201
+ ctx.web.registerSearchProvider(new PluginSearchProvider(live, usage));
202
+ ctx.inject(["webServer"], (webCtx) => {
203
+ webCtx.effect(() => webCtx.webServer.register({
204
+ kind: "exact",
205
+ path: "/dsh-web-search/usage",
206
+ async handler(req, res) {
207
+ if (req.method !== "GET" && req.method !== "HEAD" && req.method !== "POST") {
208
+ res.writeHead(405, { Allow: "GET, HEAD, POST" });
209
+ res.end();
210
+ return;
211
+ }
212
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
213
+ const force = req.method === "POST" || url.searchParams.get("force") === "1" || url.searchParams.get("force") === "true";
214
+ const options = live();
215
+ const tavilyState = usage.snapshot().tavily;
216
+ const missingIdentity = tavilyState.plan == null && tavilyState.limit == null;
217
+ if ((force || missingIdentity) && options.provider === "tavily" && options.tavily.mode === "keyed") {
218
+ try {
219
+ if (req.method === "POST") await readJsonBody(req).catch(() => ({}));
220
+ await usage.refreshTavily(undefined, force);
221
+ } catch {
222
+ /* serialize still returns the last cache */
223
+ }
224
+ }
225
+ if (req.method === "HEAD") {
226
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
227
+ res.end();
228
+ return;
229
+ }
230
+ sendJson(res, 200, usage.serialize(options.provider, options.tavily.mode));
231
+ }
232
+ }), "dsh-web-search-plugin: usage route");
233
+ });
119
234
  }
120
235
 
121
236
  export {
@@ -123,10 +238,15 @@ export {
123
238
  BRAVE_DEFAULT_BASE_URL,
124
239
  BraveSearchProvider,
125
240
  Config,
241
+ DEEPSEEK_DEFAULT_API_KEY_ENV,
242
+ DEEPSEEK_DEFAULT_BASE_URL,
243
+ DeepSeekSearchProvider,
126
244
  SEARCH_PROVIDER_ID,
127
245
  TAVILY_DEFAULT_API_KEY_ENV,
128
246
  TAVILY_DEFAULT_BASE_URL,
247
+ TAVILY_USAGE_POLL_MS,
129
248
  TavilySearchProvider,
249
+ UsageTracker,
130
250
  WEB_SEARCH_SETTINGS_NAMESPACE,
131
251
  WEB_SEARCH_TAVILY_SETTINGS_NAMESPACE,
132
252
  apply,
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.1.2";
10
+ export const USER_AGENT = "dsh-web-search-plugin/0.2.1";
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
@@ -5,6 +5,7 @@
5
5
  import { WebError } from "@deepseek-ai/dsh-web";
6
6
  import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
7
7
  import { MAX_RESULTS_CAP, USER_AGENT, isAbortError, resolveApiKey, resolveSecret, searchAborted, throwIfSearchAborted } from "./shared.js";
8
+ import { parseTavilySearchCredits } from "./usage.js";
8
9
 
9
10
  /** Default Tavily REST API base; `/search` is appended. */
10
11
  export const TAVILY_DEFAULT_BASE_URL = "https://api.tavily.com";
@@ -67,13 +68,15 @@ export class TavilySearchProvider {
67
68
  throwIfSearchAborted(signal);
68
69
  }
69
70
  const endpoint = `${options.baseURL.replace(/\/+$/u, "")}/search`;
71
+ const keyed = options.mode === "keyed";
70
72
  const body = {
71
73
  query: request.query,
72
74
  max_results: Math.min(request.maxResults ?? options.maxResults, MAX_RESULTS_CAP),
73
75
  search_depth: options.searchDepth,
74
76
  topic: options.topic,
75
77
  include_answer: options.includeAnswer === true,
76
- include_images: false
78
+ include_images: false,
79
+ ...keyed ? { include_usage: true } : {}
77
80
  };
78
81
  const headers = {
79
82
  "content-type": "application/json",
@@ -106,7 +109,12 @@ export class TavilySearchProvider {
106
109
  throw new WebError(message, "WEB_PROVIDER_ERROR");
107
110
  }
108
111
  try {
109
- return mapTavilyResponse(await response.json(), options.includeAnswer === true);
112
+ const json = await response.json();
113
+ if (keyed) {
114
+ const credits = parseTavilySearchCredits(json);
115
+ if (credits !== null) options.onUsage?.(credits);
116
+ }
117
+ return mapTavilyResponse(json, options.includeAnswer === true);
110
118
  } catch (error) {
111
119
  if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
112
120
  if (error instanceof WebError) throw error;