@240xu/dsh-websearch 2.0.0
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/LICENSE +21 -0
- package/README.md +136 -0
- package/cordis.patch.yml +3 -0
- package/examples/cordis.patch.yml +17 -0
- package/lib/backends/anthropic-like.js +262 -0
- package/lib/backends/brave.js +98 -0
- package/lib/backends/ddg.js +136 -0
- package/lib/backends/exa.js +97 -0
- package/lib/backends/index.js +121 -0
- package/lib/backends/mojeek.js +103 -0
- package/lib/backends/openai.js +171 -0
- package/lib/backends/parallel.js +105 -0
- package/lib/backends/searxng.js +107 -0
- package/lib/backends/serper.js +115 -0
- package/lib/backends/tavily.js +112 -0
- package/lib/client.js +317 -0
- package/lib/index.js +277 -0
- package/lib/provider.js +225 -0
- package/lib/util/abort.js +82 -0
- package/lib/util/log.js +51 -0
- package/lib/util/mcp-client.js +233 -0
- package/lib/util/rpc.js +262 -0
- package/package.json +70 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// exa.js — Exa search backend (streamable-http MCP, keyless).
|
|
2
|
+
// Exa's MCP server is at https://mcp.exa.ai/mcp. It exposes:
|
|
3
|
+
// - web_search_exa { query, numResults } -> searching
|
|
4
|
+
// - web_fetch_exa { urls, maxCharacters } -> page content (not used here)
|
|
5
|
+
// Responses come back as text/event-stream where the JSON envelope holds
|
|
6
|
+
// result.content[0].text as human-readable blocks of "Title:/URL:/Published:
|
|
7
|
+
// .../Highlights: .../ ..." separated by "\n\n---\n\n"; we split into source objects.
|
|
8
|
+
// Exa is keyless — its public MCP endpoint requires no API key.
|
|
9
|
+
|
|
10
|
+
import { mcpCall, invalidateSession } from "../util/mcp-client.js";
|
|
11
|
+
import { WebError } from "@deepseek-ai/dsh-web";
|
|
12
|
+
|
|
13
|
+
const EXA_ENDPOINT = "https://mcp.exa.ai/mcp";
|
|
14
|
+
const EXA_TOOL = "web_search_exa";
|
|
15
|
+
|
|
16
|
+
export const exaBackend = {
|
|
17
|
+
id: "exa",
|
|
18
|
+
requiresCredential: false,
|
|
19
|
+
|
|
20
|
+
available() {
|
|
21
|
+
return true; // keyless endpoint
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
async search(request, signal, options, hooks = {}) {
|
|
25
|
+
const args = {
|
|
26
|
+
query: request.query,
|
|
27
|
+
numResults: request.maxResults ?? 8,
|
|
28
|
+
};
|
|
29
|
+
hooks.recordRequest?.({
|
|
30
|
+
endpoint: EXA_ENDPOINT,
|
|
31
|
+
toolName: EXA_TOOL,
|
|
32
|
+
body: args,
|
|
33
|
+
headers: {},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
let result;
|
|
37
|
+
try {
|
|
38
|
+
result = await mcpCall(EXA_ENDPOINT, EXA_TOOL, args, {}, signal);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if (err instanceof WebError) throw err;
|
|
41
|
+
throw new WebError(
|
|
42
|
+
`exa search failed: ${String(err?.message ?? err)}`,
|
|
43
|
+
"WEB_PROVIDER_ERROR",
|
|
44
|
+
{ cause: err },
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
hooks.recordOutcome?.({ status: "ok" });
|
|
49
|
+
const sources = parseExaResult(result, request.maxResults ?? 8);
|
|
50
|
+
|
|
51
|
+
if (result?.isError === true) {
|
|
52
|
+
throw new WebError(
|
|
53
|
+
`exa returned an MCP error: ${String(result.content?.[0]?.text ?? "unknown")}`,
|
|
54
|
+
"WEB_PROVIDER_ERROR",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { sources, content: undefined, truncated: false };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function parseExaResult(result, maxResults) {
|
|
63
|
+
const text = result?.content?.[0]?.text;
|
|
64
|
+
if (typeof text !== "string" || text.length === 0) return [];
|
|
65
|
+
const blocks = text.split("\n\n---\n\n");
|
|
66
|
+
const sources = [];
|
|
67
|
+
for (const block of blocks) {
|
|
68
|
+
const url = matchField(block, /^URL:\s*(.+)$/m);
|
|
69
|
+
if (!url) continue;
|
|
70
|
+
const source = { url: url.trim() };
|
|
71
|
+
const title = matchField(block, /^Title:\s*(.+)$/m);
|
|
72
|
+
if (title) source.title = title.trim();
|
|
73
|
+
const published = matchField(block, /^Published:\s*(.+)$/m);
|
|
74
|
+
if (published) source.publishedAt = published.trim();
|
|
75
|
+
const snippet = joinHighlights(block);
|
|
76
|
+
if (snippet) source.snippet = snippet;
|
|
77
|
+
sources.push(source);
|
|
78
|
+
if (sources.length >= maxResults) break;
|
|
79
|
+
}
|
|
80
|
+
return sources;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function matchField(block, re) {
|
|
84
|
+
const m = re.exec(block);
|
|
85
|
+
return m ? m[1].trim() : undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function joinHighlights(block) {
|
|
89
|
+
const m = /^Highlights:\s*$/m.exec(block);
|
|
90
|
+
if (!m) return undefined;
|
|
91
|
+
const tail = block.slice(m.index + m[0].length);
|
|
92
|
+
const bullets = [...tail.matchAll(/^\s+-\s+(.+)$/gm)].map((b) => b[1].trim());
|
|
93
|
+
if (bullets.length === 0) return undefined;
|
|
94
|
+
return bullets.join(" … ");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default exaBackend;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified backend registry for dsh-websearch.
|
|
3
|
+
* Auto-discovers and exports all backend implementations.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { exaBackend } from "./exa.js";
|
|
7
|
+
import { parallelBackend } from "./parallel.js";
|
|
8
|
+
import { ddgBackend } from "./ddg.js";
|
|
9
|
+
import { deepseekBackend, anthropicBackend } from "./anthropic-like.js";
|
|
10
|
+
import { openaiBackend } from "./openai.js";
|
|
11
|
+
import { braveBackend } from "./brave.js";
|
|
12
|
+
import { tavilyBackend } from "./tavily.js";
|
|
13
|
+
import { serperBackend } from "./serper.js";
|
|
14
|
+
import { searxngBackend } from "./searxng.js";
|
|
15
|
+
import { mojeekBackend } from "./mojeek.js";
|
|
16
|
+
|
|
17
|
+
// Individual backend exports for direct import
|
|
18
|
+
export { exaBackend } from "./exa.js";
|
|
19
|
+
export { parallelBackend } from "./parallel.js";
|
|
20
|
+
export { ddgBackend } from "./ddg.js";
|
|
21
|
+
export { deepseekBackend, anthropicBackend } from "./anthropic-like.js";
|
|
22
|
+
export { openaiBackend } from "./openai.js";
|
|
23
|
+
export { braveBackend } from "./brave.js";
|
|
24
|
+
export { tavilyBackend } from "./tavily.js";
|
|
25
|
+
export { serperBackend } from "./serper.js";
|
|
26
|
+
export { searxngBackend } from "./searxng.js";
|
|
27
|
+
export { mojeekBackend } from "./mojeek.js";
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
/** All registered backends. Order = default fan-out priority. */
|
|
31
|
+
export const BACKENDS = {
|
|
32
|
+
exa: exaBackend,
|
|
33
|
+
parallel: parallelBackend,
|
|
34
|
+
ddg: ddgBackend,
|
|
35
|
+
searxng: searxngBackend, // keyless, new
|
|
36
|
+
brave: braveBackend, // key-gated, new
|
|
37
|
+
tavily: tavilyBackend, // key-gated, new
|
|
38
|
+
serper: serperBackend, // key-gated, new
|
|
39
|
+
mojeek: mojeekBackend, // key-gated, new
|
|
40
|
+
deepseek: deepseekBackend,
|
|
41
|
+
anthropic: anthropicBackend,
|
|
42
|
+
openai: openaiBackend,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** All backend IDs in registry order. */
|
|
46
|
+
export const ALL_BACKEND_IDS = Object.keys(BACKENDS);
|
|
47
|
+
|
|
48
|
+
/** Default enabled backends (keyless only). */
|
|
49
|
+
export const DEFAULT_ENABLED = ["exa", "parallel", "ddg", "searxng"];
|
|
50
|
+
|
|
51
|
+
/** Environment variable names for key-gated backends. */
|
|
52
|
+
export const DEFAULT_KEY_ENV = {
|
|
53
|
+
deepseek: "DEEPSEEK_API_KEY",
|
|
54
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
55
|
+
openai: "OPENAI_API_KEY",
|
|
56
|
+
brave: "BRAVE_API_KEY",
|
|
57
|
+
tavily: "TAVILY_API_KEY",
|
|
58
|
+
serper: "SERPER_API_KEY",
|
|
59
|
+
mojeek: "MOJEEK_API_KEY",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Default base URLs for each backend. */
|
|
63
|
+
export const DEFAULT_BASE_URLS = {
|
|
64
|
+
exa: "https://mcp.exa.ai/mcp",
|
|
65
|
+
parallel: "https://search.parallel.ai/mcp",
|
|
66
|
+
ddg: "https://html.duckduckgo.com/html/",
|
|
67
|
+
searxng: "https://searx.be",
|
|
68
|
+
brave: "https://api.search.brave.com/res/v1/web/search",
|
|
69
|
+
tavily: "https://api.tavily.com/search",
|
|
70
|
+
serper: "https://google.serper.dev/search",
|
|
71
|
+
mojeek: "https://api.mojeek.com/v1/search",
|
|
72
|
+
deepseek: "https://api.deepseek.com/anthropic/v1",
|
|
73
|
+
anthropic: "https://api.anthropic.com/v1",
|
|
74
|
+
openai: "https://api.openai.com/v1",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Backend type classification for GUI. */
|
|
78
|
+
export const BACKEND_TYPES = {
|
|
79
|
+
exa: "mcp",
|
|
80
|
+
parallel: "mcp",
|
|
81
|
+
ddg: "keyless",
|
|
82
|
+
searxng: "keyless",
|
|
83
|
+
brave: "credential",
|
|
84
|
+
tavily: "credential",
|
|
85
|
+
serper: "credential",
|
|
86
|
+
mojeek: "credential",
|
|
87
|
+
deepseek: "credential",
|
|
88
|
+
anthropic: "credential",
|
|
89
|
+
openai: "credential",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** Human-readable labels for GUI. */
|
|
93
|
+
export const BACKEND_LABELS = {
|
|
94
|
+
exa: "Exa (MCP, 免费无 Key)",
|
|
95
|
+
parallel: "Parallel (MCP, 免费无 Key)",
|
|
96
|
+
ddg: "DuckDuckGo (HTML 抓取, 兜底)",
|
|
97
|
+
searxng: "SearXNG (元搜索, 免费无 Key)",
|
|
98
|
+
brave: "Brave Search (独立索引, 2000/月免费)",
|
|
99
|
+
tavily: "Tavily (AI 专用, 含答案摘要)",
|
|
100
|
+
serper: "Serper.dev (Google 抓取, 2500/月免费)",
|
|
101
|
+
mojeek: "Mojeek (独立索引, 1000/天免费)",
|
|
102
|
+
deepseek: "DeepSeek (原生 web_search, 需 Key)",
|
|
103
|
+
anthropic: "Anthropic (Claude 原生 web_search, 需 Key)",
|
|
104
|
+
openai: "OpenAI (Responses API web_search, 需 Key)",
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** Backend descriptions for GUI tooltips. */
|
|
108
|
+
export const BACKEND_DESCRIPTIONS = {
|
|
109
|
+
exa: "流式 HTTP MCP 协议,原生 web_search_exa 工具,结果含标题/URL/高亮摘要",
|
|
110
|
+
parallel: "流式 HTTP MCP 协议,原生 web_search 工具,返回结构化 JSON 含摘要数组",
|
|
111
|
+
ddg: "HTML 页面抓取解析,通过 uddg= 参数还原真实 URL,零依赖兜底",
|
|
112
|
+
searxng: "开源元搜索引擎聚合,可指向公共实例或自托管,隐私友好",
|
|
113
|
+
brave: "Brave 独立搜索索引,REST API,免费 2000 次/月,结果质量高",
|
|
114
|
+
tavily: "AI 专用搜索,可返回 AI 生成答案 + 结构化结果,支持深度搜索模式",
|
|
115
|
+
serper: "抓取 Google SERP,结构化 organic[] 结果,免费 2500 次/月,速度极快",
|
|
116
|
+
mojeek: "英国独立搜索引擎,无追踪,免费 1000 次/天,英文为主",
|
|
117
|
+
deepseek: "DeepSeek Anthropic 兼容 API + 原生 web_search_20250305 工具",
|
|
118
|
+
anthropic: "Claude 官方 Messages API + web_search_20250305 原生工具",
|
|
119
|
+
openai: "OpenAI Responses API + 原生 web_search 工具,解析 url_citation 注释",
|
|
120
|
+
};
|
|
121
|
+
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mojeek backend for dsh-websearch.
|
|
3
|
+
* REST API: https://api.mojeek.com/v1/search
|
|
4
|
+
* Auth: Authorization: Bearer {apiKey} header or api_key query param
|
|
5
|
+
* Free tier: 1,000 requests/day
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { WebError } from "@deepseek-ai/dsh-web";
|
|
9
|
+
import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
|
|
10
|
+
|
|
11
|
+
const MOJEEK_ENDPOINT = "https://api.mojeek.com/v1/search";
|
|
12
|
+
|
|
13
|
+
/** @type {import("../provider.js").Backend} */
|
|
14
|
+
export const mojeekBackend = {
|
|
15
|
+
id: "mojeek",
|
|
16
|
+
requiresCredential: true,
|
|
17
|
+
|
|
18
|
+
available(options) {
|
|
19
|
+
return typeof options?.resolveApiKey === "function";
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
async search(request, signal, options, hooks) {
|
|
23
|
+
throwIfSearchAborted(signal);
|
|
24
|
+
const backendOpts = options ?? {};
|
|
25
|
+
|
|
26
|
+
const apiKey = await backendOpts.resolveApiKey?.();
|
|
27
|
+
if (!apiKey) {
|
|
28
|
+
throw new WebError("Mojeek: API key not configured", "WEB_PROVIDER_UNAVAILABLE");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const baseURL = backendOpts.baseURL ?? MOJEEK_ENDPOINT;
|
|
32
|
+
const count = Math.min(request.maxResults ?? 8, 20);
|
|
33
|
+
|
|
34
|
+
const url = new URL(baseURL);
|
|
35
|
+
url.searchParams.set("q", request.query);
|
|
36
|
+
url.searchParams.set("fmt", "json");
|
|
37
|
+
url.searchParams.set("count", String(count));
|
|
38
|
+
url.searchParams.set("api_key", apiKey);
|
|
39
|
+
|
|
40
|
+
const headers = {
|
|
41
|
+
"Accept": "application/json",
|
|
42
|
+
"User-Agent": "dsh-websearch/2.0",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
hooks?.recordRequest?.({
|
|
46
|
+
endpoint: baseURL,
|
|
47
|
+
toolName: "mojeek_search",
|
|
48
|
+
body: { q: request.query, count },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
let response;
|
|
52
|
+
try {
|
|
53
|
+
response = await fetch(url, {
|
|
54
|
+
method: "GET",
|
|
55
|
+
headers,
|
|
56
|
+
signal,
|
|
57
|
+
});
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
60
|
+
throw new WebError("Mojeek request failed: " + error, "WEB_PROVIDER_ERROR", { cause: error });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
const text = await response.text().catch(() => "");
|
|
65
|
+
hooks?.recordOutcome?.({ status: "error", message: "HTTP " + response.status + ": " + text });
|
|
66
|
+
throw new WebError("Mojeek API error (HTTP " + response.status + "): " + text, "WEB_PROVIDER_ERROR");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let data;
|
|
70
|
+
try {
|
|
71
|
+
data = await response.json();
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw new WebError("Mojeek: invalid JSON response", "WEB_PROVIDER_ERROR", { cause: error });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const sources = parseMojeekResult(data, request.maxResults ?? 8);
|
|
77
|
+
hooks?.recordOutcome?.({ status: "success", count: sources.length });
|
|
78
|
+
return { sources };
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parse Mojeek API response.
|
|
84
|
+
* @param {object} data
|
|
85
|
+
* @param {number} maxResults
|
|
86
|
+
*/
|
|
87
|
+
export function parseMojeekResult(data, maxResults) {
|
|
88
|
+
const sources = [];
|
|
89
|
+
const results = data?.response?.results ?? [];
|
|
90
|
+
|
|
91
|
+
for (const item of results) {
|
|
92
|
+
if (sources.length >= maxResults) break;
|
|
93
|
+
if (!item?.url) continue;
|
|
94
|
+
sources.push({
|
|
95
|
+
url: item.url,
|
|
96
|
+
...(item.title ? { title: item.title } : {}),
|
|
97
|
+
...(item.desc ? { snippet: item.desc.slice(0, 500) } : {}),
|
|
98
|
+
...(item.last_updated ? { publishedAt: item.last_updated } : {}),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return sources;
|
|
103
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// openai.js — OpenAI Responses API backend (key-gated)
|
|
2
|
+
// Uses POST /responses with the built-in "web_search" tool (Responses API
|
|
3
|
+
// native web search). The Responses API returns:
|
|
4
|
+
// output: [
|
|
5
|
+
// { type:"web_search_call", id, status:"completed" },
|
|
6
|
+
// { type:"message", content:[ { type:"output_text", text, annotations:
|
|
7
|
+
// [ { type:"url_citation", url, title, start_index, end_index } ] } ] }
|
|
8
|
+
// ]
|
|
9
|
+
// Sources = url_citation annotations (deduped by url); the surrounding output_text
|
|
10
|
+
// segment ~[start,end] can serve as a rough snippet when the API doesn't
|
|
11
|
+
// return a separate excerpt.
|
|
12
|
+
|
|
13
|
+
import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
|
|
14
|
+
import { WebError } from "@deepseek-ai/dsh-web";
|
|
15
|
+
|
|
16
|
+
const UA = "dsh-websearch/2.0";
|
|
17
|
+
|
|
18
|
+
export const openaiBackend = {
|
|
19
|
+
id: "openai",
|
|
20
|
+
requiresCredential: true,
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* options: { apiKey, baseURL?, model? }
|
|
24
|
+
*/
|
|
25
|
+
available(options) {
|
|
26
|
+
const okKey = (typeof options?.apiKey === "string" && options.apiKey.length > 0)
|
|
27
|
+
|| typeof options?.resolveApiKey === "function";
|
|
28
|
+
const urlOk = typeof options?.baseURL === "string" && URL.canParse(options.baseURL);
|
|
29
|
+
return okKey && urlOk;
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {WebSearchRequest} request
|
|
34
|
+
* @param {AbortSignal} signal
|
|
35
|
+
* @param {object} options { apiKey, baseURL?, model? }
|
|
36
|
+
* @param {{ recordRequest?: Function, recordOutcome?: Function }} [hooks]
|
|
37
|
+
* @returns {Promise<WebSearchResult>}
|
|
38
|
+
*/
|
|
39
|
+
async search(request, signal, options, hooks = {}) {
|
|
40
|
+
throwIfSearchAborted(signal);
|
|
41
|
+
const apiKey = options?.apiKey ?? await options?.resolveApiKey?.();
|
|
42
|
+
if (!apiKey || apiKey.length === 0) {
|
|
43
|
+
hooks.recordOutcome?.({ status: "error", message: "credential missing" });
|
|
44
|
+
throw new WebError(
|
|
45
|
+
"openai search has no API key",
|
|
46
|
+
"WEB_PROVIDER_CREDENTIAL_MISSING",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const baseURL = (options.baseURL || "https://api.openai.com/v1").replace(/\/$/, "");
|
|
50
|
+
const model = options.model || "gpt-5";
|
|
51
|
+
const endpoint = `${baseURL}/responses`;
|
|
52
|
+
|
|
53
|
+
const body = {
|
|
54
|
+
model,
|
|
55
|
+
tools: [{ type: "web_search" }],
|
|
56
|
+
input: request.query,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
hooks.recordRequest?.({
|
|
60
|
+
endpoint,
|
|
61
|
+
toolName: "openai:web_search",
|
|
62
|
+
body: { model, query: request.query },
|
|
63
|
+
headers: {},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
throwIfSearchAborted(signal);
|
|
67
|
+
|
|
68
|
+
let response;
|
|
69
|
+
try {
|
|
70
|
+
response = await fetch(endpoint, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
redirect: "error",
|
|
73
|
+
headers: {
|
|
74
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
"Accept": "application/json",
|
|
77
|
+
"User-Agent": UA,
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify(body),
|
|
80
|
+
...(signal !== undefined ? { signal } : {}),
|
|
81
|
+
});
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (signal?.aborted === true || isAbortError(err)) throw searchAborted(signal, err);
|
|
84
|
+
hooks.recordOutcome?.({ status: "error", message: String(err?.message ?? err) });
|
|
85
|
+
throw new WebError(
|
|
86
|
+
`openai search request failed: ${String(err?.message ?? err)}`,
|
|
87
|
+
"WEB_PROVIDER_ERROR",
|
|
88
|
+
{ cause: err },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
let message = `openai API error (HTTP ${response.status})`;
|
|
94
|
+
try {
|
|
95
|
+
const parsed = await response.json();
|
|
96
|
+
const detail =
|
|
97
|
+
typeof parsed.error === "string"
|
|
98
|
+
? parsed.error
|
|
99
|
+
: parsed.error?.message ?? parsed.message;
|
|
100
|
+
if (detail !== undefined && detail.length > 0) message = detail;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (signal?.aborted === true || isAbortError(err)) throw searchAborted(signal, err);
|
|
103
|
+
}
|
|
104
|
+
hooks.recordOutcome?.({ status: "error", message });
|
|
105
|
+
throw new WebError(message, "WEB_PROVIDER_ERROR");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const json = await response.json();
|
|
110
|
+
const sources = parseOpenAIResponses(json, request.maxResults ?? 8);
|
|
111
|
+
hooks.recordOutcome?.({ status: "ok", sources: sources.length });
|
|
112
|
+
return { sources, content: undefined, truncated: false };
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (signal?.aborted === true || isAbortError(err)) throw searchAborted(signal, err);
|
|
115
|
+
if (err instanceof WebError) {
|
|
116
|
+
hooks.recordOutcome?.({ status: "error", message: err.message });
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
hooks.recordOutcome?.({ status: "error", message: String(err?.message ?? err) });
|
|
120
|
+
throw new WebError(
|
|
121
|
+
`openai returned an unprocessable response body: ${String(err?.message ?? err)}`,
|
|
122
|
+
"WEB_PROVIDER_ERROR",
|
|
123
|
+
{ cause: err },
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Parse a /responses body into WebSearchSource[]. Sources come from
|
|
131
|
+
* output[].content[].annotations[] with type "url_citation". Each carries
|
|
132
|
+
* { url, title, start_index, end_index } — we optionally slice the surrounding
|
|
133
|
+
* output_text to produce a rough snippet.
|
|
134
|
+
*
|
|
135
|
+
* @param {object} json the /responses body
|
|
136
|
+
* @param {number} maxResults
|
|
137
|
+
* @returns {Array<{url:string,title?:string,snippet?:string}>}
|
|
138
|
+
*/
|
|
139
|
+
export function parseOpenAIResponses(json, maxResults) {
|
|
140
|
+
if (!json || !Array.isArray(json.output)) return [];
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
const sources = [];
|
|
143
|
+
|
|
144
|
+
for (const out of json.output) {
|
|
145
|
+
if (out.type !== "message" || !Array.isArray(out.content)) continue;
|
|
146
|
+
for (const block of out.content) {
|
|
147
|
+
if (block.type !== "output_text" || typeof block.text !== "string") continue;
|
|
148
|
+
for (const ann of block.annotations ?? []) {
|
|
149
|
+
if (ann.type !== "url_citation" || !ann.url || seen.has(ann.url)) continue;
|
|
150
|
+
seen.add(ann.url);
|
|
151
|
+
const source = { url: ann.url };
|
|
152
|
+
if (ann.title && ann.title.length > 0) source.title = ann.title;
|
|
153
|
+
// Rough snippet: the text slice around the citation. May be verbose;
|
|
154
|
+
// cap to 240 chars as a courtesy. Only emit if non-empty & informative.
|
|
155
|
+
if (
|
|
156
|
+
typeof ann.start_index === "number" &&
|
|
157
|
+
typeof ann.end_index === "number" &&
|
|
158
|
+
ann.end_index > ann.start_index
|
|
159
|
+
) {
|
|
160
|
+
const slice = block.text.slice(ann.start_index, ann.end_index).trim();
|
|
161
|
+
if (slice.length > 0) source.snippet = slice.slice(0, 240);
|
|
162
|
+
}
|
|
163
|
+
sources.push(source);
|
|
164
|
+
if (sources.length >= maxResults) return sources;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return sources;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export default openaiBackend;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// parallel.js — Parallel search backend (streamable-http MCP, keyless).
|
|
2
|
+
// Parallel's MCP server is at https://search.parallel.ai/mcp. It exposes:
|
|
3
|
+
// - web_search { objective, search_queries[], session_id?, model_name? }
|
|
4
|
+
// - web_fetch { urls, objective?, full_content? }
|
|
5
|
+
// The search_queries array should be 3-6 keyword-level variants; we map the
|
|
6
|
+
// raw query into a single-element query array plus that same query as the
|
|
7
|
+
// objective — that's their most atomic usage. session_id is a conversation
|
|
8
|
+
// stable identifier; we generate one per process so call correlation is
|
|
9
|
+
// consistent across searches. model_name is advisory metadata for their rate
|
|
10
|
+
// limiter; we use a stable sentinel.
|
|
11
|
+
//
|
|
12
|
+
// Response comes back as a JSON envelope whose result.content[0].text is a
|
|
13
|
+
// JSON-string with { search_id, results: [{ url, title, publish_date, excerpts:[] }] }.
|
|
14
|
+
// Parallel is keyless — its public MCP endpoint requires no API key.
|
|
15
|
+
|
|
16
|
+
import { mcpCall, invalidateSession } from "../util/mcp-client.js";
|
|
17
|
+
import { WebError } from "@deepseek-ai/dsh-web";
|
|
18
|
+
|
|
19
|
+
const PARALLEL_ENDPOINT = "https://search.parallel.ai/mcp";
|
|
20
|
+
const PARALLEL_TOOL = "web_search";
|
|
21
|
+
|
|
22
|
+
/** Per-process session id — stable across searches so Parallel's free-tier
|
|
23
|
+
* rate limiter correlates calls consistently. */
|
|
24
|
+
const SESSION_ID =
|
|
25
|
+
"dsh-websearch-" +
|
|
26
|
+
process.pid?.toString(36) + "-" +
|
|
27
|
+
Math.random().toString(36).slice(2, 10);
|
|
28
|
+
|
|
29
|
+
export const parallelBackend = {
|
|
30
|
+
id: "parallel",
|
|
31
|
+
requiresCredential: false,
|
|
32
|
+
|
|
33
|
+
available() {
|
|
34
|
+
return true; // keyless endpoint
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
async search(request, signal, options, hooks = {}) {
|
|
38
|
+
const args = {
|
|
39
|
+
objective: request.query,
|
|
40
|
+
search_queries: [request.query],
|
|
41
|
+
session_id: SESSION_ID,
|
|
42
|
+
model_name: "dsh-websearch",
|
|
43
|
+
};
|
|
44
|
+
hooks.recordRequest?.({
|
|
45
|
+
endpoint: PARALLEL_ENDPOINT,
|
|
46
|
+
toolName: PARALLEL_TOOL,
|
|
47
|
+
body: args,
|
|
48
|
+
headers: {},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
let result;
|
|
52
|
+
try {
|
|
53
|
+
result = await mcpCall(PARALLEL_ENDPOINT, PARALLEL_TOOL, args, {}, signal);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err instanceof WebError) throw err;
|
|
56
|
+
throw new WebError(
|
|
57
|
+
`parallel search failed: ${String(err?.message ?? err)}`,
|
|
58
|
+
"WEB_PROVIDER_ERROR",
|
|
59
|
+
{ cause: err },
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
hooks.recordOutcome?.({ status: "ok" });
|
|
64
|
+
const sources = parseParallelResult(result, request.maxResults ?? 8);
|
|
65
|
+
|
|
66
|
+
if (result?.isError === true) {
|
|
67
|
+
throw new WebError(
|
|
68
|
+
`parallel returned an MCP error: ${String(result.content?.[0]?.text ?? "unknown")}`,
|
|
69
|
+
"WEB_PROVIDER_ERROR",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { sources, content: undefined, truncated: false };
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export function parseParallelResult(result, maxResults) {
|
|
78
|
+
const text = result?.content?.[0]?.text;
|
|
79
|
+
if (typeof text !== "string" || text.length === 0) return [];
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(text);
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
const results = Array.isArray(parsed?.results) ? parsed.results : [];
|
|
87
|
+
const sources = [];
|
|
88
|
+
for (const r of results) {
|
|
89
|
+
if (typeof r?.url !== "string" || r.url.length === 0) continue;
|
|
90
|
+
const source = { url: r.url };
|
|
91
|
+
if (r.title && r.title.length > 0) source.title = r.title;
|
|
92
|
+
const excerpt = Array.isArray(r.excerpts) && r.excerpts.length > 0 ? r.excerpts[0] : undefined;
|
|
93
|
+
if (typeof excerpt === "string" && excerpt.length > 0) {
|
|
94
|
+
source.snippet = excerpt.length > 240 ? excerpt.slice(0, 240) + "…" : excerpt;
|
|
95
|
+
}
|
|
96
|
+
if (r.publish_date && r.publish_date !== "null" && r.publish_date.length > 0) {
|
|
97
|
+
source.publishedAt = r.publish_date;
|
|
98
|
+
}
|
|
99
|
+
sources.push(source);
|
|
100
|
+
if (sources.length >= maxResults) break;
|
|
101
|
+
}
|
|
102
|
+
return sources;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export default parallelBackend;
|