opencode-websearch-deepseek 0.2.0 → 0.3.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/README.md +28 -0
- package/dist/index.d.ts +28 -5
- package/dist/index.js +121 -66
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -77,6 +77,34 @@ in:
|
|
|
77
77
|
> so no environment variable is required when that provider is already
|
|
78
78
|
> authenticated.
|
|
79
79
|
|
|
80
|
+
### Plugin options
|
|
81
|
+
|
|
82
|
+
As an alternative to environment variables, configure the plugin with the
|
|
83
|
+
`plugins` object form (read from `ctx.options`):
|
|
84
|
+
|
|
85
|
+
```jsonc
|
|
86
|
+
{
|
|
87
|
+
"$schema": "https://opencode.ai/config.json",
|
|
88
|
+
"plugins": [
|
|
89
|
+
{
|
|
90
|
+
"package": "opencode-websearch-deepseek",
|
|
91
|
+
"options": {
|
|
92
|
+
"apiKey": "{file:~/.config/opencode/deepseek.key}",
|
|
93
|
+
"model": "deepseek-v4-flash",
|
|
94
|
+
"maxUses": 5,
|
|
95
|
+
"thinking": "enabled"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
]
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Precedence for the API key: `options.apiKey` → `DEEPSEEK_API_KEY` /
|
|
103
|
+
`WEBSEARCH_API_KEY` → stored `deepseek` provider credential. `model`, `maxUses`,
|
|
104
|
+
and `thinking` fall back to their environment variables and defaults.
|
|
105
|
+
OpenCode interpolates `{file:...}` (and `{env:...}`) in config values, so the
|
|
106
|
+
key can live in a file instead of the OS environment.
|
|
107
|
+
|
|
80
108
|
> The plugin always selects the DeepSeek provider. Without an API key, a
|
|
81
109
|
> `websearch` call fails with a clear error rather than silently falling back
|
|
82
110
|
> to a different provider.
|
package/dist/index.d.ts
CHANGED
|
@@ -95,25 +95,48 @@ export interface IntegrationContext {
|
|
|
95
95
|
resolve(connection: unknown): Promise<StoredCredential | undefined>;
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Options accepted through the `plugins` object form in `opencode.json(c)` and
|
|
100
|
+
* forwarded to the plugin via `ctx.options`:
|
|
101
|
+
*
|
|
102
|
+
* ```jsonc
|
|
103
|
+
* { "plugins": [{ "package": "opencode-websearch-deepseek", "options": {
|
|
104
|
+
* "apiKey": "...", "model": "deepseek-v4-flash", "maxUses": 5, "thinking": "enabled"
|
|
105
|
+
* } }] }
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
export interface WebsearchOptions {
|
|
109
|
+
/** DeepSeek API key; takes precedence over env and the stored credential. */
|
|
110
|
+
apiKey?: string;
|
|
111
|
+
/** Model used for search and synthesis. */
|
|
112
|
+
model?: string;
|
|
113
|
+
/** Max server-side searches per query. */
|
|
114
|
+
maxUses?: number | string;
|
|
115
|
+
/** `enabled` or `disabled`. */
|
|
116
|
+
thinking?: string;
|
|
117
|
+
}
|
|
98
118
|
/** Context passed to the plugin's `setup`. */
|
|
99
119
|
export interface WebsearchContext {
|
|
100
120
|
websearch: {
|
|
101
121
|
transform(callback: (editor: WebsearchEditor) => void): Promise<unknown> | unknown;
|
|
102
122
|
};
|
|
123
|
+
/** Plugin options from the `plugins` object form. */
|
|
124
|
+
options?: Record<string, unknown>;
|
|
103
125
|
/** Present in OpenCode 2.x; used to reuse the DeepSeek provider credential. */
|
|
104
126
|
integration?: IntegrationContext;
|
|
105
127
|
}
|
|
106
128
|
/**
|
|
107
|
-
* Resolve the DeepSeek API key
|
|
108
|
-
*
|
|
109
|
-
*
|
|
129
|
+
* Resolve the DeepSeek API key, in order of precedence: the `apiKey` plugin
|
|
130
|
+
* option, then the `DEEPSEEK_API_KEY`/`WEBSEARCH_API_KEY` environment
|
|
131
|
+
* variables, then the credential OpenCode stores for the `deepseek` provider
|
|
132
|
+
* (configured via `opencode auth login`).
|
|
110
133
|
*/
|
|
111
|
-
export declare function resolveApiKey(ctx: WebsearchContext): Promise<string | undefined>;
|
|
134
|
+
export declare function resolveApiKey(ctx: WebsearchContext, options?: WebsearchOptions): Promise<string | undefined>;
|
|
112
135
|
/**
|
|
113
136
|
* Resolve the `max_uses` value for the search tool declaration.
|
|
114
137
|
* Falls back to {@link DEFAULT_MAX_USES} for missing or invalid input.
|
|
115
138
|
*/
|
|
116
|
-
export declare function resolveMaxUses(raw?: string | undefined): number;
|
|
139
|
+
export declare function resolveMaxUses(raw?: string | number | undefined): number;
|
|
117
140
|
/**
|
|
118
141
|
* Resolve the thinking mode. Only `disabled` disables extended thinking;
|
|
119
142
|
* anything else (including unset) keeps the historical `enabled` behavior.
|
package/dist/index.js
CHANGED
|
@@ -46,12 +46,20 @@ export const SYSTEM_PROMPT = [
|
|
|
46
46
|
"",
|
|
47
47
|
"Your response must be the final answer, not another search request.",
|
|
48
48
|
].join("\n");
|
|
49
|
+
/** Return a trimmed non-empty string, or undefined for any other value. */
|
|
50
|
+
function readString(value) {
|
|
51
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
52
|
+
}
|
|
49
53
|
/**
|
|
50
|
-
* Resolve the DeepSeek API key
|
|
51
|
-
*
|
|
52
|
-
*
|
|
54
|
+
* Resolve the DeepSeek API key, in order of precedence: the `apiKey` plugin
|
|
55
|
+
* option, then the `DEEPSEEK_API_KEY`/`WEBSEARCH_API_KEY` environment
|
|
56
|
+
* variables, then the credential OpenCode stores for the `deepseek` provider
|
|
57
|
+
* (configured via `opencode auth login`).
|
|
53
58
|
*/
|
|
54
|
-
export async function resolveApiKey(ctx) {
|
|
59
|
+
export async function resolveApiKey(ctx, options) {
|
|
60
|
+
const fromOptions = readString(options?.apiKey);
|
|
61
|
+
if (fromOptions)
|
|
62
|
+
return fromOptions;
|
|
55
63
|
const fromEnv = readEnv("DEEPSEEK_API_KEY") ?? readEnv("WEBSEARCH_API_KEY");
|
|
56
64
|
if (fromEnv)
|
|
57
65
|
return fromEnv;
|
|
@@ -63,11 +71,11 @@ export async function resolveApiKey(ctx) {
|
|
|
63
71
|
if (!connection)
|
|
64
72
|
return undefined;
|
|
65
73
|
const credential = await integration.connection.resolve(connection);
|
|
66
|
-
|
|
67
|
-
return typeof key === "string" && key.trim() ? key.trim() : undefined;
|
|
74
|
+
return readString(credential?.key);
|
|
68
75
|
}
|
|
69
|
-
catch {
|
|
70
|
-
|
|
76
|
+
catch (error) {
|
|
77
|
+
// A genuine integration failure is not "no key": surface it with context.
|
|
78
|
+
throw new Error("Failed to read the DeepSeek credential from OpenCode", { cause: error });
|
|
71
79
|
}
|
|
72
80
|
}
|
|
73
81
|
/** Read an environment variable, treating blank strings as unset. */
|
|
@@ -76,44 +84,77 @@ function readEnv(name, env = process.env) {
|
|
|
76
84
|
return value && value.trim() ? value.trim() : undefined;
|
|
77
85
|
}
|
|
78
86
|
/**
|
|
79
|
-
* Rethrow the cancellation reason from `signal`. `Error` and `
|
|
80
|
-
* (e.g. `
|
|
87
|
+
* Rethrow the cancellation reason from `signal`. `Error` and `AbortError`
|
|
88
|
+
* objects (e.g. `DOMException`) are preserved as-is; other reasons are
|
|
81
89
|
* normalised to an `Error` so callers always receive one.
|
|
82
90
|
*/
|
|
83
91
|
function throwCancellation(signal, fallback) {
|
|
84
92
|
const reason = signal.reason;
|
|
85
93
|
if (reason instanceof Error)
|
|
86
94
|
throw reason;
|
|
87
|
-
if (typeof reason === "object" && reason !== null)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
95
|
+
if (typeof reason === "object" && reason !== null) {
|
|
96
|
+
// Preserve AbortError-like objects (DOMException has a message); wrap the rest.
|
|
97
|
+
const candidate = reason;
|
|
98
|
+
if (candidate.name === "AbortError" && typeof candidate.message === "string")
|
|
99
|
+
throw reason;
|
|
100
|
+
throw new Error("The web search was aborted", { cause: reason });
|
|
101
|
+
}
|
|
102
|
+
if (reason !== undefined && reason !== null) {
|
|
103
|
+
const text = String(reason).trim();
|
|
104
|
+
if (text)
|
|
105
|
+
throw new Error(text);
|
|
106
|
+
}
|
|
91
107
|
if (fallback instanceof Error)
|
|
92
108
|
throw fallback;
|
|
93
109
|
throw new Error("The web search was aborted");
|
|
94
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Validate a `max_uses` value: a positive safe integer, given as a number or a
|
|
113
|
+
* plain-integer string. Returns `undefined` when unset or invalid.
|
|
114
|
+
*/
|
|
115
|
+
function pickMaxUses(value) {
|
|
116
|
+
if (typeof value === "number") {
|
|
117
|
+
return Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
118
|
+
}
|
|
119
|
+
if (typeof value !== "string")
|
|
120
|
+
return undefined;
|
|
121
|
+
// Strict: only a plain positive integer. Rejects "1e3", "5.5", "5abc" and
|
|
122
|
+
// values outside the safe-integer range (e.g. a 24-digit number).
|
|
123
|
+
const trimmed = value.trim();
|
|
124
|
+
if (!/^\d+$/.test(trimmed))
|
|
125
|
+
return undefined;
|
|
126
|
+
const parsed = Number(trimmed);
|
|
127
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
128
|
+
}
|
|
95
129
|
/**
|
|
96
130
|
* Resolve the `max_uses` value for the search tool declaration.
|
|
97
131
|
* Falls back to {@link DEFAULT_MAX_USES} for missing or invalid input.
|
|
98
132
|
*/
|
|
99
133
|
export function resolveMaxUses(raw = process.env.WEBSEARCH_MAX_USES) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
134
|
+
return pickMaxUses(raw) ?? DEFAULT_MAX_USES;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Validate a thinking mode: only recognised `enabled`/`disabled` spellings
|
|
138
|
+
* count. Returns `undefined` when unset or unrecognised.
|
|
139
|
+
*/
|
|
140
|
+
function pickThinking(value) {
|
|
141
|
+
if (typeof value !== "string")
|
|
142
|
+
return undefined;
|
|
143
|
+
const normalized = value.trim().toLowerCase();
|
|
144
|
+
if (normalized === "enabled" || normalized === "on" || normalized === "true" || normalized === "1") {
|
|
145
|
+
return "enabled";
|
|
146
|
+
}
|
|
147
|
+
if (normalized === "disabled" || normalized === "off" || normalized === "false" || normalized === "0") {
|
|
148
|
+
return "disabled";
|
|
149
|
+
}
|
|
150
|
+
return undefined;
|
|
107
151
|
}
|
|
108
152
|
/**
|
|
109
153
|
* Resolve the thinking mode. Only `disabled` disables extended thinking;
|
|
110
154
|
* anything else (including unset) keeps the historical `enabled` behavior.
|
|
111
155
|
*/
|
|
112
156
|
export function resolveThinking(raw = process.env.WEBSEARCH_THINKING) {
|
|
113
|
-
|
|
114
|
-
return value === "disabled" || value === "off" || value === "false" || value === "0"
|
|
115
|
-
? "disabled"
|
|
116
|
-
: "enabled";
|
|
157
|
+
return pickThinking(raw) ?? "enabled";
|
|
117
158
|
}
|
|
118
159
|
/** Build the JSON request body sent to the DeepSeek Messages endpoint. */
|
|
119
160
|
export function buildRequestBody(query, options) {
|
|
@@ -143,7 +184,7 @@ export function buildRequestBody(query, options) {
|
|
|
143
184
|
*/
|
|
144
185
|
export function dedupeSources(sources) {
|
|
145
186
|
const byUrl = new Map();
|
|
146
|
-
for (const source of sources) {
|
|
187
|
+
for (const source of Array.isArray(sources) ? sources : []) {
|
|
147
188
|
if (typeof source?.url !== "string" || source.url.length === 0)
|
|
148
189
|
continue;
|
|
149
190
|
const existing = byUrl.get(source.url);
|
|
@@ -199,16 +240,17 @@ export function extractAnswerAndSources(data) {
|
|
|
199
240
|
* present), followed by the individual sources.
|
|
200
241
|
*/
|
|
201
242
|
export function toResults(answer, sources = []) {
|
|
243
|
+
const valid = (Array.isArray(sources) ? sources : []).filter((source) => typeof source?.url === "string" && source.url.length > 0);
|
|
202
244
|
const results = [];
|
|
203
245
|
if (answer) {
|
|
204
246
|
results.push({
|
|
205
|
-
url:
|
|
247
|
+
url: valid[0]?.url ?? FALLBACK_URL,
|
|
206
248
|
title: "DeepSeek answer",
|
|
207
249
|
content: answer,
|
|
208
250
|
time: {},
|
|
209
251
|
});
|
|
210
252
|
}
|
|
211
|
-
for (const source of
|
|
253
|
+
for (const source of valid) {
|
|
212
254
|
results.push({
|
|
213
255
|
url: source.url,
|
|
214
256
|
title: source.title || source.url,
|
|
@@ -230,51 +272,64 @@ export function toResults(answer, sources = []) {
|
|
|
230
272
|
export const plugin = {
|
|
231
273
|
id: "websearch.deepseek",
|
|
232
274
|
async setup(ctx) {
|
|
275
|
+
const options = (ctx.options ?? {});
|
|
233
276
|
await ctx.websearch.transform((editor) => {
|
|
234
277
|
editor.add({
|
|
235
278
|
id: "deepseek",
|
|
236
279
|
name: "DeepSeek Web Search",
|
|
237
|
-
execute: async ({ query },
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
signal,
|
|
253
|
-
headers: {
|
|
254
|
-
"content-type": "application/json",
|
|
255
|
-
"x-api-key": apiKey,
|
|
256
|
-
"anthropic-version": ANTHROPIC_VERSION,
|
|
257
|
-
},
|
|
258
|
-
body: JSON.stringify(body),
|
|
259
|
-
});
|
|
260
|
-
if (!response.ok) {
|
|
261
|
-
const text = await response.text().catch((error) => {
|
|
262
|
-
// Preserve cancellation: never turn an abort into an API error.
|
|
263
|
-
if (signal?.aborted)
|
|
264
|
-
return throwCancellation(signal, error);
|
|
265
|
-
if (error?.name === "AbortError")
|
|
266
|
-
throw error;
|
|
267
|
-
return "";
|
|
280
|
+
execute: async ({ query }, context) => {
|
|
281
|
+
const signal = context?.signal;
|
|
282
|
+
try {
|
|
283
|
+
const apiKey = await resolveApiKey(ctx, options);
|
|
284
|
+
if (!apiKey) {
|
|
285
|
+
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");
|
|
286
|
+
}
|
|
287
|
+
if (!/^[\x21-\x7e]+$/.test(apiKey)) {
|
|
288
|
+
throw new Error("The API key contains invalid characters");
|
|
289
|
+
}
|
|
290
|
+
const body = buildRequestBody(query, {
|
|
291
|
+
model: readString(options.model) ?? readEnv("WEBSEARCH_MODEL") ?? DEFAULT_MODEL,
|
|
292
|
+
// Valid options win; an unset or invalid option falls back to env.
|
|
293
|
+
maxUses: pickMaxUses(options.maxUses) ?? resolveMaxUses(readEnv("WEBSEARCH_MAX_USES")),
|
|
294
|
+
thinking: pickThinking(options.thinking) ?? resolveThinking(readEnv("WEBSEARCH_THINKING")),
|
|
268
295
|
});
|
|
269
|
-
|
|
270
|
-
|
|
296
|
+
const response = await fetch(API_URL, {
|
|
297
|
+
method: "POST",
|
|
298
|
+
signal,
|
|
299
|
+
headers: {
|
|
300
|
+
"content-type": "application/json",
|
|
301
|
+
"x-api-key": apiKey,
|
|
302
|
+
"anthropic-version": ANTHROPIC_VERSION,
|
|
303
|
+
},
|
|
304
|
+
body: JSON.stringify(body),
|
|
305
|
+
});
|
|
306
|
+
if (!response.ok) {
|
|
307
|
+
const text = await response.text().catch((error) => {
|
|
308
|
+
// Preserve cancellation: never turn an abort into an API error.
|
|
309
|
+
if (signal?.aborted)
|
|
310
|
+
return throwCancellation(signal, error);
|
|
311
|
+
if (error?.name === "AbortError")
|
|
312
|
+
throw error;
|
|
313
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
314
|
+
return `[body read failed: ${detail}]`;
|
|
315
|
+
});
|
|
316
|
+
// An abort can also arrive after the body resolves; re-check so it
|
|
317
|
+
// is not masked as an API error.
|
|
318
|
+
if (signal?.aborted)
|
|
319
|
+
throwCancellation(signal, undefined);
|
|
320
|
+
throw new Error(`DeepSeek API error ${response.status}: ${text.slice(0, 300)}`);
|
|
321
|
+
}
|
|
322
|
+
const data = (await response.json());
|
|
323
|
+
const { answer, sources } = extractAnswerAndSources(data);
|
|
324
|
+
return toResults(answer, sources);
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
// `fetch`/`json` reject with the raw signal reason; normalise any
|
|
328
|
+
// cancellation so callers always receive an Error.
|
|
271
329
|
if (signal?.aborted)
|
|
272
|
-
throwCancellation(signal,
|
|
273
|
-
throw
|
|
330
|
+
throwCancellation(signal, error);
|
|
331
|
+
throw error;
|
|
274
332
|
}
|
|
275
|
-
const data = (await response.json());
|
|
276
|
-
const { answer, sources } = extractAnswerAndSources(data);
|
|
277
|
-
return toResults(answer, sources);
|
|
278
333
|
},
|
|
279
334
|
});
|
|
280
335
|
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.
|
|
4
|
+
"version": "0.3.1",
|
|
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 \"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": {
|