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.
- package/CHANGELOG.md +39 -0
- package/README.md +70 -26
- package/cordis.patch.yml +8 -2
- package/lib/brave.js +1 -0
- package/lib/client.js +632 -90
- package/lib/deepseek.js +148 -0
- package/lib/index.js +138 -18
- package/lib/shared.js +1 -1
- package/lib/tavily.js +10 -2
- package/lib/usage.js +352 -0
- package/package.json +3 -3
package/lib/deepseek.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek search backend: the Anthropic-compatible Messages API with the
|
|
3
|
+
* native `web_search_20250305` server tool. Each search costs a model turn
|
|
4
|
+
* and returns structured `web_search_tool_result` blocks; absence of those
|
|
5
|
+
* blocks is an error rather than a prose-scraping fallback.
|
|
6
|
+
*
|
|
7
|
+
* This plugin re-hosts the `deepseek-official` provider so the DeepSeek
|
|
8
|
+
* configuration can sit beside Tavily and Brave. The bundle patch disables
|
|
9
|
+
* the in-box `web-search-deepseek` host plugin (its namespace feed the legacy
|
|
10
|
+
* "plugins → web search" card, and re-registering its provider would collide).
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-web-search-plugin/deepseek
|
|
13
|
+
*/
|
|
14
|
+
import { WebError } from "@deepseek-ai/dsh-web";
|
|
15
|
+
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
|
|
16
|
+
import { MAX_RESULTS_CAP, USER_AGENT, isAbortError, resolveApiKey, resolveSecret, searchAborted, throwIfSearchAborted } from "./shared.js";
|
|
17
|
+
|
|
18
|
+
/** Default endpoint: DeepSeek's Anthropic-compatible API (`/messages` appended). */
|
|
19
|
+
export const DEEPSEEK_DEFAULT_BASE_URL = "https://api.deepseek.com/anthropic/v1";
|
|
20
|
+
/** Default credential reference for this backend. */
|
|
21
|
+
export const DEEPSEEK_DEFAULT_API_KEY_ENV = "DEEPSEEK_API_KEY";
|
|
22
|
+
/** Environment variable naming this backend's endpoint override. */
|
|
23
|
+
const DEEPSEEK_SEARCH_BASE_URL_ENV = "DEEPSEEK_SEARCH_BASE_URL";
|
|
24
|
+
const DEFAULT_MODEL = "deepseek-v4-flash";
|
|
25
|
+
const DEFAULT_API_VERSION = "2023-06-01";
|
|
26
|
+
const DEFAULT_MAX_TOKENS = 4096;
|
|
27
|
+
const DEFAULT_MAX_USES = 5;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build a `url → cited_text` map from `text` blocks' `citations[]` (the snippet
|
|
31
|
+
* source for Anthropic `web_search_result` items, which carry url/title but no
|
|
32
|
+
* inline excerpt).
|
|
33
|
+
*/
|
|
34
|
+
function citationSnippets(blocks) {
|
|
35
|
+
const map = /* @__PURE__ */ new Map();
|
|
36
|
+
for (const block of blocks) {
|
|
37
|
+
if (block.type !== "text") continue;
|
|
38
|
+
for (const cite of block.citations ?? []) if (cite.url != null && cite.url.length > 0 && cite.cited_text != null && cite.cited_text.length > 0 && !map.has(cite.url)) map.set(cite.url, cite.cited_text);
|
|
39
|
+
}
|
|
40
|
+
return map;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Map an Anthropic Messages response to a normalized search result. */
|
|
44
|
+
function mapAnthropicResponse(response) {
|
|
45
|
+
const blocks = response.content ?? [];
|
|
46
|
+
const resultBlocks = blocks.filter((block) => block.type === "web_search_tool_result");
|
|
47
|
+
if (resultBlocks.length === 0) throw new WebError("DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search", "WEB_PROVIDER_ERROR");
|
|
48
|
+
const snippets = citationSnippets(blocks);
|
|
49
|
+
const seen = /* @__PURE__ */ new Set();
|
|
50
|
+
const sources = [];
|
|
51
|
+
for (const block of resultBlocks) for (const item of block.content ?? []) {
|
|
52
|
+
if (item.type !== "web_search_result" || typeof item.url !== "string" || item.url.length === 0 || seen.has(item.url)) continue;
|
|
53
|
+
seen.add(item.url);
|
|
54
|
+
const snippet = snippets.get(item.url);
|
|
55
|
+
sources.push({
|
|
56
|
+
url: item.url,
|
|
57
|
+
...typeof item.title === "string" && item.title.length > 0 ? { title: item.title } : {},
|
|
58
|
+
...snippet !== void 0 ? { snippet } : {},
|
|
59
|
+
...typeof item.page_age === "string" && item.page_age.length > 0 ? { publishedAt: item.page_age } : {}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return { sources, truncated: false };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The DeepSeek-backed search backend; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
|
|
66
|
+
export class DeepSeekSearchProvider {
|
|
67
|
+
constructor(resolveOptions) {
|
|
68
|
+
this.resolveOptions = resolveOptions;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
available() {
|
|
72
|
+
const options = this.resolveOptions();
|
|
73
|
+
return ((options.apiKey?.length ?? 0) > 0 || options.resolveApiKey !== void 0)
|
|
74
|
+
&& URL.canParse(options.baseURL)
|
|
75
|
+
&& Number.isInteger(options.maxTokens) && options.maxTokens > 0
|
|
76
|
+
&& Number.isInteger(options.maxUses) && options.maxUses > 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async search(request, signal) {
|
|
80
|
+
const options = this.resolveOptions();
|
|
81
|
+
const apiKey = await resolveApiKey(options, signal, `DeepSeek search has no API key for "${options.apiKeyEnv ?? DEEPSEEK_DEFAULT_API_KEY_ENV}"; store it through the credentials service, export it in the launching environment, or set a literal "deepseekApiKey" in the dsh-web-search-plugin config`);
|
|
82
|
+
throwIfSearchAborted(signal);
|
|
83
|
+
const endpoint = `${options.baseURL.replace(/\/+$/u, "")}/messages`;
|
|
84
|
+
const body = {
|
|
85
|
+
model: options.model,
|
|
86
|
+
max_tokens: options.maxTokens,
|
|
87
|
+
messages: [{
|
|
88
|
+
role: "user",
|
|
89
|
+
content: [{ type: "text", text: `Perform a web search for the query: ${request.query}` }]
|
|
90
|
+
}],
|
|
91
|
+
tools: [{ type: "web_search_20250305", name: "web_search", max_uses: options.maxUses }]
|
|
92
|
+
};
|
|
93
|
+
throwIfSearchAborted(signal);
|
|
94
|
+
let response;
|
|
95
|
+
try {
|
|
96
|
+
response = await fetch(endpoint, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
redirect: "error",
|
|
99
|
+
headers: {
|
|
100
|
+
"x-api-key": apiKey,
|
|
101
|
+
"authorization": `Bearer ${apiKey}`,
|
|
102
|
+
"anthropic-version": options.apiVersion,
|
|
103
|
+
"content-type": "application/json",
|
|
104
|
+
"accept": "application/json",
|
|
105
|
+
"user-agent": USER_AGENT
|
|
106
|
+
},
|
|
107
|
+
body: JSON.stringify(body),
|
|
108
|
+
...signal !== void 0 ? { signal } : {}
|
|
109
|
+
});
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
112
|
+
throw new WebError(`DeepSeek search request failed: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
113
|
+
}
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
let message = `DeepSeek API error (HTTP ${response.status})`;
|
|
116
|
+
try {
|
|
117
|
+
const parsed = await response.json();
|
|
118
|
+
const detail = typeof parsed.error === "string" ? parsed.error : parsed.error?.message ?? parsed.message;
|
|
119
|
+
if (typeof detail === "string" && detail.length > 0) message = detail;
|
|
120
|
+
} catch {
|
|
121
|
+
/* non-JSON error body — keep the status-line message */
|
|
122
|
+
}
|
|
123
|
+
throw new WebError(message, "WEB_PROVIDER_ERROR");
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
return mapAnthropicResponse(await response.json());
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
129
|
+
if (error instanceof WebError) throw error;
|
|
130
|
+
throw new WebError(`DeepSeek returned an unprocessable response body: ${String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Project the plugin section into DeepSeek backend options. */
|
|
136
|
+
export function resolveDeepseekOptions(ctx, config) {
|
|
137
|
+
return {
|
|
138
|
+
...resolveSecret(ctx, {
|
|
139
|
+
literal: config.deepseekApiKey,
|
|
140
|
+
envName: config.deepseekApiKeyEnv ?? DEEPSEEK_DEFAULT_API_KEY_ENV
|
|
141
|
+
}),
|
|
142
|
+
baseURL: config.deepseekBaseURL ?? launchEnvironmentOf(ctx).get(DEEPSEEK_SEARCH_BASE_URL_ENV)?.value ?? DEEPSEEK_DEFAULT_BASE_URL,
|
|
143
|
+
model: config.model ?? DEFAULT_MODEL,
|
|
144
|
+
apiVersion: config.apiVersion ?? DEFAULT_API_VERSION,
|
|
145
|
+
maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
146
|
+
maxUses: config.maxUses ?? DEFAULT_MAX_USES
|
|
147
|
+
};
|
|
148
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -1,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
|
|
5
|
-
* to the `provider` setting — so switching backends does
|
|
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
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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.
|
|
84
|
-
this.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|