opencode-websearch-deepseek 0.3.0 → 0.3.2
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/dist/index.d.ts +1 -1
- package/dist/index.js +147 -66
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -131,7 +131,7 @@ export interface WebsearchContext {
|
|
|
131
131
|
* variables, then the credential OpenCode stores for the `deepseek` provider
|
|
132
132
|
* (configured via `opencode auth login`).
|
|
133
133
|
*/
|
|
134
|
-
export declare function resolveApiKey(ctx: WebsearchContext, options?: WebsearchOptions): Promise<string | undefined>;
|
|
134
|
+
export declare function resolveApiKey(ctx: WebsearchContext, options?: WebsearchOptions, signal?: AbortSignal): Promise<string | undefined>;
|
|
135
135
|
/**
|
|
136
136
|
* Resolve the `max_uses` value for the search tool declaration.
|
|
137
137
|
* Falls back to {@link DEFAULT_MAX_USES} for missing or invalid input.
|
package/dist/index.js
CHANGED
|
@@ -56,7 +56,7 @@ function readString(value) {
|
|
|
56
56
|
* variables, then the credential OpenCode stores for the `deepseek` provider
|
|
57
57
|
* (configured via `opencode auth login`).
|
|
58
58
|
*/
|
|
59
|
-
export async function resolveApiKey(ctx, options) {
|
|
59
|
+
export async function resolveApiKey(ctx, options, signal) {
|
|
60
60
|
const fromOptions = readString(options?.apiKey);
|
|
61
61
|
if (fromOptions)
|
|
62
62
|
return fromOptions;
|
|
@@ -67,15 +67,17 @@ export async function resolveApiKey(ctx, options) {
|
|
|
67
67
|
if (!integration)
|
|
68
68
|
return undefined;
|
|
69
69
|
try {
|
|
70
|
-
const connection = await integration.connection.active("deepseek");
|
|
70
|
+
const connection = await withAbort(integration.connection.active("deepseek"), signal);
|
|
71
71
|
if (!connection)
|
|
72
72
|
return undefined;
|
|
73
|
-
const credential = await integration.connection.resolve(connection);
|
|
74
|
-
|
|
75
|
-
return typeof key === "string" && key.trim() ? key.trim() : undefined;
|
|
73
|
+
const credential = await withAbort(integration.connection.resolve(connection), signal);
|
|
74
|
+
return readString(credential?.key);
|
|
76
75
|
}
|
|
77
|
-
catch {
|
|
78
|
-
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (signal?.aborted)
|
|
78
|
+
throwCancellation(signal, error);
|
|
79
|
+
// A genuine integration failure is not "no key": surface it with context.
|
|
80
|
+
throw new Error("Failed to read the DeepSeek credential from OpenCode", { cause: error });
|
|
79
81
|
}
|
|
80
82
|
}
|
|
81
83
|
/** Read an environment variable, treating blank strings as unset. */
|
|
@@ -84,47 +86,108 @@ function readEnv(name, env = process.env) {
|
|
|
84
86
|
return value && value.trim() ? value.trim() : undefined;
|
|
85
87
|
}
|
|
86
88
|
/**
|
|
87
|
-
* Rethrow the cancellation reason from `signal`. `Error` and `
|
|
88
|
-
* (e.g. `
|
|
89
|
+
* Rethrow the cancellation reason from `signal`. `Error` and `AbortError`
|
|
90
|
+
* objects (e.g. `DOMException`) are preserved as-is; other reasons are
|
|
89
91
|
* normalised to an `Error` so callers always receive one.
|
|
90
92
|
*/
|
|
91
93
|
function throwCancellation(signal, fallback) {
|
|
92
94
|
const reason = signal.reason;
|
|
93
95
|
if (reason instanceof Error)
|
|
94
96
|
throw reason;
|
|
95
|
-
if (typeof reason === "object" && reason !== null)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
if (typeof reason === "object" && reason !== null) {
|
|
98
|
+
// Preserve AbortError-like objects (DOMException has a message); wrap the
|
|
99
|
+
// rest. Guard the property reads: a hostile reason may throw from a getter.
|
|
100
|
+
try {
|
|
101
|
+
const candidate = reason;
|
|
102
|
+
if (candidate.name === "AbortError" && typeof candidate.message === "string" && candidate.message) {
|
|
103
|
+
throw reason;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (error === reason)
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
throw new Error("The web search was aborted", { cause: reason });
|
|
111
|
+
}
|
|
112
|
+
if (typeof reason === "string" && reason.trim())
|
|
113
|
+
throw new Error(reason.trim());
|
|
114
|
+
if (reason !== undefined && reason !== null) {
|
|
115
|
+
// Non-string primitives (e.g. 0) are not useful messages; keep them as cause.
|
|
116
|
+
throw new Error("The web search was aborted", { cause: reason });
|
|
117
|
+
}
|
|
99
118
|
if (fallback instanceof Error)
|
|
100
119
|
throw fallback;
|
|
101
120
|
throw new Error("The web search was aborted");
|
|
102
121
|
}
|
|
122
|
+
/** Reject with the signal's reason if `signal` aborts before `promise` settles. */
|
|
123
|
+
function withAbort(promise, signal) {
|
|
124
|
+
if (!signal)
|
|
125
|
+
return promise;
|
|
126
|
+
if (signal.aborted) {
|
|
127
|
+
// The passed promise is already created; swallow its later rejection so it
|
|
128
|
+
// does not surface as an unhandled rejection.
|
|
129
|
+
promise.catch(() => { });
|
|
130
|
+
return Promise.reject(signal.reason ?? new Error("The web search was aborted"));
|
|
131
|
+
}
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const onAbort = () => reject(signal.reason ?? new Error("The web search was aborted"));
|
|
134
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
135
|
+
promise.then((value) => {
|
|
136
|
+
signal.removeEventListener("abort", onAbort);
|
|
137
|
+
resolve(value);
|
|
138
|
+
}, (error) => {
|
|
139
|
+
signal.removeEventListener("abort", onAbort);
|
|
140
|
+
reject(error);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Validate a `max_uses` value: a positive safe integer, given as a number or a
|
|
146
|
+
* plain-integer string. Returns `undefined` when unset or invalid.
|
|
147
|
+
*/
|
|
148
|
+
function pickMaxUses(value) {
|
|
149
|
+
if (typeof value === "number") {
|
|
150
|
+
return Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
151
|
+
}
|
|
152
|
+
if (typeof value !== "string")
|
|
153
|
+
return undefined;
|
|
154
|
+
// Strict: only a plain positive integer. Rejects "1e3", "5.5", "5abc" and
|
|
155
|
+
// values outside the safe-integer range (e.g. a 24-digit number).
|
|
156
|
+
const trimmed = value.trim();
|
|
157
|
+
if (!/^\d+$/.test(trimmed))
|
|
158
|
+
return undefined;
|
|
159
|
+
const parsed = Number(trimmed);
|
|
160
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
161
|
+
}
|
|
103
162
|
/**
|
|
104
163
|
* Resolve the `max_uses` value for the search tool declaration.
|
|
105
164
|
* Falls back to {@link DEFAULT_MAX_USES} for missing or invalid input.
|
|
106
165
|
*/
|
|
107
166
|
export function resolveMaxUses(raw = process.env.WEBSEARCH_MAX_USES) {
|
|
108
|
-
|
|
109
|
-
|
|
167
|
+
return pickMaxUses(raw) ?? DEFAULT_MAX_USES;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Validate a thinking mode: only recognised `enabled`/`disabled` spellings
|
|
171
|
+
* count. Returns `undefined` when unset or unrecognised.
|
|
172
|
+
*/
|
|
173
|
+
function pickThinking(value) {
|
|
174
|
+
if (typeof value !== "string")
|
|
175
|
+
return undefined;
|
|
176
|
+
const normalized = value.trim().toLowerCase();
|
|
177
|
+
if (normalized === "enabled" || normalized === "on" || normalized === "true" || normalized === "1") {
|
|
178
|
+
return "enabled";
|
|
110
179
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return DEFAULT_MAX_USES;
|
|
116
|
-
const parsed = Number(value);
|
|
117
|
-
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_USES;
|
|
180
|
+
if (normalized === "disabled" || normalized === "off" || normalized === "false" || normalized === "0") {
|
|
181
|
+
return "disabled";
|
|
182
|
+
}
|
|
183
|
+
return undefined;
|
|
118
184
|
}
|
|
119
185
|
/**
|
|
120
186
|
* Resolve the thinking mode. Only `disabled` disables extended thinking;
|
|
121
187
|
* anything else (including unset) keeps the historical `enabled` behavior.
|
|
122
188
|
*/
|
|
123
189
|
export function resolveThinking(raw = process.env.WEBSEARCH_THINKING) {
|
|
124
|
-
|
|
125
|
-
return value === "disabled" || value === "off" || value === "false" || value === "0"
|
|
126
|
-
? "disabled"
|
|
127
|
-
: "enabled";
|
|
190
|
+
return pickThinking(raw) ?? "enabled";
|
|
128
191
|
}
|
|
129
192
|
/** Build the JSON request body sent to the DeepSeek Messages endpoint. */
|
|
130
193
|
export function buildRequestBody(query, options) {
|
|
@@ -154,7 +217,7 @@ export function buildRequestBody(query, options) {
|
|
|
154
217
|
*/
|
|
155
218
|
export function dedupeSources(sources) {
|
|
156
219
|
const byUrl = new Map();
|
|
157
|
-
for (const source of sources) {
|
|
220
|
+
for (const source of Array.isArray(sources) ? sources : []) {
|
|
158
221
|
if (typeof source?.url !== "string" || source.url.length === 0)
|
|
159
222
|
continue;
|
|
160
223
|
const existing = byUrl.get(source.url);
|
|
@@ -210,16 +273,19 @@ export function extractAnswerAndSources(data) {
|
|
|
210
273
|
* present), followed by the individual sources.
|
|
211
274
|
*/
|
|
212
275
|
export function toResults(answer, sources = []) {
|
|
276
|
+
const valid = (Array.isArray(sources) ? sources : []).filter((source) => typeof source?.url === "string" && source.url.length > 0);
|
|
213
277
|
const results = [];
|
|
214
278
|
if (answer) {
|
|
279
|
+
// The synthesized answer reuses the first source's URL so it links to a
|
|
280
|
+
// real page; the same source is also listed below (intentional).
|
|
215
281
|
results.push({
|
|
216
|
-
url:
|
|
282
|
+
url: valid[0]?.url ?? FALLBACK_URL,
|
|
217
283
|
title: "DeepSeek answer",
|
|
218
284
|
content: answer,
|
|
219
285
|
time: {},
|
|
220
286
|
});
|
|
221
287
|
}
|
|
222
|
-
for (const source of
|
|
288
|
+
for (const source of valid) {
|
|
223
289
|
results.push({
|
|
224
290
|
url: source.url,
|
|
225
291
|
title: source.title || source.url,
|
|
@@ -246,47 +312,62 @@ export const plugin = {
|
|
|
246
312
|
editor.add({
|
|
247
313
|
id: "deepseek",
|
|
248
314
|
name: "DeepSeek Web Search",
|
|
249
|
-
execute: async ({ query },
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
signal,
|
|
265
|
-
headers: {
|
|
266
|
-
"content-type": "application/json",
|
|
267
|
-
"x-api-key": apiKey,
|
|
268
|
-
"anthropic-version": ANTHROPIC_VERSION,
|
|
269
|
-
},
|
|
270
|
-
body: JSON.stringify(body),
|
|
271
|
-
});
|
|
272
|
-
if (!response.ok) {
|
|
273
|
-
const text = await response.text().catch((error) => {
|
|
274
|
-
// Preserve cancellation: never turn an abort into an API error.
|
|
275
|
-
if (signal?.aborted)
|
|
276
|
-
return throwCancellation(signal, error);
|
|
277
|
-
if (error?.name === "AbortError")
|
|
278
|
-
throw error;
|
|
279
|
-
return "";
|
|
315
|
+
execute: async ({ query }, context) => {
|
|
316
|
+
const signal = context?.signal;
|
|
317
|
+
try {
|
|
318
|
+
const apiKey = await resolveApiKey(ctx, options, signal);
|
|
319
|
+
if (!apiKey) {
|
|
320
|
+
throw new Error("No DeepSeek API key: set the DEEPSEEK_API_KEY env var, pass the apiKey plugin option, or sign in to the deepseek provider in OpenCode");
|
|
321
|
+
}
|
|
322
|
+
if (!/^[\x21-\x7e]+$/.test(apiKey)) {
|
|
323
|
+
throw new Error("The API key contains invalid characters");
|
|
324
|
+
}
|
|
325
|
+
const body = buildRequestBody(query, {
|
|
326
|
+
model: readString(options.model) ?? readEnv("WEBSEARCH_MODEL") ?? DEFAULT_MODEL,
|
|
327
|
+
// Valid options win; an unset or invalid option falls back to env.
|
|
328
|
+
maxUses: pickMaxUses(options.maxUses) ?? resolveMaxUses(readEnv("WEBSEARCH_MAX_USES")),
|
|
329
|
+
thinking: pickThinking(options.thinking) ?? resolveThinking(readEnv("WEBSEARCH_THINKING")),
|
|
280
330
|
});
|
|
281
|
-
|
|
282
|
-
|
|
331
|
+
const response = await fetch(API_URL, {
|
|
332
|
+
method: "POST",
|
|
333
|
+
signal,
|
|
334
|
+
headers: {
|
|
335
|
+
"content-type": "application/json",
|
|
336
|
+
"x-api-key": apiKey,
|
|
337
|
+
"anthropic-version": ANTHROPIC_VERSION,
|
|
338
|
+
},
|
|
339
|
+
body: JSON.stringify(body),
|
|
340
|
+
});
|
|
341
|
+
if (!response.ok) {
|
|
342
|
+
const text = await response.text().catch((error) => {
|
|
343
|
+
// Preserve cancellation: never turn an abort into an API error.
|
|
344
|
+
if (signal?.aborted)
|
|
345
|
+
return throwCancellation(signal, error);
|
|
346
|
+
if (error?.name === "AbortError")
|
|
347
|
+
throw error;
|
|
348
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
349
|
+
return `[body read failed: ${detail}]`;
|
|
350
|
+
});
|
|
351
|
+
// An abort can also arrive after the body resolves; re-check so it
|
|
352
|
+
// is not masked as an API error.
|
|
353
|
+
if (signal?.aborted)
|
|
354
|
+
throwCancellation(signal, undefined);
|
|
355
|
+
throw new Error(`DeepSeek API error ${response.status}: ${text.slice(0, 300)}`);
|
|
356
|
+
}
|
|
357
|
+
const data = (await response.json());
|
|
358
|
+
// A cancellation may arrive after the response completed.
|
|
283
359
|
if (signal?.aborted)
|
|
284
360
|
throwCancellation(signal, undefined);
|
|
285
|
-
|
|
361
|
+
const { answer, sources } = extractAnswerAndSources(data);
|
|
362
|
+
return toResults(answer, sources);
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
// `fetch`/`json` reject with the raw signal reason; normalise any
|
|
366
|
+
// cancellation so callers always receive an Error.
|
|
367
|
+
if (signal?.aborted)
|
|
368
|
+
throwCancellation(signal, error);
|
|
369
|
+
throw error;
|
|
286
370
|
}
|
|
287
|
-
const data = (await response.json());
|
|
288
|
-
const { answer, sources } = extractAnswerAndSources(data);
|
|
289
|
-
return toResults(answer, sources);
|
|
290
371
|
},
|
|
291
372
|
});
|
|
292
373
|
editor.default.set("deepseek");
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "opencode-websearch-deepseek",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.2",
|
|
5
5
|
"description": "DeepSeek-powered web search provider for OpenCode's built-in websearch tool.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"build": "tsc -p tsconfig.json",
|
|
45
45
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
46
46
|
"test": "npm run build && node --test",
|
|
47
|
+
"prepare": "node -e \"const fs=require('node:fs');if(fs.existsSync('dist/index.js'))process.exit(0);try{require.resolve('typescript')}catch{process.exit(0)};require('node:child_process').execSync('npm run build',{stdio:'inherit'})\"",
|
|
47
48
|
"prepack": "npm run build",
|
|
48
|
-
"prepublishOnly": "npm run build && node --test",
|
|
49
49
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|