deepseek-web-retrieval 0.3.0 → 0.5.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/CHANGELOG.md +96 -230
- package/README.md +53 -25
- package/dist/cli.js +1358 -617
- package/docs/agents/codex.md +32 -11
- package/docs/agents/opencode-v2.md +5 -5
- package/docs/agents/pi.md +92 -90
- package/docs/design/installer-design.md +72 -9
- package/docs/hosts.jsonc +2 -2
- package/docs/integration.md +14 -9
- package/docs/research/control-matrix.md +48 -13
- package/docs/research/mcp-compatibility.md +9 -4
- package/docs/research/search-api-defaults.md +309 -0
- package/examples/pi.settings.jsonc +17 -0
- package/index.ts +2 -2
- package/package.json +35 -4
- package/src/core/auth.ts +4 -6
- package/src/core/client.ts +65 -53
- package/src/core/constants.ts +30 -24
- package/src/core/descriptions.ts +30 -6
- package/src/core/domains.ts +56 -67
- package/src/core/env.ts +112 -0
- package/src/core/format.ts +53 -50
- package/src/core/index.ts +9 -9
- package/src/core/jsonc.ts +6 -6
- package/src/core/object.ts +4 -0
- package/src/core/options.ts +21 -26
- package/src/core/search.ts +45 -44
- package/src/core/transport.ts +109 -113
- package/src/core/types.ts +47 -47
- package/src/core/url.ts +20 -20
- package/src/installer/apply.ts +256 -81
- package/src/installer/cli.ts +135 -104
- package/src/installer/detect.ts +46 -31
- package/src/installer/formats/index.ts +26 -28
- package/src/installer/formats/json.ts +81 -77
- package/src/installer/formats/rules.ts +55 -28
- package/src/installer/formats/toml.ts +88 -81
- package/src/installer/formats/value.ts +34 -30
- package/src/installer/hosts/codex.ts +100 -54
- package/src/installer/hosts/index.ts +126 -41
- package/src/installer/hosts/opencode.ts +70 -51
- package/src/installer/hosts/pi.ts +269 -75
- package/src/installer/hosts/shared.ts +19 -14
- package/src/installer/operation-lock.ts +54 -0
- package/src/installer/ownership.ts +129 -0
- package/src/installer/paths.ts +7 -5
- package/src/installer/persistence.ts +66 -0
- package/src/installer/render.ts +29 -29
- package/src/installer/revert.ts +33 -29
- package/src/installer/rulesContent.ts +24 -13
- package/src/installer/state.ts +116 -31
- package/src/installer/types.ts +74 -56
- package/src/installer/uninstall.ts +40 -17
- package/src/mcp/auth.ts +7 -8
- package/src/mcp/cli.ts +38 -20
- package/src/mcp/client.ts +35 -0
- package/src/mcp/server.ts +68 -97
- package/src/opencode/auth.ts +24 -26
- package/src/opencode/plugin.ts +99 -87
- package/src/pi/extension.ts +164 -0
- package/src/pi/settings.ts +49 -0
- package/src/mcp/config.ts +0 -124
package/src/core/client.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { FETCH_ENDPOINT, MAX_QUERY_CHARS, SEARCH_ENDPOINT } from "./constants"
|
|
2
|
-
import { assertFetchHostPolicy } from "./domains"
|
|
3
|
-
import { formatFetchedContent } from "./format"
|
|
4
|
-
import { buildQuery, processResults } from "./search"
|
|
5
|
-
import { postJson } from "./transport"
|
|
6
|
-
import { parseFetchUrl } from "./url"
|
|
1
|
+
import { FETCH_ENDPOINT, MAX_QUERY_CHARS, SEARCH_ENDPOINT } from "./constants";
|
|
2
|
+
import { assertFetchHostPolicy } from "./domains";
|
|
3
|
+
import { formatFetchedContent } from "./format";
|
|
4
|
+
import { buildQuery, processResults } from "./search";
|
|
5
|
+
import { postJson } from "./transport";
|
|
7
6
|
import type {
|
|
8
7
|
DeepSeekSearchResult,
|
|
9
8
|
FetchedDocument,
|
|
@@ -13,30 +12,33 @@ import type {
|
|
|
13
12
|
RetryOptions,
|
|
14
13
|
SearchOptions,
|
|
15
14
|
SearchResult,
|
|
16
|
-
} from "./types"
|
|
15
|
+
} from "./types";
|
|
16
|
+
import { parseFetchUrl } from "./url";
|
|
17
17
|
|
|
18
|
-
export { DeepSeekApiError } from "./transport"
|
|
18
|
+
export { DeepSeekApiError } from "./transport";
|
|
19
19
|
|
|
20
20
|
/** The shorter of the configured and per-call timeout always wins. */
|
|
21
21
|
function effectiveTimeout(configuredMs: number, perCallMs: number | undefined): number {
|
|
22
|
-
return perCallMs === undefined ? configuredMs : Math.min(configuredMs, perCallMs)
|
|
22
|
+
return perCallMs === undefined ? configuredMs : Math.min(configuredMs, perCallMs);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
interface SearchResponse {
|
|
26
|
-
readonly results?: unknown
|
|
26
|
+
readonly results?: unknown;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
interface FetchResponse {
|
|
30
|
-
readonly title?: unknown
|
|
31
|
-
readonly content?: unknown
|
|
30
|
+
readonly title?: unknown;
|
|
31
|
+
readonly content?: unknown;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export interface RequestOptions {
|
|
35
|
-
|
|
36
|
-
readonly
|
|
37
|
-
readonly
|
|
35
|
+
/** Optional per-request HTTP dependency; factories bind this once for an adapter. */
|
|
36
|
+
readonly fetchImpl?: typeof fetch;
|
|
37
|
+
readonly signal: AbortSignal;
|
|
38
|
+
readonly auth: RequestAuth;
|
|
39
|
+
readonly retry: RetryOptions;
|
|
38
40
|
/** Per-call cap in milliseconds; the shorter of this and the configured timeout wins. */
|
|
39
|
-
readonly timeoutMs?: number
|
|
41
|
+
readonly timeoutMs?: number;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
export async function searchWeb(
|
|
@@ -44,23 +46,23 @@ export async function searchWeb(
|
|
|
44
46
|
options: SearchOptions,
|
|
45
47
|
request: RequestOptions,
|
|
46
48
|
): Promise<SearchResult[]> {
|
|
47
|
-
const trimmed = query.trim()
|
|
48
|
-
if (!trimmed) throw new Error("DeepSeek search query must not be empty")
|
|
49
|
+
const trimmed = query.trim();
|
|
50
|
+
if (!trimmed) throw new Error("DeepSeek search query must not be empty");
|
|
49
51
|
if (Array.from(trimmed).length > MAX_QUERY_CHARS) {
|
|
50
|
-
throw new Error(`DeepSeek search query must be ${MAX_QUERY_CHARS} characters or fewer`)
|
|
52
|
+
throw new Error(`DeepSeek search query must be ${MAX_QUERY_CHARS} characters or fewer`);
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
const builtQuery = buildQuery(trimmed, options)
|
|
54
|
-
const builtQueryLength = Array.from(builtQuery).length
|
|
55
|
+
const builtQuery = buildQuery(trimmed, options);
|
|
56
|
+
const builtQueryLength = Array.from(builtQuery).length;
|
|
55
57
|
if (builtQueryLength > MAX_QUERY_CHARS) {
|
|
56
58
|
throw new Error(
|
|
57
59
|
`DeepSeek search query must be ${MAX_QUERY_CHARS} characters or fewer after applying ` +
|
|
58
60
|
`blockedDomains, includeDomains, and language ` +
|
|
59
61
|
`(built query is ${builtQueryLength} characters)`,
|
|
60
|
-
)
|
|
62
|
+
);
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
const timeout = effectiveTimeout(options.timeoutMs, request.timeoutMs)
|
|
65
|
+
const timeout = effectiveTimeout(options.timeoutMs, request.timeoutMs);
|
|
64
66
|
const payload = await postJson<SearchResponse>(
|
|
65
67
|
SEARCH_ENDPOINT,
|
|
66
68
|
{
|
|
@@ -69,43 +71,38 @@ export async function searchWeb(
|
|
|
69
71
|
need_content: false,
|
|
70
72
|
},
|
|
71
73
|
"DeepSeek Search",
|
|
72
|
-
{
|
|
73
|
-
)
|
|
74
|
+
{ ...request, timeoutMs: timeout },
|
|
75
|
+
);
|
|
74
76
|
|
|
75
77
|
if (!Array.isArray(payload.results)) {
|
|
76
|
-
throw new Error("DeepSeek Search returned a response without a results array")
|
|
78
|
+
throw new Error("DeepSeek Search returned a response without a results array");
|
|
77
79
|
}
|
|
78
|
-
return processResults(payload.results as DeepSeekSearchResult[], options)
|
|
80
|
+
return processResults(payload.results as DeepSeekSearchResult[], options);
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
export async function fetchPage(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
"DeepSeek Fetch",
|
|
94
|
-
{ signal: request.signal, auth: request.auth, retry: request.retry, timeoutMs: timeout },
|
|
95
|
-
)
|
|
96
|
-
const title = typeof payload.title === "string" ? payload.title.trim() : ""
|
|
97
|
-
const content = typeof payload.content === "string" ? payload.content : ""
|
|
83
|
+
export async function fetchPage(url: URL, options: FetchOptions, request: RequestOptions): Promise<FetchedPage> {
|
|
84
|
+
// A URL object is parsed, not necessarily safe: direct core callers must pass
|
|
85
|
+
// the same protocol, credentials, length and host checks as string callers.
|
|
86
|
+
const target = parseFetchUrl(url.href);
|
|
87
|
+
assertFetchHostPolicy(target, options);
|
|
88
|
+
const timeout = effectiveTimeout(options.timeoutMs, request.timeoutMs);
|
|
89
|
+
const payload = await postJson<FetchResponse>(FETCH_ENDPOINT, { url: target.href }, "DeepSeek Fetch", {
|
|
90
|
+
...request,
|
|
91
|
+
timeoutMs: timeout,
|
|
92
|
+
});
|
|
93
|
+
const title = typeof payload.title === "string" ? payload.title.trim() : "";
|
|
94
|
+
const content = typeof payload.content === "string" ? payload.content : "";
|
|
98
95
|
if (!content.trim()) {
|
|
99
|
-
throw new Error("DeepSeek Fetch returned no page content (the page may be empty or unavailable)")
|
|
96
|
+
throw new Error("DeepSeek Fetch returned no page content (the page may be empty or unavailable)");
|
|
100
97
|
}
|
|
101
|
-
return { url:
|
|
98
|
+
return { url: target.href, title, content };
|
|
102
99
|
}
|
|
103
100
|
|
|
104
101
|
/** Parses the URL and enforces the host policy in one call, returning the fetch target. */
|
|
105
102
|
export function inspectFetchTarget(url: string, options: FetchOptions): URL {
|
|
106
|
-
const target = parseFetchUrl(url)
|
|
107
|
-
assertFetchHostPolicy(target, options)
|
|
108
|
-
return target
|
|
103
|
+
const target = parseFetchUrl(url);
|
|
104
|
+
assertFetchHostPolicy(target, options);
|
|
105
|
+
return target;
|
|
109
106
|
}
|
|
110
107
|
|
|
111
108
|
/**
|
|
@@ -117,13 +114,28 @@ export async function fetchDocument(
|
|
|
117
114
|
options: FetchOptions,
|
|
118
115
|
request: RequestOptions,
|
|
119
116
|
): Promise<FetchedDocument> {
|
|
120
|
-
if (typeof url !== "string" && !(url instanceof URL)) throw new Error('"url" must be a string or URL')
|
|
121
|
-
const page = await fetchPage(url instanceof URL ? url : parseFetchUrl(url), options, request)
|
|
122
|
-
const formatted = formatFetchedContent(page.title, page.content, options.maxChars)
|
|
117
|
+
if (typeof url !== "string" && !(url instanceof URL)) throw new Error('"url" must be a string or URL');
|
|
118
|
+
const page = await fetchPage(url instanceof URL ? url : parseFetchUrl(url), options, request);
|
|
119
|
+
const formatted = formatFetchedContent(page.title, page.content, options.maxChars);
|
|
123
120
|
return {
|
|
124
121
|
url: page.url,
|
|
125
122
|
title: page.title,
|
|
126
123
|
content: formatted.content,
|
|
127
124
|
truncated: formatted.truncated,
|
|
128
|
-
}
|
|
125
|
+
};
|
|
129
126
|
}
|
|
127
|
+
|
|
128
|
+
/** One client per adapter/test; transport overrides never mutate process globals. */
|
|
129
|
+
export function createRetrievalClient(dependencies: { readonly fetchImpl?: typeof fetch } = {}) {
|
|
130
|
+
const bind = (request: RequestOptions): RequestOptions => ({ ...request, ...dependencies });
|
|
131
|
+
return {
|
|
132
|
+
searchWeb: (query: string, options: SearchOptions, request: RequestOptions) =>
|
|
133
|
+
searchWeb(query, options, bind(request)),
|
|
134
|
+
fetchPage: (url: URL, options: FetchOptions, request: RequestOptions) => fetchPage(url, options, bind(request)),
|
|
135
|
+
fetchDocument: (url: string | URL, options: FetchOptions, request: RequestOptions) =>
|
|
136
|
+
fetchDocument(url, options, bind(request)),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type RetrievalClient = ReturnType<typeof createRetrievalClient>;
|
|
141
|
+
export const retrievalClient = createRetrievalClient();
|
package/src/core/constants.ts
CHANGED
|
@@ -1,30 +1,36 @@
|
|
|
1
|
-
export const SEARCH_ENDPOINT = "https://api.deepseek.com/v1/tools/search"
|
|
2
|
-
export const FETCH_ENDPOINT = "https://api.deepseek.com/v1/tools/fetch"
|
|
1
|
+
export const SEARCH_ENDPOINT = "https://api.deepseek.com/v1/tools/search";
|
|
2
|
+
export const FETCH_ENDPOINT = "https://api.deepseek.com/v1/tools/fetch";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Search defaults are benchmarked against surveyed AI-native search APIs and
|
|
6
|
+
* DeepSeek's own factory defaults; see docs/research/search-api-defaults.md.
|
|
7
|
+
* Result caps cluster at 10 across vendors, and no surveyed vendor filters
|
|
8
|
+
* results client-side by score or authority — thresholds stay opt-in.
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_SEARCH_COUNT = 10;
|
|
11
|
+
export const DEFAULT_MAX_RESULTS = 10;
|
|
12
|
+
export const DEFAULT_MIN_RERANK = 0;
|
|
13
|
+
export const DEFAULT_MIN_AUTHORITY = 0;
|
|
14
|
+
export const DEFAULT_MAX_SUMMARY_CHARS = 1200;
|
|
9
15
|
|
|
10
|
-
export const DEFAULT_FETCH_MAX_CHARS = 16000
|
|
11
|
-
export const DEFAULT_TIMEOUT_MS = 30000
|
|
12
|
-
export const MIN_TIMEOUT_MS = 1000
|
|
13
|
-
export const MAX_TIMEOUT_MS = 120000
|
|
16
|
+
export const DEFAULT_FETCH_MAX_CHARS = 16000;
|
|
17
|
+
export const DEFAULT_TIMEOUT_MS = 30000;
|
|
18
|
+
export const MIN_TIMEOUT_MS = 1000;
|
|
19
|
+
export const MAX_TIMEOUT_MS = 120000;
|
|
14
20
|
|
|
15
|
-
export const MAX_QUERY_CHARS = 128
|
|
16
|
-
export const MAX_URL_CHARS = 8192
|
|
17
|
-
export const MAX_ERROR_BODY_CHARS = 2000
|
|
18
|
-
export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
|
|
19
|
-
export const FETCH_TRUNCATION_MARKER = "\n\n[content truncated]"
|
|
21
|
+
export const MAX_QUERY_CHARS = 128;
|
|
22
|
+
export const MAX_URL_CHARS = 8192;
|
|
23
|
+
export const MAX_ERROR_BODY_CHARS = 2000;
|
|
24
|
+
export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
|
|
25
|
+
export const FETCH_TRUNCATION_MARKER = "\n\n[content truncated]";
|
|
20
26
|
|
|
21
|
-
export const DEFAULT_API_KEY_SOURCE = "auto"
|
|
22
|
-
export const DEFAULT_MAX_RETRIES = 0
|
|
23
|
-
export const MAX_RETRIES = 5
|
|
24
|
-
export const DEFAULT_RETRY_BASE_DELAY_MS = 250
|
|
25
|
-
export const MAX_RETRY_DELAY_MS = 10000
|
|
27
|
+
export const DEFAULT_API_KEY_SOURCE = "auto";
|
|
28
|
+
export const DEFAULT_MAX_RETRIES = 0;
|
|
29
|
+
export const MAX_RETRIES = 5;
|
|
30
|
+
export const DEFAULT_RETRY_BASE_DELAY_MS = 250;
|
|
31
|
+
export const MAX_RETRY_DELAY_MS = 10000;
|
|
26
32
|
|
|
27
33
|
/** The single source for every DEEPSEEK_* environment variable name. */
|
|
28
|
-
export const API_KEY_ENV = "DEEPSEEK_API_KEY"
|
|
29
|
-
export const ENABLE_SEARCH_ENV = "DEEPSEEK_ENABLE_SEARCH"
|
|
30
|
-
export const ENABLE_FETCH_ENV = "DEEPSEEK_ENABLE_FETCH"
|
|
34
|
+
export const API_KEY_ENV = "DEEPSEEK_API_KEY";
|
|
35
|
+
export const ENABLE_SEARCH_ENV = "DEEPSEEK_ENABLE_SEARCH";
|
|
36
|
+
export const ENABLE_FETCH_ENV = "DEEPSEEK_ENABLE_FETCH";
|
package/src/core/descriptions.ts
CHANGED
|
@@ -4,16 +4,40 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
export const FETCH_SERVICE_LIMITS =
|
|
7
|
-
"DeepSeek does not render JavaScript and cannot reach private networks; PDFs, images, and other binary files are unsupported."
|
|
7
|
+
"DeepSeek does not render JavaScript and cannot reach private networks; PDFs, images, and other binary files are unsupported.";
|
|
8
8
|
|
|
9
|
-
export const FETCH_OUTPUT_TRUNCATED = "Output is truncated to the configured size."
|
|
9
|
+
export const FETCH_OUTPUT_TRUNCATED = "Output is truncated to the configured size.";
|
|
10
10
|
|
|
11
|
-
export const FETCH_MARKDOWN_ONLY = 'Only format "markdown" is supported.'
|
|
11
|
+
export const FETCH_MARKDOWN_ONLY = 'Only format "markdown" is supported.';
|
|
12
12
|
|
|
13
13
|
export const SEARCH_RANKED_FILTERED =
|
|
14
|
-
"Results are ranked by relevance and source authority, deduplicated by URL, and capped at the configured maximum."
|
|
14
|
+
"Results are ranked by relevance and source authority, deduplicated by URL, and capped at the configured maximum.";
|
|
15
15
|
|
|
16
|
-
export const SEARCH_EMPTY_IS_NORMAL = "An empty result list is normal, not an error."
|
|
16
|
+
export const SEARCH_EMPTY_IS_NORMAL = "An empty result list is normal, not an error.";
|
|
17
17
|
|
|
18
18
|
export const SEARCH_QUERY_OPERATORS =
|
|
19
|
-
"Use site:/-site: operators in the query to include or exclude domains; the full query must stay within 128 characters."
|
|
19
|
+
"Use site:/-site: operators in the query to include or exclude domains; the full query must stay within 128 characters.";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The full tool descriptions, composed from the fact fragments. Every host
|
|
23
|
+
* adapter registers its tools with these verbatim — the MCP server and the Pi
|
|
24
|
+
* extension must never drift on what a tool does.
|
|
25
|
+
*/
|
|
26
|
+
export const SEARCH_TOOL_DESCRIPTION =
|
|
27
|
+
"Search the web with DeepSeek's in-house search engine and return a short, filtered result " +
|
|
28
|
+
"list (title, URL, summary). " +
|
|
29
|
+
`${SEARCH_RANKED_FILTERED} ` +
|
|
30
|
+
`${SEARCH_EMPTY_IS_NORMAL} ` +
|
|
31
|
+
`${SEARCH_QUERY_OPERATORS} ` +
|
|
32
|
+
"To read a full page, pass a result URL to the deepseek_fetch tool.";
|
|
33
|
+
|
|
34
|
+
export const FETCH_TOOL_DESCRIPTION =
|
|
35
|
+
"Fetch one web page as clean Markdown through DeepSeek's Fetch API. " +
|
|
36
|
+
`${FETCH_SERVICE_LIMITS} ` +
|
|
37
|
+
`${FETCH_OUTPUT_TRUNCATED}`;
|
|
38
|
+
|
|
39
|
+
/** Parameter descriptions shared by every adapter's input schema for the two tools. */
|
|
40
|
+
export const SEARCH_QUERY_PARAMETER =
|
|
41
|
+
"Search query, 128 characters or fewer including operators (site:, -site:, quoted phrases, lang:).";
|
|
42
|
+
|
|
43
|
+
export const FETCH_URL_PARAMETER = "Absolute http:// or https:// URL to fetch. Credentials are rejected.";
|
package/src/core/domains.ts
CHANGED
|
@@ -1,30 +1,25 @@
|
|
|
1
|
-
import { isIP } from "node:net"
|
|
2
|
-
import type { FetchOptions } from "./types"
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import type { FetchOptions } from "./types";
|
|
3
3
|
|
|
4
4
|
export function normalizeDomain(value: string): string | undefined {
|
|
5
|
-
const trimmed = value.trim()
|
|
6
|
-
if (!trimmed || /\s/.test(trimmed)) return undefined
|
|
7
|
-
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
|
|
8
|
-
if (hasScheme && !/^https?:\/\//i.test(trimmed)) return undefined
|
|
5
|
+
const trimmed = value.trim();
|
|
6
|
+
if (!trimmed || /\s/.test(trimmed)) return undefined;
|
|
7
|
+
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed);
|
|
8
|
+
if (hasScheme && !/^https?:\/\//i.test(trimmed)) return undefined;
|
|
9
9
|
|
|
10
|
-
let hostname: string
|
|
10
|
+
let hostname: string;
|
|
11
11
|
try {
|
|
12
12
|
// Parsing with a synthetic scheme also strips paths, ports, and userinfo.
|
|
13
|
-
hostname = new URL(hasScheme ? trimmed : `http://${trimmed}`).hostname
|
|
13
|
+
hostname = new URL(hasScheme ? trimmed : `http://${trimmed}`).hostname;
|
|
14
14
|
} catch {
|
|
15
|
-
return undefined
|
|
15
|
+
return undefined;
|
|
16
16
|
}
|
|
17
|
-
return normalizeHostname(hostname)
|
|
17
|
+
return normalizeHostname(hostname);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/** Lowercases a URL hostname and removes brackets and trailing root-label dots. */
|
|
21
21
|
export function normalizeHostname(value: string): string {
|
|
22
|
-
return value
|
|
23
|
-
.trim()
|
|
24
|
-
.toLowerCase()
|
|
25
|
-
.replace(/^\[/, "")
|
|
26
|
-
.replace(/\]$/, "")
|
|
27
|
-
.replace(/\.+$/, "")
|
|
22
|
+
return value.trim().toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/\.+$/, "");
|
|
28
23
|
}
|
|
29
24
|
|
|
30
25
|
/**
|
|
@@ -32,21 +27,21 @@ export function normalizeHostname(value: string): string {
|
|
|
32
27
|
* but not `notexample.com` or `example.com.evil.test`.
|
|
33
28
|
*/
|
|
34
29
|
export function domainMatches(hostname: string, domain: string): boolean {
|
|
35
|
-
const host = normalizeHostname(hostname)
|
|
36
|
-
const target = normalizeHostname(domain)
|
|
37
|
-
if (!host || !target) return false
|
|
38
|
-
return host === target || host.endsWith(`.${target}`)
|
|
30
|
+
const host = normalizeHostname(hostname);
|
|
31
|
+
const target = normalizeHostname(domain);
|
|
32
|
+
if (!host || !target) return false;
|
|
33
|
+
return host === target || host.endsWith(`.${target}`);
|
|
39
34
|
}
|
|
40
35
|
|
|
41
36
|
export function normalizeDomains(values: unknown): string[] {
|
|
42
|
-
if (!Array.isArray(values)) return []
|
|
43
|
-
const domains = new Set<string>()
|
|
37
|
+
if (!Array.isArray(values)) return [];
|
|
38
|
+
const domains = new Set<string>();
|
|
44
39
|
for (const value of values) {
|
|
45
|
-
if (typeof value !== "string") continue
|
|
46
|
-
const domain = normalizeDomain(value)
|
|
47
|
-
if (domain) domains.add(domain)
|
|
40
|
+
if (typeof value !== "string") continue;
|
|
41
|
+
const domain = normalizeDomain(value);
|
|
42
|
+
if (domain) domains.add(domain);
|
|
48
43
|
}
|
|
49
|
-
return [...domains]
|
|
44
|
+
return [...domains];
|
|
50
45
|
}
|
|
51
46
|
|
|
52
47
|
/**
|
|
@@ -55,9 +50,9 @@ export function normalizeDomains(values: unknown): string[] {
|
|
|
55
50
|
* to a remote service and fails early instead of producing a confusing 404.
|
|
56
51
|
*/
|
|
57
52
|
export function isPrivateHostname(value: string): boolean {
|
|
58
|
-
const host = normalizeHostname(value)
|
|
59
|
-
if (!host) return true
|
|
60
|
-
if (host === "localhost" || host === "local") return true
|
|
53
|
+
const host = normalizeHostname(value);
|
|
54
|
+
if (!host) return true;
|
|
55
|
+
if (host === "localhost" || host === "local") return true;
|
|
61
56
|
if (
|
|
62
57
|
host.endsWith(".localhost") ||
|
|
63
58
|
host.endsWith(".local") ||
|
|
@@ -65,48 +60,43 @@ export function isPrivateHostname(value: string): boolean {
|
|
|
65
60
|
host.endsWith(".lan") ||
|
|
66
61
|
host.endsWith(".home.arpa")
|
|
67
62
|
) {
|
|
68
|
-
return true
|
|
63
|
+
return true;
|
|
69
64
|
}
|
|
70
65
|
|
|
71
|
-
const version = isIP(host)
|
|
72
|
-
if (version === 4) return isPrivateIpv4(host)
|
|
73
|
-
if (version === 6) return isPrivateIpv6(host)
|
|
74
|
-
return false
|
|
66
|
+
const version = isIP(host);
|
|
67
|
+
if (version === 4) return isPrivateIpv4(host);
|
|
68
|
+
if (version === 6) return isPrivateIpv6(host);
|
|
69
|
+
return false;
|
|
75
70
|
}
|
|
76
71
|
|
|
77
72
|
export function assertFetchHostPolicy(
|
|
78
73
|
url: URL,
|
|
79
74
|
options: Pick<FetchOptions, "allowedDomains" | "blockedDomains" | "blockPrivateHosts">,
|
|
80
75
|
): void {
|
|
81
|
-
const host = normalizeHostname(url.hostname)
|
|
76
|
+
const host = normalizeHostname(url.hostname);
|
|
82
77
|
|
|
83
78
|
if (options.blockPrivateHosts && isPrivateHostname(host)) {
|
|
84
79
|
throw new Error(
|
|
85
80
|
`Fetch blocked for ${host}: local/private addresses are not reachable by DeepSeek. ` +
|
|
86
81
|
"Set fetchBlockPrivateHosts=false only if you really need to send the URL.",
|
|
87
|
-
)
|
|
82
|
+
);
|
|
88
83
|
}
|
|
89
84
|
|
|
90
|
-
const blocked = options.blockedDomains.find((domain) => domainMatches(host, domain))
|
|
85
|
+
const blocked = options.blockedDomains.find((domain) => domainMatches(host, domain));
|
|
91
86
|
if (blocked) {
|
|
92
|
-
throw new Error(`Fetch blocked for ${host}: matched fetchBlockedDomains entry "${blocked}"`)
|
|
87
|
+
throw new Error(`Fetch blocked for ${host}: matched fetchBlockedDomains entry "${blocked}"`);
|
|
93
88
|
}
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
!options.allowedDomains.some((domain) => domainMatches(host, domain))
|
|
97
|
-
) {
|
|
98
|
-
throw new Error(
|
|
99
|
-
`Fetch not allowed for ${host}: add it to fetchAllowedDomains (exact host or subdomain)`,
|
|
100
|
-
)
|
|
89
|
+
if (options.allowedDomains.length > 0 && !options.allowedDomains.some((domain) => domainMatches(host, domain))) {
|
|
90
|
+
throw new Error(`Fetch not allowed for ${host}: add it to fetchAllowedDomains (exact host or subdomain)`);
|
|
101
91
|
}
|
|
102
92
|
}
|
|
103
93
|
|
|
104
94
|
function isPrivateIpv4(host: string): boolean {
|
|
105
|
-
const parts = host.split(".").map((part) => Number(part))
|
|
95
|
+
const parts = host.split(".").map((part) => Number(part));
|
|
106
96
|
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
107
|
-
return true
|
|
97
|
+
return true;
|
|
108
98
|
}
|
|
109
|
-
const [a, b] = parts
|
|
99
|
+
const [a, b] = parts;
|
|
110
100
|
return (
|
|
111
101
|
a === 0 ||
|
|
112
102
|
a === 10 ||
|
|
@@ -117,37 +107,36 @@ function isPrivateIpv4(host: string): boolean {
|
|
|
117
107
|
(a === 192 && b === 168) ||
|
|
118
108
|
(a === 198 && (b === 18 || b === 19)) ||
|
|
119
109
|
a >= 224
|
|
120
|
-
)
|
|
110
|
+
);
|
|
121
111
|
}
|
|
122
112
|
|
|
123
113
|
function isPrivateIpv6(host: string): boolean {
|
|
124
|
-
const value = host.toLowerCase()
|
|
125
|
-
if (value === "::" || value === "::1") return true
|
|
126
|
-
if (value.startsWith("fc") || value.startsWith("fd")) return true
|
|
127
|
-
if (/^fe[89ab]/.test(value)) return true
|
|
114
|
+
const value = host.toLowerCase();
|
|
115
|
+
if (value === "::" || value === "::1") return true;
|
|
116
|
+
if (value.startsWith("fc") || value.startsWith("fd")) return true;
|
|
117
|
+
if (/^fe[89ab]/.test(value)) return true;
|
|
128
118
|
|
|
129
119
|
// IPv4-mapped IPv6, e.g. ::ffff:127.0.0.1. URL parsers normalize the dotted
|
|
130
120
|
// form to hex (`::ffff:7f00:1`), so both representations must be handled.
|
|
131
|
-
const dottedMapped = value.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1]
|
|
132
|
-
if (dottedMapped) return isPrivateIpv4(dottedMapped)
|
|
121
|
+
const dottedMapped = value.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/)?.[1];
|
|
122
|
+
if (dottedMapped) return isPrivateIpv4(dottedMapped);
|
|
133
123
|
|
|
134
|
-
const mapped = ipv4FromIpv6Hextets(value, /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
|
|
135
|
-
if (mapped) return isPrivateIpv4(mapped)
|
|
124
|
+
const mapped = ipv4FromIpv6Hextets(value, /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
125
|
+
if (mapped) return isPrivateIpv4(mapped);
|
|
136
126
|
|
|
137
127
|
// IPv4-compatible IPv6, e.g. ::127.0.0.1, which URL parsers normalize to
|
|
138
128
|
// ::7f00:1.
|
|
139
|
-
const compatible = ipv4FromIpv6Hextets(value, /^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
|
|
140
|
-
if (compatible) return isPrivateIpv4(compatible)
|
|
129
|
+
const compatible = ipv4FromIpv6Hextets(value, /^::([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
130
|
+
if (compatible) return isPrivateIpv4(compatible);
|
|
141
131
|
|
|
142
|
-
return false
|
|
132
|
+
return false;
|
|
143
133
|
}
|
|
144
134
|
|
|
145
135
|
function ipv4FromIpv6Hextets(value: string, pattern: RegExp): string | undefined {
|
|
146
|
-
const match = value.match(pattern)
|
|
147
|
-
if (!match) return undefined
|
|
148
|
-
const high = Number.parseInt(match[1], 16)
|
|
149
|
-
const low = Number.parseInt(match[2], 16)
|
|
150
|
-
if (!Number.isFinite(high) || !Number.isFinite(low)) return undefined
|
|
151
|
-
return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}
|
|
136
|
+
const match = value.match(pattern);
|
|
137
|
+
if (!match) return undefined;
|
|
138
|
+
const high = Number.parseInt(match[1], 16);
|
|
139
|
+
const low = Number.parseInt(match[2], 16);
|
|
140
|
+
if (!Number.isFinite(high) || !Number.isFinite(low)) return undefined;
|
|
141
|
+
return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
|
|
152
142
|
}
|
|
153
|
-
|
package/src/core/env.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { ENABLE_FETCH_ENV, ENABLE_SEARCH_ENV } from "./constants";
|
|
2
|
+
import { resolvePluginOptions } from "./options";
|
|
3
|
+
import type { PluginOptions, ResolvedOptions } from "./types";
|
|
4
|
+
|
|
5
|
+
/** The environment an adapter reads its key and options from. */
|
|
6
|
+
export type Env = Readonly<Record<string, string | undefined>>;
|
|
7
|
+
|
|
8
|
+
/** Each supported option mapped onto its DEEPSEEK_* variable, grouped by value type. */
|
|
9
|
+
const NUMBER_ENV = {
|
|
10
|
+
count: "DEEPSEEK_SEARCH_COUNT",
|
|
11
|
+
maxResults: "DEEPSEEK_SEARCH_MAX_RESULTS",
|
|
12
|
+
minRerank: "DEEPSEEK_SEARCH_MIN_RERANK",
|
|
13
|
+
minAuthority: "DEEPSEEK_SEARCH_MIN_AUTHORITY",
|
|
14
|
+
maxSummaryChars: "DEEPSEEK_SEARCH_MAX_SUMMARY_CHARS",
|
|
15
|
+
searchTimeoutMs: "DEEPSEEK_SEARCH_TIMEOUT_MS",
|
|
16
|
+
fetchMaxChars: "DEEPSEEK_FETCH_MAX_CHARS",
|
|
17
|
+
fetchTimeoutMs: "DEEPSEEK_FETCH_TIMEOUT_MS",
|
|
18
|
+
maxRetries: "DEEPSEEK_MAX_RETRIES",
|
|
19
|
+
retryBaseDelayMs: "DEEPSEEK_RETRY_BASE_DELAY_MS",
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
const TEXT_ENV = {
|
|
23
|
+
language: "DEEPSEEK_SEARCH_LANGUAGE",
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
const DOMAINS_ENV = {
|
|
27
|
+
blockedDomains: "DEEPSEEK_SEARCH_BLOCKED_DOMAINS",
|
|
28
|
+
includeDomains: "DEEPSEEK_SEARCH_INCLUDE_DOMAINS",
|
|
29
|
+
fetchAllowedDomains: "DEEPSEEK_FETCH_ALLOWED_DOMAINS",
|
|
30
|
+
fetchBlockedDomains: "DEEPSEEK_FETCH_BLOCKED_DOMAINS",
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
/** Shared defaults are true; only an explicit false value disables them. */
|
|
34
|
+
const FALSE_ONLY_ENV = {
|
|
35
|
+
allowUnknownAuthority: "DEEPSEEK_SEARCH_ALLOW_UNKNOWN_AUTHORITY",
|
|
36
|
+
fetchBlockPrivateHosts: "DEEPSEEK_FETCH_BLOCK_PRIVATE_HOSTS",
|
|
37
|
+
searchEnabled: ENABLE_SEARCH_ENV,
|
|
38
|
+
fetchEnabled: ENABLE_FETCH_ENV,
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Maps `DEEPSEEK_*` environment variables onto the shared plugin option keys and
|
|
43
|
+
* resolves them through the shared pipeline, so defaults and clamping stay
|
|
44
|
+
* identical across every adapter. Invalid values are dropped and the shared
|
|
45
|
+
* default applies.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveEnvOptions(env: Env = process.env): ResolvedOptions {
|
|
48
|
+
return resolvePluginOptions(pluginOptionsFromEnv(env));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function pluginOptionsFromEnv(env: Env): PluginOptions {
|
|
52
|
+
const options: Record<string, unknown> = {};
|
|
53
|
+
|
|
54
|
+
putNumber(options, "count", env);
|
|
55
|
+
putNumber(options, "maxResults", env);
|
|
56
|
+
putNumber(options, "minRerank", env);
|
|
57
|
+
putNumber(options, "minAuthority", env);
|
|
58
|
+
putFalseOnly(options, "allowUnknownAuthority", env);
|
|
59
|
+
putDomains(options, "blockedDomains", env);
|
|
60
|
+
putDomains(options, "includeDomains", env);
|
|
61
|
+
putText(options, "language", env);
|
|
62
|
+
putNumber(options, "maxSummaryChars", env);
|
|
63
|
+
putNumber(options, "searchTimeoutMs", env);
|
|
64
|
+
|
|
65
|
+
putNumber(options, "fetchMaxChars", env);
|
|
66
|
+
putDomains(options, "fetchAllowedDomains", env);
|
|
67
|
+
putDomains(options, "fetchBlockedDomains", env);
|
|
68
|
+
putNumber(options, "fetchTimeoutMs", env);
|
|
69
|
+
putFalseOnly(options, "fetchBlockPrivateHosts", env);
|
|
70
|
+
putFalseOnly(options, "searchEnabled", env);
|
|
71
|
+
putFalseOnly(options, "fetchEnabled", env);
|
|
72
|
+
|
|
73
|
+
putNumber(options, "maxRetries", env);
|
|
74
|
+
putNumber(options, "retryBaseDelayMs", env);
|
|
75
|
+
|
|
76
|
+
return options;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function putNumber(target: Record<string, unknown>, key: keyof typeof NUMBER_ENV, env: Env): void {
|
|
80
|
+
const value = env[NUMBER_ENV[key]];
|
|
81
|
+
if (value === undefined) return;
|
|
82
|
+
const trimmed = value.trim();
|
|
83
|
+
if (!trimmed) return;
|
|
84
|
+
const parsed = Number(trimmed);
|
|
85
|
+
if (Number.isFinite(parsed)) target[key] = parsed;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function putText(target: Record<string, unknown>, key: keyof typeof TEXT_ENV, env: Env): void {
|
|
89
|
+
const value = env[TEXT_ENV[key]];
|
|
90
|
+
if (value === undefined) return;
|
|
91
|
+
const trimmed = value.trim();
|
|
92
|
+
if (trimmed) target[key] = trimmed;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function putFalseOnly(target: Record<string, unknown>, key: keyof typeof FALSE_ONLY_ENV, env: Env): void {
|
|
96
|
+
if (isFalseValue(env[FALSE_ONLY_ENV[key]])) target[key] = false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function putDomains(target: Record<string, unknown>, key: keyof typeof DOMAINS_ENV, env: Env): void {
|
|
100
|
+
const value = env[DOMAINS_ENV[key]];
|
|
101
|
+
if (value === undefined || !value.trim()) return;
|
|
102
|
+
const domains = value
|
|
103
|
+
.split(",")
|
|
104
|
+
.map((domain) => domain.trim())
|
|
105
|
+
.filter((domain) => domain.length > 0);
|
|
106
|
+
if (domains.length > 0) target[key] = domains;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isFalseValue(value: string | undefined): boolean {
|
|
110
|
+
const trimmed = value?.trim().toLowerCase();
|
|
111
|
+
return trimmed === "false" || trimmed === "0" || trimmed === "no" || trimmed === "off";
|
|
112
|
+
}
|