deepseek-web-retrieval 0.8.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +13 -5
  3. package/dist/cli.js +281 -167
  4. package/docs/agents/claude-code.md +7 -1
  5. package/docs/agents/codex.md +7 -1
  6. package/docs/agents/opencode-v2.md +11 -3
  7. package/docs/agents/pi.md +6 -1
  8. package/docs/configuration.md +7 -0
  9. package/docs/design/installer-design.md +4 -4
  10. package/docs/research/ori-question-sourcing.md +12 -1
  11. package/package.json +3 -2
  12. package/src/core/auth.ts +7 -0
  13. package/src/core/client.ts +89 -25
  14. package/src/core/config-summary.ts +1 -0
  15. package/src/core/constants.ts +17 -2
  16. package/src/core/descriptions.ts +10 -1
  17. package/src/core/env.ts +3 -1
  18. package/src/core/format.ts +25 -4
  19. package/src/core/options.ts +47 -0
  20. package/src/core/search.ts +97 -14
  21. package/src/core/types.ts +17 -0
  22. package/src/installer/apply.ts +4 -4
  23. package/src/installer/cli.ts +2 -2
  24. package/src/installer/config-command.ts +4 -3
  25. package/src/installer/hosts/claude-code.ts +3 -17
  26. package/src/installer/hosts/codex.ts +3 -17
  27. package/src/installer/hosts/index.ts +2 -2
  28. package/src/installer/hosts/opencode.ts +1 -1
  29. package/src/installer/hosts/pi.ts +14 -15
  30. package/src/installer/hosts/shared.ts +23 -0
  31. package/src/installer/ownership.ts +2 -2
  32. package/src/installer/paths.ts +2 -2
  33. package/src/installer/{state.ts → record.ts} +15 -15
  34. package/src/installer/types.ts +1 -1
  35. package/src/installer/uninstall.ts +4 -4
  36. package/src/mcp/serve.ts +1 -2
  37. package/src/mcp/server.ts +16 -19
  38. package/src/opencode/plugin.ts +15 -10
  39. package/src/pi/extension.ts +23 -17
  40. package/src/mcp/auth.ts +0 -11
@@ -168,7 +168,13 @@ The model calls `mcp__deepseek__deepseek_web_search` or `mcp__deepseek__deepseek
168
168
 
169
169
  The search tool accepts an optional `original_question` argument — the user's question in
170
170
  their own words — forwarded as DeepSeek's `ori_question` to sharpen search intent; omit it
171
- when the query already stands alone.
171
+ when the query already stands alone. It also takes an optional `count` (1–50) for the call,
172
+ and the fetch tool takes an optional `maxChars` (0–1,000,000) per-call output budget; each
173
+ overrides the configured default for that call, so the model can widen a truncated fetch or
174
+ ask for more results without rephrasing the query. When configured domain or language
175
+ operators change the query actually sent, the result text opens with
176
+ `Searched for: <effective query>` (plus any degradation applied to fit the API's
177
+ 128-character limit), so the model can attribute results to the configuration in effect.
172
178
 
173
179
  Tool output is capped at 25k tokens by default (`MAX_MCP_OUTPUT_TOKENS`), and successful
174
180
  text results longer than 50,000 characters are saved to a file and referenced — not
@@ -148,7 +148,13 @@ The model calls `mcp__deepseek__deepseek_web_search` or `mcp__deepseek__deepseek
148
148
 
149
149
  The search tool accepts an optional `original_question` argument — the user's question in
150
150
  their own words — forwarded as DeepSeek's `ori_question` to sharpen search intent; omit it
151
- when the query already stands alone.
151
+ when the query already stands alone. It also takes an optional `count` (1–50) for the call,
152
+ and the fetch tool takes an optional `maxChars` (0–1,000,000) per-call output budget; each
153
+ overrides the configured default for that call, so the model can widen a truncated fetch or
154
+ ask for more results without rephrasing the query. When configured domain or language
155
+ operators change the query actually sent, the result text opens with
156
+ `Searched for: <effective query>` (plus any degradation applied to fit the API's
157
+ 128-character limit), so the model can attribute results to the configuration in effect.
152
158
 
153
159
  ## Configuration
154
160
 
@@ -176,9 +176,17 @@ Fetch https://opencode.ai/v2/docs/build/plugins and summarize the plugin lifecyc
176
176
  The model calls `websearch` (provider `deepseek`) or `deepseek_fetch`. When
177
177
  `fetchOverrideWebfetch` is enabled, Markdown fetches go through `webfetch` instead.
178
178
 
179
- DeepSeek's optional `ori_question` is not sent on this path: OpenCode's `websearch` tool
180
- input is fixed to `query` at 2.0.32, so the provider has no channel for it (see the
181
- [research note](../research/ori-question-sourcing.md)).
179
+ DeepSeek's optional `ori_question` is not sent on this path, and the model cannot request a
180
+ per-call result count: OpenCode's `websearch` tool input is fixed to `query` (verified unchanged
181
+ at 2.1.4), so the provider has no channel for either. Upstream requests:
182
+ [anomalyco/opencode#58635](https://github.com/anomalyco/opencode/issues/58635) (session linkage)
183
+ and [#58636](https://github.com/anomalyco/opencode/issues/58636) (per-call `count`); see the
184
+ [research note](../research/ori-question-sourcing.md). The fetch tools are not constrained:
185
+ they accept an optional per-call `maxChars` (0–1,000,000) that overrides the configured
186
+ `fetchMaxChars` for one call, so the model can widen the budget after seeing a
187
+ `[content truncated]` marker. The effective-query header and query degradation notes have no
188
+ channel on this path either — OpenCode's result shape is closed — but degradation still runs,
189
+ and failures that cannot degrade surface as errors.
182
190
 
183
191
  ## Configuration
184
192
 
package/docs/agents/pi.md CHANGED
@@ -110,7 +110,12 @@ The search tool accepts an optional `original_question` input — the user's que
110
110
  own words — forwarded as DeepSeek's `ori_question` to sharpen search intent. When the model
111
111
  leaves it blank, the extension falls back to the most recent user message in the session
112
112
  (best-effort; truncated to the API's 128-character limit). The MCP fallback path has no
113
- session access, so there a blank input simply omits the parameter.
113
+ session access, so there a blank input simply omits the parameter. The search tool also
114
+ takes an optional `count` (1–50) for the call, and the fetch tool an optional `maxChars`
115
+ (0–1,000,000) per-call output budget; each overrides the configured default for that call.
116
+ When configured domain or language operators change the query actually sent, the result text
117
+ opens with `Searched for: <effective query>` (plus any degradation applied to fit the API's
118
+ 128-character limit), so the model can attribute results to the configuration in effect.
114
119
 
115
120
  ## Configuration
116
121
 
@@ -84,6 +84,7 @@ drift from the code (`bun run docs:matrix` regenerates it; CI checks it).
84
84
  | `fetchBlockPrivateHosts` | `DEEPSEEK_FETCH_BLOCK_PRIVATE_HOSTS` | true |
85
85
  | `maxRetries` | `DEEPSEEK_MAX_RETRIES` | 2 |
86
86
  | `retryBaseDelayMs` | `DEEPSEEK_RETRY_BASE_DELAY_MS` | 250 |
87
+ | `baseUrl` | `DEEPSEEK_BASE_URL` | https://api.deepseek.com |
87
88
 
88
89
  <!-- /generated: knob-table -->
89
90
 
@@ -105,5 +106,11 @@ drift from the code (`bun run docs:matrix` regenerates it; CI checks it).
105
106
  - **`searchLocalization` must be a valid IPv4 or IPv6 address**; anything else
106
107
  is dropped at option resolution. It is deployment-level configuration, never
107
108
  a per-call tool parameter.
109
+ - **`baseUrl` routes the retrieval API** (`/v1/tools/search` and
110
+ `/v1/tools/fetch` are composed from it). It is infrastructure routing for
111
+ proxies, gateways, and mock upstreams — not a provider switch, so nothing
112
+ falls back to another search service. Only an http(s) URL survives
113
+ resolution (trailing slashes are stripped); anything else drops back to
114
+ `https://api.deepseek.com`.
108
115
  - **Domains** in the environment are comma-separated; entries that are not
109
116
  domains are dropped (and reported by `config`).
@@ -186,10 +186,10 @@ into any existing `permissions` array rather than replacing it.
186
186
  comments and formatting in `opencode.jsonc` and Pi's `mcp.json` survive apply and
187
187
  uninstall; TOML edits go through `smol-toml` and drop comments, which the plan reports
188
188
  as a note. The hand-written JSONC stripper that predated this lived in `scripts/jsonc.ts`.
189
- - Install state lives per host and scope (global under
190
- `~/.config/deepseek-web-retrieval/`, project under `.deepseek-web-retrieval/`); it
191
- records snapshots, appended entries, created files, and created path prefixes — never
192
- full file contents, so other servers' secrets stay out of the state file.
189
+ - Install records live per host and scope (global under
190
+ `~/.config/deepseek-web-retrieval/`, project under `.deepseek-web-retrieval/`); they
191
+ record snapshots, appended entries, created files, and created path prefixes — never
192
+ full file contents, so other servers' secrets stay out of the install record.
193
193
  - Re-applying over a previous init inherits the original snapshots instead of recording
194
194
  the installer's own output as the "previous" value. Pi forced re-inits keep their
195
195
  competitor-disable ops even when those servers are already disabled, so orphan
@@ -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. Note also that
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.8.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
+ }
@@ -1,7 +1,14 @@
1
- import { FETCH_ENDPOINT, MAX_ORI_QUESTION_CHARS, MAX_QUERY_CHARS, SEARCH_ENDPOINT } from "./constants";
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 { buildQuery, processResults } from "./search";
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
- SearchResult,
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
- export async function searchWeb(
64
- query: string,
65
- options: SearchOptions,
66
- request: SearchRequest,
67
- ): Promise<SearchResult[]> {
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
- if (Array.from(trimmed).length > MAX_QUERY_CHARS) {
71
- throw new Error(`DeepSeek search query must be ${MAX_QUERY_CHARS} characters or fewer`);
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 after applying ` +
79
- `blockedDomains, includeDomains, and language ` +
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
- SEARCH_ENDPOINT,
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
- return processResults(payload.results as DeepSeekSearchResult[], options);
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>(FETCH_ENDPOINT, { url: target.href }, "DeepSeek Fetch", {
112
- ...request,
113
- timeoutMs: timeout,
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(page.title, page.content, options.maxChars);
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. */
@@ -1,5 +1,15 @@
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
+ /**
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";
@@ -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 = "Output is truncated to the configured size.";
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 = {
@@ -107,6 +108,7 @@ export function envOptionsReport(env: Env = process.env): EnvOptionsReport {
107
108
 
108
109
  putNumber(options, "maxRetries", env, warnings);
109
110
  putNumber(options, "retryBaseDelayMs", env, warnings);
111
+ putText(options, "baseUrl", env);
110
112
 
111
113
  const known = new Set(OPTION_ENV_VARS);
112
114
  for (const key of Object.keys(env)) {
@@ -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
- if (results.length === 0) return "No results found.";
94
-
95
- return results
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
  /**
@@ -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.