opencode-websearch-deepseek 0.3.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.
Files changed (2) hide show
  1. package/dist/index.js +107 -64
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -71,11 +71,11 @@ export async function resolveApiKey(ctx, options) {
71
71
  if (!connection)
72
72
  return undefined;
73
73
  const credential = await integration.connection.resolve(connection);
74
- const key = credential?.key;
75
- return typeof key === "string" && key.trim() ? key.trim() : undefined;
74
+ return readString(credential?.key);
76
75
  }
77
- catch {
78
- return undefined;
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 });
79
79
  }
80
80
  }
81
81
  /** Read an environment variable, treating blank strings as unset. */
@@ -84,47 +84,77 @@ function readEnv(name, env = process.env) {
84
84
  return value && value.trim() ? value.trim() : undefined;
85
85
  }
86
86
  /**
87
- * Rethrow the cancellation reason from `signal`. `Error` and `DOMException`
88
- * (e.g. `AbortError`) are preserved as-is; other values (primitives) are
87
+ * Rethrow the cancellation reason from `signal`. `Error` and `AbortError`
88
+ * objects (e.g. `DOMException`) are preserved as-is; other reasons are
89
89
  * normalised to an `Error` so callers always receive one.
90
90
  */
91
91
  function throwCancellation(signal, fallback) {
92
92
  const reason = signal.reason;
93
93
  if (reason instanceof Error)
94
94
  throw reason;
95
- if (typeof reason === "object" && reason !== null)
96
- throw reason;
97
- if (reason !== undefined && reason !== null)
98
- throw new Error(String(reason));
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
+ }
99
107
  if (fallback instanceof Error)
100
108
  throw fallback;
101
109
  throw new Error("The web search was aborted");
102
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
+ }
103
129
  /**
104
130
  * Resolve the `max_uses` value for the search tool declaration.
105
131
  * Falls back to {@link DEFAULT_MAX_USES} for missing or invalid input.
106
132
  */
107
133
  export function resolveMaxUses(raw = process.env.WEBSEARCH_MAX_USES) {
108
- if (typeof raw === "number") {
109
- return Number.isSafeInteger(raw) && raw > 0 ? raw : DEFAULT_MAX_USES;
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";
110
146
  }
111
- const value = (typeof raw === "string" ? raw : "").trim();
112
- // Strict: only a plain positive integer. Rejects "1e3", "5.5", "5abc" and
113
- // values outside the safe-integer range (e.g. a 24-digit number).
114
- if (!/^\d+$/.test(value))
115
- return DEFAULT_MAX_USES;
116
- const parsed = Number(value);
117
- return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_USES;
147
+ if (normalized === "disabled" || normalized === "off" || normalized === "false" || normalized === "0") {
148
+ return "disabled";
149
+ }
150
+ return undefined;
118
151
  }
119
152
  /**
120
153
  * Resolve the thinking mode. Only `disabled` disables extended thinking;
121
154
  * anything else (including unset) keeps the historical `enabled` behavior.
122
155
  */
123
156
  export function resolveThinking(raw = process.env.WEBSEARCH_THINKING) {
124
- const value = (typeof raw === "string" ? raw : "").trim().toLowerCase();
125
- return value === "disabled" || value === "off" || value === "false" || value === "0"
126
- ? "disabled"
127
- : "enabled";
157
+ return pickThinking(raw) ?? "enabled";
128
158
  }
129
159
  /** Build the JSON request body sent to the DeepSeek Messages endpoint. */
130
160
  export function buildRequestBody(query, options) {
@@ -154,7 +184,7 @@ export function buildRequestBody(query, options) {
154
184
  */
155
185
  export function dedupeSources(sources) {
156
186
  const byUrl = new Map();
157
- for (const source of sources) {
187
+ for (const source of Array.isArray(sources) ? sources : []) {
158
188
  if (typeof source?.url !== "string" || source.url.length === 0)
159
189
  continue;
160
190
  const existing = byUrl.get(source.url);
@@ -210,16 +240,17 @@ export function extractAnswerAndSources(data) {
210
240
  * present), followed by the individual sources.
211
241
  */
212
242
  export function toResults(answer, sources = []) {
243
+ const valid = (Array.isArray(sources) ? sources : []).filter((source) => typeof source?.url === "string" && source.url.length > 0);
213
244
  const results = [];
214
245
  if (answer) {
215
246
  results.push({
216
- url: sources[0]?.url ?? FALLBACK_URL,
247
+ url: valid[0]?.url ?? FALLBACK_URL,
217
248
  title: "DeepSeek answer",
218
249
  content: answer,
219
250
  time: {},
220
251
  });
221
252
  }
222
- for (const source of sources) {
253
+ for (const source of valid) {
223
254
  results.push({
224
255
  url: source.url,
225
256
  title: source.title || source.url,
@@ -246,47 +277,59 @@ export const plugin = {
246
277
  editor.add({
247
278
  id: "deepseek",
248
279
  name: "DeepSeek Web Search",
249
- execute: async ({ query }, { signal } = {}) => {
250
- const apiKey = await resolveApiKey(ctx, options);
251
- if (!apiKey) {
252
- 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");
253
- }
254
- if (!/^[\x21-\x7e]+$/.test(apiKey)) {
255
- throw new Error("The API key contains invalid characters");
256
- }
257
- const body = buildRequestBody(query, {
258
- model: readString(options.model) ?? readEnv("WEBSEARCH_MODEL") ?? DEFAULT_MODEL,
259
- maxUses: resolveMaxUses(options.maxUses ?? readEnv("WEBSEARCH_MAX_USES")),
260
- thinking: resolveThinking(readString(options.thinking) ?? readEnv("WEBSEARCH_THINKING")),
261
- });
262
- const response = await fetch(API_URL, {
263
- method: "POST",
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 "";
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")),
280
295
  });
281
- // An abort can also arrive after the body resolves; re-check so it
282
- // is not masked as an API error.
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.
283
329
  if (signal?.aborted)
284
- throwCancellation(signal, undefined);
285
- throw new Error(`DeepSeek API error ${response.status}: ${text.slice(0, 300)}`);
330
+ throwCancellation(signal, error);
331
+ throw error;
286
332
  }
287
- const data = (await response.json());
288
- const { answer, sources } = extractAnswerAndSources(data);
289
- return toResults(answer, sources);
290
333
  },
291
334
  });
292
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.3.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": {