deepseek-web-retrieval 0.7.0 → 0.9.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 +20 -0
- package/README.md +100 -101
- package/dist/cli.js +526 -175
- package/docs/agents/claude-code.md +279 -0
- package/docs/agents/codex.md +9 -1
- package/docs/agents/opencode-v2.md +13 -3
- package/docs/agents/pi.md +8 -1
- package/docs/configuration.md +116 -0
- package/docs/design/installer-design.md +20 -17
- package/docs/hosts.jsonc +5 -1
- package/docs/integration.md +9 -6
- package/docs/research/control-matrix.md +34 -9
- package/docs/research/mcp-compatibility.md +2 -2
- package/docs/research/ori-question-sourcing.md +12 -1
- package/package.json +3 -2
- package/src/core/auth.ts +7 -0
- package/src/core/client.ts +89 -25
- package/src/core/config-summary.ts +1 -0
- package/src/core/constants.ts +17 -2
- package/src/core/descriptions.ts +10 -1
- package/src/core/env.ts +12 -7
- package/src/core/format.ts +25 -4
- package/src/core/options.ts +47 -0
- package/src/core/search.ts +97 -14
- package/src/core/types.ts +17 -0
- package/src/installer/apply.ts +4 -4
- package/src/installer/cli.ts +2 -2
- package/src/installer/config-command.ts +4 -3
- package/src/installer/hosts/claude-code.ts +311 -0
- package/src/installer/hosts/codex.ts +3 -14
- package/src/installer/hosts/index.ts +5 -2
- package/src/installer/hosts/opencode.ts +1 -1
- package/src/installer/hosts/pi.ts +38 -18
- package/src/installer/hosts/shared.ts +23 -0
- package/src/installer/ownership.ts +2 -2
- package/src/installer/paths.ts +2 -2
- package/src/installer/{state.ts → record.ts} +16 -16
- package/src/installer/types.ts +2 -2
- package/src/installer/uninstall.ts +8 -5
- package/src/mcp/serve.ts +1 -2
- package/src/mcp/server.ts +16 -19
- package/src/opencode/plugin.ts +15 -10
- package/src/pi/extension.ts +23 -17
- package/src/mcp/auth.ts +0 -11
|
@@ -71,8 +71,9 @@ Reading notes:
|
|
|
71
71
|
One section per host, in hosts.jsonc order. Each carries what an install needs beyond the
|
|
72
72
|
matrix: the built-in competitors, their kill switches, how the MCP server is registered,
|
|
73
73
|
and which rules files steer preference. The `init` CLI implements these tiers for
|
|
74
|
-
OpenCode, Pi, and
|
|
75
|
-
paths imply the available
|
|
74
|
+
OpenCode, Pi, Codex, and Claude Code (see
|
|
75
|
+
[installer design](../design/installer-design.md)). Config paths imply the available
|
|
76
|
+
scopes; keys locked to a single scope are enumerated in the
|
|
76
77
|
[installer design](../design/installer-design.md#scope).
|
|
77
78
|
|
|
78
79
|
### Cline
|
|
@@ -209,15 +210,31 @@ paths imply the available scopes; keys locked to a single scope are enumerated i
|
|
|
209
210
|
- **Built-in web tools** — `WebSearch` and `WebFetch`. Kill switch: bare deny rules —
|
|
210
211
|
`"permissions": {"deny": ["WebSearch", "WebFetch"]}` — remove the tools from the model's
|
|
211
212
|
context entirely; deny outranks allow at every settings level and cannot be carved out.
|
|
212
|
-
|
|
213
|
-
|
|
213
|
+
Permission rules live in the settings file of each level: managed
|
|
214
|
+
`managed-settings.json` (Linux `/etc/claude-code/`, macOS
|
|
215
|
+
`/Library/Application Support/ClaudeCode/`, Windows
|
|
216
|
+
`C:\Program Files\ClaudeCode\` — the legacy `C:\ProgramData\ClaudeCode\` path
|
|
217
|
+
is not read) →
|
|
218
|
+
user `~/.claude/settings.json` → project `.claude/settings.json` → local
|
|
219
|
+
`.claude/settings.local.json`; the installer writes the chosen scope's settings file
|
|
220
|
+
and reports an existing managed layer. A bare `mcp__<server>` rule denies every tool
|
|
221
|
+
of one MCP server — the per-server kill switch the installer uses for web-capable
|
|
222
|
+
competitors under the forced tier. `--disallowedTools` is the flag form.
|
|
223
|
+
`CLAUDE_CODE_DISABLE_WEB_FETCH=1` (2.1.285+) kills WebFetch only; there is no env
|
|
224
|
+
equivalent for WebSearch
|
|
214
225
|
([tools](https://code.claude.com/docs/en/tools),
|
|
215
|
-
[permissions](https://code.claude.com/docs/en/permissions)
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
226
|
+
[permissions](https://code.claude.com/docs/en/permissions),
|
|
227
|
+
[settings](https://code.claude.com/docs/en/settings)).
|
|
228
|
+
- **MCP registration** — three scopes across two files: project `.mcp.json` at the
|
|
229
|
+
project root (team-shared, first-use approval prompt per project), local scope under
|
|
230
|
+
the project's entry in `~/.claude.json` (private, the `claude mcp add` default), and
|
|
231
|
+
user scope at the top-level `mcpServers` key of `~/.claude.json` (`--scope user`).
|
|
232
|
+
Entries take `mcpServers` with `command`/`args`/`env`, or `type: "http"` + `url`;
|
|
233
|
+
`${VAR}` expansion in `command`/`args`/`env`/`url`/`headers`. Tool permission rules
|
|
234
|
+
use `mcp__<server>__<tool>`. Tool descriptions are capped at 4,096
|
|
219
235
|
chars (2.1.296+) and tool output at 25k tokens by default (`MAX_MCP_OUTPUT_TOKENS`),
|
|
220
|
-
with oversized text saved to a file; 16 MiB message cap on HTTP transports
|
|
236
|
+
with oversized text saved to a file; 16 MiB message cap on HTTP transports
|
|
237
|
+
([MCP quickstart](https://code.claude.com/docs/en/mcp-quickstart)).
|
|
221
238
|
- **Rules** — `CLAUDE.md` hierarchy (managed → user `~/.claude/CLAUDE.md` → project →
|
|
222
239
|
`CLAUDE.local.md`) with `@import`; `AGENTS.md` is read instead only when no CLAUDE.md
|
|
223
240
|
exists anywhere above the cwd (since 2.1.277)
|
|
@@ -303,3 +320,11 @@ paths imply the available scopes; keys locked to a single scope are enumerated i
|
|
|
303
320
|
`toolPolicies`). Codex's docs location and Copilot's `GEMINI.md` dating were re-verified
|
|
304
321
|
2026-10-21 and are settled in their sections above. Protocol-revision evidence for every
|
|
305
322
|
host lives in [mcp-compatibility.md](mcp-compatibility.md).
|
|
323
|
+
- **Claude Code forward-compat anchors (checked at the 2.1.303 lock):** the headless docs
|
|
324
|
+
pre-announce `--bare` becoming the default for `-p`, and bare mode never reads
|
|
325
|
+
`.mcp.json` — the billed e2e scenario and the project-scope install story both need
|
|
326
|
+
re-checking when that lands. `${VAR}` expansion in the `env` block of a user-scope
|
|
327
|
+
`~/.claude.json` entry is supported but only indirectly documented (the docs spell out
|
|
328
|
+
`.mcp.json` and `command`/`args`; the e2e's `hasCompletedOnboarding` flag and the
|
|
329
|
+
redundant `enableAllProjectMcpServers` belt-and-suspenders are undocumented), so a
|
|
330
|
+
baseline bump should re-verify all three against the then-current docs.
|
|
@@ -82,7 +82,7 @@ host facts; each row below was verified against that lock on the research baseli
|
|
|
82
82
|
| Cline | `2024-11-05` | stdio, Streamable HTTP, SSE | Custom client (no official SDK); `protocolVersion "2024-11-05"` extracted from the platform binary; source repo not public |
|
|
83
83
|
| Codex | `2025-06-18`; `mcp_2026_07_28` feature flag opts into `2026-07-28` | stdio, Streamable HTTP (no SSE) | Git tag `rust-v0.163.0`: rmcp 3.5.1 pinned; initialize sends `V_2025_06_18` |
|
|
84
84
|
| Pi | `2025-11-25` | stdio, Streamable HTTP (SSE rejected) | Git tag: own `@earendil-works/pi-mcp` client, `LATEST_PROTOCOL_VERSION "2025-11-25"` |
|
|
85
|
-
| Claude Code | `2026-07-28` by default (`MCP_PROTOCOL_NEGOTIATION=legacy` opt-out) | stdio, Streamable HTTP, SSE, ws |
|
|
85
|
+
| Claude Code | `2026-07-28` by default (`MCP_PROTOCOL_NEGOTIATION=legacy` opt-out) | stdio, Streamable HTTP, SSE, ws | Docs: the v2 runtime (TS SDK 2.0) defaults from 2.1.274+ without flag fetching and "asks stdio servers for that revision by default"; changelog 2.1.299 extended the default to cloud session and Remote Control connectors |
|
|
86
86
|
| Cursor | Unpinnable from primary sources (docs show ≥ `2025-06-18` features) | stdio, Streamable HTTP, SSE | Docs feature set only; no revision stated in docs or changelog |
|
|
87
87
|
| Devin | `2026-07-28` | stdio, Streamable HTTP (SSE fallback), SSE | Changelog for the locked release itself: "Support for MCP protocol revision 2026-07-28" |
|
|
88
88
|
| GitHub Copilot | `2026-07-28` | stdio (`type: "local"`), Streamable HTTP, SSE | Changelog 1.0.81: "Ship MCP 2026-07-28 support to CLI, SDK, IDE, and in-memory clients" |
|
|
@@ -128,7 +128,7 @@ differences:
|
|
|
128
128
|
Codex and Pi reject it anyway.
|
|
129
129
|
3. **Build on the official SDK v2 dual-era serving.** One build then serves the full range:
|
|
130
130
|
the `2024-11-05` floor set by Cline (v2 legacy support spans `2024-10-07`–`2025-11-25`)
|
|
131
|
-
up to the `2026-07-28` negotiators (Claude Code 2.1.
|
|
131
|
+
up to the `2026-07-28` negotiators (Claude Code 2.1.274+, Devin 3000.11.92, Copilot CLI
|
|
132
132
|
1.0.81+). A v1.x-built server still works today — modern clients fall back to the legacy
|
|
133
133
|
handshake — but it can never offer the stateless era; v2 serves both from one entry
|
|
134
134
|
point.
|
|
@@ -170,6 +170,15 @@ tag (commit `843a6c5`); the `opencode-ai/opencode` repository's tags stop at `v0
|
|
|
170
170
|
only heuristic correlation via the session hooks (which fire per model request with the
|
|
171
171
|
sessionID, but no API contract binds a provider call to a session).
|
|
172
172
|
|
|
173
|
+
Post-baseline drift check (2026-10-24): the interface is unchanged at 2.1.4, the latest npm
|
|
174
|
+
release on that date (unpkg dist of `@opencode/schema`/`@opencode/plugin` 2.1.4:
|
|
175
|
+
`ProviderInput = Pick<Input, "query">`, provider context `{ signal }`). Upstream requests
|
|
176
|
+
filed the same day to widen this surface:
|
|
177
|
+
[anomalyco/opencode#58635](https://github.com/anomalyco/opencode/issues/58635) (session
|
|
178
|
+
linkage in the provider execute context) and
|
|
179
|
+
[#58636](https://github.com/anomalyco/opencode/issues/58636) (optional per-call `count`
|
|
180
|
+
passthrough) — re-check both when the baseline or the OpenCode lock bumps.
|
|
181
|
+
|
|
173
182
|
Verdict: **tool path (`deepseek_fetch` / the `webfetch` override): A feasible (validated against
|
|
174
183
|
the declared schema; raw JSON Schema forwarded verbatim) and B feasible
|
|
175
184
|
(`ctx.session.context`). Websearch provider path (the search replacement this repo ships): neither
|
|
@@ -256,7 +265,9 @@ negotiated revision in the matrix.
|
|
|
256
265
|
`ctx.session.context({ sessionID })` — user messages with their `text` — at call time, so
|
|
257
266
|
channel B exists here without touching schemas. The `websearch` provider replacement — the path
|
|
258
267
|
this repo uses for search — has neither channel at 2.0.32: its model-facing input schema is
|
|
259
|
-
fixed in host source to `query`, and its provider context is `{ signal }` only
|
|
268
|
+
fixed in host source to `query`, and its provider context is `{ signal }` only (upstream
|
|
269
|
+
requests: [#58635](https://github.com/anomalyco/opencode/issues/58635),
|
|
270
|
+
[#58636](https://github.com/anomalyco/opencode/issues/58636)). Note also that
|
|
260
271
|
`FETCH_INPUT` sets `additionalProperties: false`, which matters if a parameter is ever added on
|
|
261
272
|
channel A: an undeclared field would be rejected by OpenCode's runtime validation with a
|
|
262
273
|
retryable error.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "deepseek-web-retrieval",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.9.0",
|
|
5
5
|
"description": "Token-efficient DeepSeek Search and Fetch for coding agents, starting with OpenCode v2.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
@@ -57,7 +57,8 @@
|
|
|
57
57
|
"prepublishOnly": "bun run check:release",
|
|
58
58
|
"test": "bun test",
|
|
59
59
|
"test:package": "node scripts/test-package.mjs",
|
|
60
|
-
"test:e2e:keyless": "bun run test:e2e pi pi-extension installer reinit upgrade pkg capability-off",
|
|
60
|
+
"test:e2e:keyless": "bun run test:e2e pi pi-extension pi-mock opencode-mock installer reinit upgrade pkg capability-off",
|
|
61
|
+
"test:e2e:billed": "bun run test:e2e pi-billed opencode codex claude-code preferred",
|
|
61
62
|
"check:release": "bun run check && bun run test:package && bun run test:e2e:keyless",
|
|
62
63
|
"test:e2e": "bun run test/e2e/run.ts",
|
|
63
64
|
"docs:matrix": "bun run scripts/render-matrix.ts",
|
package/src/core/auth.ts
CHANGED
|
@@ -7,3 +7,10 @@ export function apiKeyFromEnv(env: Readonly<Record<string, string | undefined>>
|
|
|
7
7
|
const key = env[API_KEY_ENV]?.trim();
|
|
8
8
|
return key ? key : undefined;
|
|
9
9
|
}
|
|
10
|
+
|
|
11
|
+
/** Resolves the API key from the environment or fails with shared guidance. */
|
|
12
|
+
export function requireApiKey(env: Readonly<Record<string, string | undefined>>): string {
|
|
13
|
+
const apiKey = apiKeyFromEnv(env);
|
|
14
|
+
if (!apiKey) throw new Error(MISSING_API_KEY_MESSAGE);
|
|
15
|
+
return apiKey;
|
|
16
|
+
}
|
package/src/core/client.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_API_BASE_URL,
|
|
3
|
+
FETCH_ENDPOINT_PATH,
|
|
4
|
+
MAX_FETCH_CHARS,
|
|
5
|
+
MAX_ORI_QUESTION_CHARS,
|
|
6
|
+
MAX_QUERY_CHARS,
|
|
7
|
+
SEARCH_ENDPOINT_PATH,
|
|
8
|
+
} from "./constants";
|
|
2
9
|
import { assertFetchHostPolicy } from "./domains";
|
|
3
10
|
import { formatFetchedContent, truncate } from "./format";
|
|
4
|
-
import {
|
|
11
|
+
import { composeSearchQuery, processResults } from "./search";
|
|
5
12
|
import { postJson } from "./transport";
|
|
6
13
|
import type {
|
|
7
14
|
DeepSeekSearchResult,
|
|
@@ -9,9 +16,10 @@ import type {
|
|
|
9
16
|
FetchedPage,
|
|
10
17
|
FetchOptions,
|
|
11
18
|
RequestAuth,
|
|
19
|
+
ResolvedOptions,
|
|
12
20
|
RetryOptions,
|
|
13
21
|
SearchOptions,
|
|
14
|
-
|
|
22
|
+
SearchOutcome,
|
|
15
23
|
} from "./types";
|
|
16
24
|
import { parseFetchUrl } from "./url";
|
|
17
25
|
|
|
@@ -22,6 +30,24 @@ function effectiveTimeout(configuredMs: number, perCallMs: number | undefined):
|
|
|
22
30
|
return perCallMs === undefined ? configuredMs : Math.min(configuredMs, perCallMs);
|
|
23
31
|
}
|
|
24
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The per-call budget wins outright, unlike timeouts: a model that just saw a
|
|
35
|
+
* truncation marker is asking for more than the default on purpose, so the
|
|
36
|
+
* configured value stays the default rather than a ceiling.
|
|
37
|
+
*/
|
|
38
|
+
function effectiveMaxChars(configured: number, perCall: number | undefined): number {
|
|
39
|
+
if (perCall === undefined || !Number.isFinite(perCall)) return configured;
|
|
40
|
+
return Math.min(MAX_FETCH_CHARS, Math.max(0, Math.trunc(perCall)));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Composes a request endpoint from the resolved base URL (already
|
|
45
|
+
* slash-stripped by resolveBaseUrl; absent means the production default).
|
|
46
|
+
*/
|
|
47
|
+
function apiUrl(baseUrl: string | undefined, path: string): string {
|
|
48
|
+
return `${baseUrl ?? DEFAULT_API_BASE_URL}${path}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
25
51
|
/**
|
|
26
52
|
* The wire value for ori_question: absent/whitespace-only means "not
|
|
27
53
|
* provided"; longer values are clamped rather than rejected — the field is
|
|
@@ -49,6 +75,14 @@ export interface RequestOptions {
|
|
|
49
75
|
readonly retry: RetryOptions;
|
|
50
76
|
/** Per-call cap in milliseconds; the shorter of this and the configured timeout wins. */
|
|
51
77
|
readonly timeoutMs?: number;
|
|
78
|
+
/** Per-call fetch budget in characters; overrides the configured cap for this call. */
|
|
79
|
+
readonly maxChars?: number;
|
|
80
|
+
/**
|
|
81
|
+
* The resolved API root; adapters pass `config.baseUrl` so every request
|
|
82
|
+
* follows the `baseUrl` knob. Absent falls back to the production default
|
|
83
|
+
* (unit tests composing requests by hand rely on that).
|
|
84
|
+
*/
|
|
85
|
+
readonly baseUrl?: string;
|
|
52
86
|
}
|
|
53
87
|
|
|
54
88
|
export interface SearchRequest extends RequestOptions {
|
|
@@ -60,31 +94,46 @@ export interface SearchRequest extends RequestOptions {
|
|
|
60
94
|
readonly oriQuestion?: string;
|
|
61
95
|
}
|
|
62
96
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
97
|
+
/**
|
|
98
|
+
* The per-request context every adapter assembles from the resolved config:
|
|
99
|
+
* bound auth, retry policy, and base URL, plus any per-call overrides a
|
|
100
|
+
* tool input supplied.
|
|
101
|
+
*/
|
|
102
|
+
export function requestContext(
|
|
103
|
+
config: Pick<ResolvedOptions, "retry" | "baseUrl">,
|
|
104
|
+
apiKey: string,
|
|
105
|
+
signal: AbortSignal,
|
|
106
|
+
perCall?: { readonly timeoutMs?: number; readonly maxChars?: number },
|
|
107
|
+
): RequestOptions {
|
|
108
|
+
return {
|
|
109
|
+
signal,
|
|
110
|
+
auth: { apiKey },
|
|
111
|
+
retry: config.retry,
|
|
112
|
+
baseUrl: config.baseUrl,
|
|
113
|
+
...(perCall?.timeoutMs === undefined ? {} : { timeoutMs: perCall.timeoutMs }),
|
|
114
|
+
...(perCall?.maxChars === undefined ? {} : { maxChars: perCall.maxChars }),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function searchWeb(query: string, options: SearchOptions, request: SearchRequest): Promise<SearchOutcome> {
|
|
68
119
|
const trimmed = query.trim();
|
|
69
120
|
if (!trimmed) throw new Error("DeepSeek search query must not be empty");
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const builtQuery = buildQuery(trimmed, options);
|
|
75
|
-
const builtQueryLength = Array.from(builtQuery).length;
|
|
76
|
-
if (builtQueryLength > MAX_QUERY_CHARS) {
|
|
121
|
+
const trimmedLength = Array.from(trimmed).length;
|
|
122
|
+
if (trimmedLength > MAX_QUERY_CHARS) {
|
|
123
|
+
// The query rides in the message so the model can see what to shorten.
|
|
77
124
|
throw new Error(
|
|
78
|
-
`DeepSeek search query must be ${MAX_QUERY_CHARS} characters or fewer
|
|
79
|
-
`
|
|
80
|
-
`(built query is ${builtQueryLength} characters)`,
|
|
125
|
+
`DeepSeek search query must be ${MAX_QUERY_CHARS} characters or fewer ` +
|
|
126
|
+
`(got ${trimmedLength}: ${JSON.stringify(truncate(trimmed, 64))})`,
|
|
81
127
|
);
|
|
82
128
|
}
|
|
83
129
|
|
|
130
|
+
const composed = composeSearchQuery(trimmed, options);
|
|
131
|
+
const builtQuery = composed.query;
|
|
132
|
+
|
|
84
133
|
const timeout = effectiveTimeout(options.timeoutMs, request.timeoutMs);
|
|
85
134
|
const oriQuestion = oriQuestionValue(request.oriQuestion);
|
|
86
135
|
const payload = await postJson<SearchResponse>(
|
|
87
|
-
|
|
136
|
+
apiUrl(request.baseUrl, SEARCH_ENDPOINT_PATH),
|
|
88
137
|
{
|
|
89
138
|
query: builtQuery,
|
|
90
139
|
count: options.count,
|
|
@@ -99,7 +148,13 @@ export async function searchWeb(
|
|
|
99
148
|
if (!Array.isArray(payload.results)) {
|
|
100
149
|
throw new Error("DeepSeek Search returned a response without a results array");
|
|
101
150
|
}
|
|
102
|
-
|
|
151
|
+
const results = processResults(payload.results as DeepSeekSearchResult[], options);
|
|
152
|
+
return {
|
|
153
|
+
results,
|
|
154
|
+
inputQuery: trimmed,
|
|
155
|
+
effectiveQuery: builtQuery,
|
|
156
|
+
notes: composed.notes,
|
|
157
|
+
};
|
|
103
158
|
}
|
|
104
159
|
|
|
105
160
|
export async function fetchPage(url: URL, options: FetchOptions, request: RequestOptions): Promise<FetchedPage> {
|
|
@@ -108,10 +163,15 @@ export async function fetchPage(url: URL, options: FetchOptions, request: Reques
|
|
|
108
163
|
const target = parseFetchUrl(url.href);
|
|
109
164
|
assertFetchHostPolicy(target, options);
|
|
110
165
|
const timeout = effectiveTimeout(options.timeoutMs, request.timeoutMs);
|
|
111
|
-
const payload = await postJson<FetchResponse>(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
166
|
+
const payload = await postJson<FetchResponse>(
|
|
167
|
+
apiUrl(request.baseUrl, FETCH_ENDPOINT_PATH),
|
|
168
|
+
{ url: target.href },
|
|
169
|
+
"DeepSeek Fetch",
|
|
170
|
+
{
|
|
171
|
+
...request,
|
|
172
|
+
timeoutMs: timeout,
|
|
173
|
+
},
|
|
174
|
+
);
|
|
115
175
|
const title = typeof payload.title === "string" ? payload.title.trim() : "";
|
|
116
176
|
const content = typeof payload.content === "string" ? payload.content : "";
|
|
117
177
|
if (!content.trim()) {
|
|
@@ -138,7 +198,11 @@ export async function fetchDocument(
|
|
|
138
198
|
): Promise<FetchedDocument> {
|
|
139
199
|
if (typeof url !== "string" && !(url instanceof URL)) throw new Error('"url" must be a string or URL');
|
|
140
200
|
const page = await fetchPage(url instanceof URL ? url : parseFetchUrl(url), options, request);
|
|
141
|
-
const formatted = formatFetchedContent(
|
|
201
|
+
const formatted = formatFetchedContent(
|
|
202
|
+
page.title,
|
|
203
|
+
page.content,
|
|
204
|
+
effectiveMaxChars(options.maxChars, request.maxChars),
|
|
205
|
+
);
|
|
142
206
|
return {
|
|
143
207
|
url: page.url,
|
|
144
208
|
title: page.title,
|
|
@@ -27,6 +27,7 @@ const VOCABULARY = [
|
|
|
27
27
|
{ key: "fetchBlockPrivateHosts", read: (o: ResolvedOptions) => o.fetch.blockPrivateHosts },
|
|
28
28
|
{ key: "maxRetries", read: (o: ResolvedOptions) => o.retry.maxRetries },
|
|
29
29
|
{ key: "retryBaseDelayMs", read: (o: ResolvedOptions) => o.retry.baseDelayMs },
|
|
30
|
+
{ key: "baseUrl", read: (o: ResolvedOptions) => o.baseUrl },
|
|
30
31
|
] as const;
|
|
31
32
|
|
|
32
33
|
/** The config keys in vocabulary order; the shared config file accepts exactly these. */
|
package/src/core/constants.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The DeepSeek API root every request is composed from. `baseUrl` overrides it
|
|
3
|
+
* (env `DEEPSEEK_BASE_URL`, shared-file key `baseUrl`) so proxies, enterprise
|
|
4
|
+
* gateways, and mock upstreams can intercept retrieval without touching the
|
|
5
|
+
* adapters; the two endpoints below stay the production default.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_API_BASE_URL = "https://api.deepseek.com";
|
|
8
|
+
export const SEARCH_ENDPOINT_PATH = "/v1/tools/search";
|
|
9
|
+
export const FETCH_ENDPOINT_PATH = "/v1/tools/fetch";
|
|
10
|
+
|
|
11
|
+
export const SEARCH_ENDPOINT = `${DEFAULT_API_BASE_URL}${SEARCH_ENDPOINT_PATH}`;
|
|
12
|
+
export const FETCH_ENDPOINT = `${DEFAULT_API_BASE_URL}${FETCH_ENDPOINT_PATH}`;
|
|
3
13
|
|
|
4
14
|
/**
|
|
5
15
|
* Search defaults are benchmarked against surveyed AI-native search APIs and
|
|
@@ -19,6 +29,10 @@ export const DEFAULT_MIN_AUTHORITY = 0;
|
|
|
19
29
|
export const DEFAULT_MAX_SUMMARY_CHARS = 1200;
|
|
20
30
|
|
|
21
31
|
export const DEFAULT_FETCH_MAX_CHARS = 16000;
|
|
32
|
+
/** The ceiling for a per-call fetch budget, matching the configured knob's range. */
|
|
33
|
+
export const MAX_FETCH_CHARS = 1_000_000;
|
|
34
|
+
/** The API's documented count range top; per-call counts share it. */
|
|
35
|
+
export const MAX_SEARCH_COUNT = 50;
|
|
22
36
|
export const DEFAULT_TIMEOUT_MS = 30000;
|
|
23
37
|
export const MIN_TIMEOUT_MS = 1000;
|
|
24
38
|
export const MAX_TIMEOUT_MS = 120000;
|
|
@@ -45,6 +59,7 @@ export const MAX_RETRY_DELAY_MS = 10000;
|
|
|
45
59
|
|
|
46
60
|
/** The single source for every DEEPSEEK_* environment variable name. */
|
|
47
61
|
export const API_KEY_ENV = "DEEPSEEK_API_KEY";
|
|
62
|
+
export const BASE_URL_ENV = "DEEPSEEK_BASE_URL";
|
|
48
63
|
export const ENABLE_SEARCH_ENV = "DEEPSEEK_ENABLE_SEARCH";
|
|
49
64
|
export const ENABLE_FETCH_ENV = "DEEPSEEK_ENABLE_FETCH";
|
|
50
65
|
export const SEARCH_LOCALIZATION_ENV = "DEEPSEEK_SEARCH_LOCALIZATION";
|
package/src/core/descriptions.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
export const FETCH_SERVICE_LIMITS =
|
|
7
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 =
|
|
9
|
+
export const FETCH_OUTPUT_TRUNCATED =
|
|
10
|
+
"Output is truncated to the configured size; the optional maxChars parameter raises or lowers that size for one call.";
|
|
10
11
|
|
|
11
12
|
export const FETCH_MARKDOWN_ONLY = 'Only format "markdown" is supported.';
|
|
12
13
|
|
|
@@ -45,3 +46,11 @@ export const SEARCH_ORI_QUESTION_PARAMETER =
|
|
|
45
46
|
"reformulated query. Optional; 1-128 characters, and longer values are truncated.";
|
|
46
47
|
|
|
47
48
|
export const FETCH_URL_PARAMETER = "Absolute http:// or https:// URL to fetch. Credentials are rejected.";
|
|
49
|
+
|
|
50
|
+
export const FETCH_MAX_CHARS_PARAMETER =
|
|
51
|
+
"Optional per-call character budget for this fetch, 0-1,000,000, title included. Overrides the configured cap " +
|
|
52
|
+
"for this call; a [content truncated] marker is appended when the output is cut.";
|
|
53
|
+
|
|
54
|
+
export const SEARCH_COUNT_PARAMETER =
|
|
55
|
+
"Optional number of results for this call, 1-50. Overrides the configured count and result cap for this call; " +
|
|
56
|
+
"billing is per request, not per result.";
|
package/src/core/env.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { API_KEY_ENV, ENABLE_FETCH_ENV, ENABLE_SEARCH_ENV, SEARCH_LOCALIZATION_ENV } from "./constants";
|
|
1
|
+
import { API_KEY_ENV, BASE_URL_ENV, ENABLE_FETCH_ENV, ENABLE_SEARCH_ENV, SEARCH_LOCALIZATION_ENV } from "./constants";
|
|
2
2
|
import { normalizeDomain } from "./domains";
|
|
3
3
|
import { resolvePluginOptions } from "./options";
|
|
4
4
|
import type { PluginOptions, ResolvedOptions } from "./types";
|
|
@@ -23,6 +23,7 @@ const NUMBER_ENV = {
|
|
|
23
23
|
const TEXT_ENV = {
|
|
24
24
|
language: "DEEPSEEK_SEARCH_LANGUAGE",
|
|
25
25
|
searchLocalization: SEARCH_LOCALIZATION_ENV,
|
|
26
|
+
baseUrl: BASE_URL_ENV,
|
|
26
27
|
} as const;
|
|
27
28
|
|
|
28
29
|
const DOMAINS_ENV = {
|
|
@@ -40,18 +41,21 @@ const FALSE_ONLY_ENV = {
|
|
|
40
41
|
fetchEnabled: ENABLE_FETCH_ENV,
|
|
41
42
|
} as const;
|
|
42
43
|
|
|
44
|
+
/** Every option key mapped onto its `DEEPSEEK_*` variable — the inverse view of the maps above. */
|
|
45
|
+
export const OPTION_ENV_BY_KEY: Readonly<Record<string, string>> = {
|
|
46
|
+
...NUMBER_ENV,
|
|
47
|
+
...TEXT_ENV,
|
|
48
|
+
...DOMAINS_ENV,
|
|
49
|
+
...FALSE_ONLY_ENV,
|
|
50
|
+
};
|
|
51
|
+
|
|
43
52
|
/**
|
|
44
53
|
* Every option's `DEEPSEEK_*` variable name, in map order — the names a host that
|
|
45
54
|
* isolates its children from the parent environment (Codex) must forward for
|
|
46
55
|
* shell-set options to reach the server. `DEEPSEEK_API_KEY` is deliberately
|
|
47
56
|
* absent: it is not an option variable, and installers compose it separately.
|
|
48
57
|
*/
|
|
49
|
-
export const OPTION_ENV_VARS: readonly string[] =
|
|
50
|
-
...Object.values(NUMBER_ENV),
|
|
51
|
-
...Object.values(TEXT_ENV),
|
|
52
|
-
...Object.values(DOMAINS_ENV),
|
|
53
|
-
...Object.values(FALSE_ONLY_ENV),
|
|
54
|
-
];
|
|
58
|
+
export const OPTION_ENV_VARS: readonly string[] = Object.values(OPTION_ENV_BY_KEY);
|
|
55
59
|
|
|
56
60
|
/**
|
|
57
61
|
* Maps `DEEPSEEK_*` environment variables onto the shared plugin option keys and
|
|
@@ -104,6 +108,7 @@ export function envOptionsReport(env: Env = process.env): EnvOptionsReport {
|
|
|
104
108
|
|
|
105
109
|
putNumber(options, "maxRetries", env, warnings);
|
|
106
110
|
putNumber(options, "retryBaseDelayMs", env, warnings);
|
|
111
|
+
putText(options, "baseUrl", env);
|
|
107
112
|
|
|
108
113
|
const known = new Set(OPTION_ENV_VARS);
|
|
109
114
|
for (const key of Object.keys(env)) {
|
package/src/core/format.ts
CHANGED
|
@@ -85,14 +85,34 @@ export function formatTimeoutError(operation: string, timeoutMs: number): string
|
|
|
85
85
|
return `${operation} timed out after ${timeoutMs} ms`;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* The query context shown to the model above the results: emitted only when
|
|
90
|
+
* the effective query differs from what the model sent (operators applied or
|
|
91
|
+
* degradation happened), so an unfiltered search adds no header noise.
|
|
92
|
+
*/
|
|
93
|
+
export interface SearchQueryContext {
|
|
94
|
+
readonly inputQuery: string;
|
|
95
|
+
readonly effectiveQuery: string;
|
|
96
|
+
readonly notes: readonly string[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function searchHeader(context: SearchQueryContext | undefined): string | undefined {
|
|
100
|
+
if (!context) return undefined;
|
|
101
|
+
if (context.effectiveQuery === context.inputQuery && context.notes.length === 0) return undefined;
|
|
102
|
+
const header = `Searched for: ${context.effectiveQuery}`;
|
|
103
|
+
return context.notes.length > 0 ? `${header} — ${context.notes.join("; ")}` : header;
|
|
104
|
+
}
|
|
105
|
+
|
|
88
106
|
/**
|
|
89
107
|
* Renders processed search results as Markdown for a text content block.
|
|
90
108
|
* Hosts that ignore structured output read this text.
|
|
91
109
|
*/
|
|
92
|
-
export function formatSearchResults(results: readonly SearchResult[]): string {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
110
|
+
export function formatSearchResults(results: readonly SearchResult[], context?: SearchQueryContext): string {
|
|
111
|
+
const header = searchHeader(context);
|
|
112
|
+
if (results.length === 0) {
|
|
113
|
+
return header ? `${header}\n\nNo results found.` : "No results found.";
|
|
114
|
+
}
|
|
115
|
+
const body = results
|
|
96
116
|
.map((result, index) => {
|
|
97
117
|
const title = result.title?.trim() || result.url;
|
|
98
118
|
const marker = formatQualityMarker(result);
|
|
@@ -106,6 +126,7 @@ export function formatSearchResults(results: readonly SearchResult[]): string {
|
|
|
106
126
|
return lines.join("\n");
|
|
107
127
|
})
|
|
108
128
|
.join("\n\n");
|
|
129
|
+
return header ? `${header}\n\n${body}` : body;
|
|
109
130
|
}
|
|
110
131
|
|
|
111
132
|
/**
|
package/src/core/options.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isIP } from "node:net";
|
|
2
2
|
import {
|
|
3
|
+
DEFAULT_API_BASE_URL,
|
|
3
4
|
DEFAULT_API_KEY_SOURCE,
|
|
4
5
|
DEFAULT_FETCH_MAX_CHARS,
|
|
5
6
|
DEFAULT_MAX_RESULTS,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
DEFAULT_TIMEOUT_MS,
|
|
13
14
|
MAX_RETRIES,
|
|
14
15
|
MAX_RETRY_DELAY_MS,
|
|
16
|
+
MAX_SEARCH_COUNT,
|
|
15
17
|
MAX_TIMEOUT_MS,
|
|
16
18
|
MIN_TIMEOUT_MS,
|
|
17
19
|
} from "./constants";
|
|
@@ -32,9 +34,22 @@ export function resolvePluginOptions(input?: PluginOptions): ResolvedOptions {
|
|
|
32
34
|
fetch: resolveFetchOptions(input),
|
|
33
35
|
auth: resolveAuthOptions(input),
|
|
34
36
|
retry: resolveRetryOptions(input),
|
|
37
|
+
baseUrl: resolveBaseUrl(input?.baseUrl),
|
|
35
38
|
};
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The both-disabled refusal both server surfaces share: undefined when a tool
|
|
43
|
+
* stays enabled, otherwise one message naming why both are off.
|
|
44
|
+
*/
|
|
45
|
+
export function bothDisabledPrecheck(
|
|
46
|
+
config: Pick<ResolvedOptions, "search" | "fetch">,
|
|
47
|
+
reason: string,
|
|
48
|
+
): string | undefined {
|
|
49
|
+
if (config.search.enabled || config.fetch.enabled) return undefined;
|
|
50
|
+
return `Both tools are disabled (${reason}); at least one must stay enabled.`;
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
export function resolveSearchOptions(input?: PluginOptions): SearchOptions {
|
|
39
54
|
const count = clampInt(input?.count, DEFAULT_SEARCH_COUNT, 1, 50);
|
|
40
55
|
return {
|
|
@@ -82,6 +97,38 @@ export function resolveRetryOptions(input?: PluginOptions): RetryOptions {
|
|
|
82
97
|
};
|
|
83
98
|
}
|
|
84
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Constraint: only an http(s) URL with no fragment survives, trailing slashes
|
|
102
|
+
* stripped so endpoint composition never doubles them. Anything else — a bare
|
|
103
|
+
* hostname, a non-http scheme, an empty string — drops to the production
|
|
104
|
+
* default, the same drop-and-default contract as every other invalid knob.
|
|
105
|
+
*/
|
|
106
|
+
export function resolveBaseUrl(value: unknown): string {
|
|
107
|
+
if (typeof value !== "string") return DEFAULT_API_BASE_URL;
|
|
108
|
+
const trimmed = value.trim();
|
|
109
|
+
if (!trimmed) return DEFAULT_API_BASE_URL;
|
|
110
|
+
let parsed: URL;
|
|
111
|
+
try {
|
|
112
|
+
parsed = new URL(trimmed);
|
|
113
|
+
} catch {
|
|
114
|
+
return DEFAULT_API_BASE_URL;
|
|
115
|
+
}
|
|
116
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return DEFAULT_API_BASE_URL;
|
|
117
|
+
return trimmed.replace(/\/+$/, "");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Applies a per-call result count to resolved search options. The count the
|
|
122
|
+
* model asked for is what it wants to see, so it sets both the request count
|
|
123
|
+
* and the post-filter cap for that call; the configured knobs stay the
|
|
124
|
+
* default, not a ceiling.
|
|
125
|
+
*/
|
|
126
|
+
export function withPerCallCount(options: SearchOptions, count: number | undefined): SearchOptions {
|
|
127
|
+
if (count === undefined || !Number.isFinite(count)) return options;
|
|
128
|
+
const clamped = Math.min(MAX_SEARCH_COUNT, Math.max(1, Math.trunc(count)));
|
|
129
|
+
return { ...options, count: clamped, maxResults: clamped };
|
|
130
|
+
}
|
|
131
|
+
|
|
85
132
|
/**
|
|
86
133
|
* Resolves the per-call timeout from the plugin cap and the built-in tool's
|
|
87
134
|
* `timeout` field (seconds). The shorter limit wins.
|