@dumbmice/pi-deepseek-websearch 0.0.0-stage → 0.1.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 +51 -0
- package/LICENSE +21 -0
- package/README.md +161 -2
- package/package.json +54 -4
- package/src/index.ts +754 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-10-30
|
|
4
|
+
|
|
5
|
+
First release. Package name: `@dumbmice/pi-deepseek-websearch` (web search and page fetch over
|
|
6
|
+
DeepSeek's Search & Fetch APIs); published scoped because npm treats the unscoped name as too
|
|
7
|
+
similar to the existing `pi-deepseek-web-search`.
|
|
8
|
+
|
|
9
|
+
**Tools**
|
|
10
|
+
|
|
11
|
+
- `deepseek_search` and `deepseek_fetch` over `POST /v1/tools/search` and `POST /v1/tools/fetch`.
|
|
12
|
+
- Blank/whitespace-only `query` is rejected locally with a clear message.
|
|
13
|
+
- Search parameters: `count` 1–50, `ori_question`, `need_content`, `localization`, `allowed_domains`, `blocked_domains`, `lang`, `recency`.
|
|
14
|
+
- Fetch parameters: `url`, `max_chars` (500–200,000, default 20,000).
|
|
15
|
+
- Result metadata: `date`, `authority` (`-1` → *unknown*), `rerank_score` (≥1.5 annotated), `site_name`; `site_icon` available in `details`.
|
|
16
|
+
- `details` reports the final query, anything trimmed, discarded non-http(s) results, and `estimatedCostUsd`.
|
|
17
|
+
|
|
18
|
+
**Query handling**
|
|
19
|
+
|
|
20
|
+
- A fitting query is sent exactly as written, spacing included.
|
|
21
|
+
- An over-length query is trimmed instead of rejected: recency → language → domain filters → trailing words, with a final code-point cut only if a single token exceeds the budget. Every removal is reported.
|
|
22
|
+
- Trimming is token-aware: quoted phrases stay whole and a trailing operator is never left dangling.
|
|
23
|
+
- `ori_question` longer than 128 code points is cut, and the cut is reported in the output and `details`; the tool-parameter schema has no `maxLength`, so the code-point cut is authoritative.
|
|
24
|
+
- `details.requestedCount` reports the pre-clamp `count` ask.
|
|
25
|
+
- Unknown `recency` values and non-IP `localization` values are rejected with an actionable message.
|
|
26
|
+
|
|
27
|
+
**Transport**
|
|
28
|
+
|
|
29
|
+
- Retries for `408`, `429`, any `5xx`, network failures and timeouts; 3 attempts; `retry-after` honoured in seconds or HTTP-date form, capped at 5 s.
|
|
30
|
+
- Documented remedies for `401`/`402`/`403`/`404`/`413`/`429`; all other `4xx` fail immediately.
|
|
31
|
+
- 30 s search / 60 s fetch timeouts; the caller's abort reason is preserved on every path, including during retry backoff.
|
|
32
|
+
- A 200 search response without a `results` array is rejected as an invalid response rather than reported as "No results".
|
|
33
|
+
- Endpoint override via `PI_DEEPSEEK_BASE_URL` or `DEEPSEEK_SEARCH_BASE_URL`, with `/v1` and trailing slashes stripped.
|
|
34
|
+
- API key from pi's `deepseek` provider, then `DEEPSEEK_API_KEY`; redacted from all error output, and upstream error text is sliced to 400 chars.
|
|
35
|
+
- Fetch URLs are sent normalised, and the 8192-char limit is enforced on both the raw and normalised form.
|
|
36
|
+
|
|
37
|
+
**Safety**
|
|
38
|
+
|
|
39
|
+
- `deepseek_fetch` refuses localhost (including trailing-dot forms), IPv4 private/loopback/CGNAT/link-local/benchmarking ranges, IPv6 `::`, `::1`, `fc00::/7`, `fe80::/10`, and IPv4-mapped IPv6 forms.
|
|
40
|
+
- Markdown unescaping never applies inside fenced code blocks (including indented fences) or inline code spans.
|
|
41
|
+
- Malformed result fields degrade instead of failing the whole call.
|
|
42
|
+
|
|
43
|
+
**Cost and compatibility**
|
|
44
|
+
|
|
45
|
+
- `estimatedCostUsd` = $0.00075 per billable request, following the raw result array so client-side filtering cannot under-report; 0 for an empty result set.
|
|
46
|
+
- Coexists with `pi-web-access`: distinct tool names, no config keys.
|
|
47
|
+
- Validated against DeepSeek API docs as of 2026-10-30.
|
|
48
|
+
|
|
49
|
+
**Tests**
|
|
50
|
+
|
|
51
|
+
- 68 `node:test` cases with mocked HTTP — no key or network required.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pi-deepseek-websearch contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,162 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @dumbmice/pi-deepseek-websearch
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DeepSeek **Search** and **Fetch** API tools for the [pi coding agent](https://pi.dev) — direct access to the fields and parameters that generic web-search wrappers throw away.
|
|
4
|
+
|
|
5
|
+
Two tools, no dependencies, and it **coexists with `pi-web-access`**: different tool names, no web-search.json keys of its own, no settings to migrate.
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
DeepSeek search · 2 results ← abridged; each result also carries site, date, authority and score
|
|
9
|
+
query: DeepSeek V4 Pro release
|
|
10
|
+
|
|
11
|
+
1. DeepSeek-V4-Pro GA Release | DeepSeek API Docs
|
|
12
|
+
https://api-docs.deepseek.com/news/news260813/
|
|
13
|
+
DeepSeek · 2026-08-12 · authority 3/4 · score 2.58 (high quality)
|
|
14
|
+
# DeepSeek-V4-Pro GA Release
|
|
15
|
+
We're launching DeepSeek-V4-Pro today! …
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
`pi-web-access` maps DeepSeek's Search API onto its generic provider interface (compared against `pi-web-access@0.44.0`, verified 2026-10-30). That is the right shape for a 40-provider router, but it loses what makes DeepSeek's API useful:
|
|
21
|
+
|
|
22
|
+
| | `pi-web-access` DeepSeek provider | this extension |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| `date`, `authority`, `rerank_score` | dropped | rendered per result |
|
|
25
|
+
| `ori_question` (ranking hint) | never sent | `ori_question` param |
|
|
26
|
+
| `localization` | never sent | `localization` param |
|
|
27
|
+
| `count` | hidden, clamped to 20 | `count` param, 1–50 |
|
|
28
|
+
| query > 128 chars | hard error, no fallback | trimmed, with the removals reported |
|
|
29
|
+
| 429 / 5xx | no retry | 3 attempts honouring `retry-after` |
|
|
30
|
+
| `need_content` | only via a generic flag | `need_content` param |
|
|
31
|
+
|
|
32
|
+
Use it when you need DeepSeek's ranking, publication dates, or a specific `count`. Keep `pi-web-access` for routing across providers, `fetch_content`'s extraction chain, and `source_check`.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# from npm, once the package is published
|
|
38
|
+
pi install npm:@dumbmice/pi-deepseek-websearch
|
|
39
|
+
|
|
40
|
+
# straight from GitHub
|
|
41
|
+
pi install git:github.com/DumbMice/pi-deepseek-websearch
|
|
42
|
+
|
|
43
|
+
# from a working copy (no publish required)
|
|
44
|
+
pi install /absolute/path/to/pi-deepseek-websearch
|
|
45
|
+
|
|
46
|
+
# try it for one session
|
|
47
|
+
pi -e /absolute/path/to/pi-deepseek-websearch
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Requires Node >= 22.6 (`engines`; the shipped entry point is TypeScript).
|
|
51
|
+
|
|
52
|
+
If you previously installed a single-file copy of this extension in `~/.pi/agent/extensions/`, remove it first — two copies would register `deepseek_search` twice. `pi-web-access` itself needs no change.
|
|
53
|
+
|
|
54
|
+
Confirm it registered: start `pi`, ask *"use deepseek_search to look up the DeepSeek API pricing"*, and you should see a `DeepSeek search · N results` header. Or check the tool list in your session.
|
|
55
|
+
|
|
56
|
+
## Credentials
|
|
57
|
+
|
|
58
|
+
The extension uses, in order:
|
|
59
|
+
|
|
60
|
+
1. pi's `deepseek` provider credentials (`ctx.modelRegistry.getApiKeyForProvider("deepseek")`) — i.e. `/login` or `auth.json`;
|
|
61
|
+
2. `DEEPSEEK_API_KEY`.
|
|
62
|
+
|
|
63
|
+
Endpoint override: `PI_DEEPSEEK_BASE_URL`, then `DEEPSEEK_SEARCH_BASE_URL` (default `https://api.deepseek.com`; a trailing slash or `/v1` suffix is stripped). The bare `DEEPSEEK_BASE_URL` is deliberately ignored: other tools commonly set it to an OpenAI-compatible `/v1` root.
|
|
64
|
+
|
|
65
|
+
Get a key at <https://platform.deepseek.com/api_keys>.
|
|
66
|
+
|
|
67
|
+
## Tools
|
|
68
|
+
|
|
69
|
+
### `deepseek_search`
|
|
70
|
+
|
|
71
|
+
| Parameter | Type | Notes |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `query` | string, required | Operators pass through: `"exact phrase"`, `-word`, `-"phrase"`, `site:a OR site:b`, `-site:c`, `lang:en,zh`, `after:2025-01-01`, `before:2026-06-01`. Capped at 128 chars; longer queries are trimmed (see below) rather than rejected |
|
|
74
|
+
| `count` | integer 1–50 | default 10; the per-request price does not depend on it |
|
|
75
|
+
| `ori_question` | string (cut to 128) | your original question, used for ranking; a longer value is cut to 128 code points and the cut is reported in the output |
|
|
76
|
+
| `need_content` | boolean | return extracted page text when the API provides it (otherwise the `summary`, or the `snippet` if there is no summary), capped at 4,000 chars per result with truncation reported; same price, more context |
|
|
77
|
+
| `allowed_domains` | string[] | compiled to `site:` clauses joined with `OR`; counts toward the 128-char cap |
|
|
78
|
+
| `blocked_domains` | string[] | compiled to `-site:` clauses; counts toward the cap |
|
|
79
|
+
| `lang` | string | e.g. `"zh,en"` → `lang:zh,en` |
|
|
80
|
+
| `recency` | `day`\|`week`\|`month`\|`year` | compiled to `after:<date>` |
|
|
81
|
+
| `localization` | string | IPv4/IPv6 literal used to localize results; anything else is rejected |
|
|
82
|
+
|
|
83
|
+
Each result carries title, URL, site, publication date, authority tier (`0–4`; the API's `-1` renders as *authority unknown*), and rerank score (`≥1.5` annotated as high quality), plus the model-oriented `summary` or the extracted page text. `site_icon` is not rendered but is included in `details`.
|
|
84
|
+
|
|
85
|
+
`details` also reports: the final `query`, `dropped` (anything trimmed), `count` (results returned), `requestedCount`, `discarded` (unusable results, e.g. no `http(s)` URL), `oriQuestionTruncated`, `needContent`, the mapped `results[]`, and `estimatedCostUsd`.
|
|
86
|
+
|
|
87
|
+
### `deepseek_fetch`
|
|
88
|
+
|
|
89
|
+
| Parameter | Type | Notes |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| `url` | string, required | absolute public HTTP(S), ≤8192 chars, no credentials; local/private targets are refused |
|
|
92
|
+
| `max_chars` | integer 500–200000 | default 20000; truncation and clamping are both reported |
|
|
93
|
+
|
|
94
|
+
Returns the page as Markdown, rendered and extracted server-side. A page with no extractable content is an error rather than an empty success. `details` reports `url`, `title`, `chars`, `maxChars`, `truncated` and `estimatedCostUsd`.
|
|
95
|
+
|
|
96
|
+
## Behaviour worth knowing
|
|
97
|
+
|
|
98
|
+
- **The 128-character limit is real** and includes `site:`, `lang:` and `after:` operators. When the query fits, it is sent exactly as written, spacing included. Only an over-long query is rewritten, and then filters are shed first (recency → language → domains), then trailing words, then — only if a single token exceeds the whole budget — the tail is cut. Every trimming removal is printed as `trimmed to fit 128 chars: …`, so the query itself is never silently shortened. Trimming is token-aware: quoted phrases stay whole, and a trailing operator (`site:`, `after:`, a bare `-`) is dropped rather than left dangling.
|
|
99
|
+
- **Retries** cover `408`, `429` and **any** `5xx` — the documented "wait and retry" set — plus network failures and timeouts, up to 3 attempts. `retry-after` is honoured in seconds or HTTP-date form, capped at 5 s. Every other `4xx` fails immediately.
|
|
100
|
+
- **Hints** accompany `401` (key), `402` (top up), `403`/`404` on Fetch (security policy, no usable content), `413` (body too large) and `429` (rate limits); `400` surfaces the API's own message.
|
|
101
|
+
- **Billing**: $0.75 per 1,000 successful requests for both Search and Fetch (<https://api-docs.deepseek.com/guides/search#billing>). A request is billable when it returns HTTP 200 **and** a non-empty result, so `estimatedCostUsd` follows the raw result array even if every URL is later discarded. Failed calls, empty search results and timeouts are reported as free, because no HTTP 200 is observed for them. `count` does not affect price.
|
|
102
|
+
- The docs' note that server-side tool calls are free until 2026-11-01 00:00 Beijing time is scoped to DeepSeek Harness / Claude Code / Codex usage; this extension calls the API directly, so the published per-request price applies.
|
|
103
|
+
- **Limits**: 100 QPS search, 25 QPS fetch per account, plus a per-domain fetch limit (<https://api-docs.deepseek.com/guides/search#rate-limits>).
|
|
104
|
+
- **Empty is not an error**: a search with no matches returns a normal "No results" answer and costs nothing.
|
|
105
|
+
|
|
106
|
+
## Safety
|
|
107
|
+
|
|
108
|
+
- The API key is redacted from every error message before it reaches the model or the transcript.
|
|
109
|
+
- `deepseek_fetch` refuses `localhost` and `*.localhost` (including trailing-dot forms), IPv4 loopback/private/CGNAT/link-local/benchmarking ranges, `::`, `::1`, `fc00::/7`, `fe80::/10`, and IPv4-mapped IPv6 forms such as `::ffff:127.0.0.1`.
|
|
110
|
+
- This is a **policy** choice, not a docs requirement: DeepSeek fetches pages server-side, so the guard exists to avoid handing intranet URLs to a third party. It matches literal addresses and localhost names only — a public hostname that resolves to a private address is not caught, because no DNS lookup is performed.
|
|
111
|
+
- Markdown unescaping of Fetch output (DeepSeek escapes punctuation that was already plain text) never touches fenced blocks — including fences indented inside list items — or inline code spans.
|
|
112
|
+
|
|
113
|
+
## Docs alignment
|
|
114
|
+
|
|
115
|
+
Validated against <https://api-docs.deepseek.com/guides/search>, `/api/search` and `/api/fetch` on **2026-10-30** (`DOCS_SNAPSHOT` in `src/index.ts`). The wire contract is followed exactly where the docs define it: endpoints, auth headers, request fields and defaults, response field semantics, price, QPS and the documented error taxonomy.
|
|
116
|
+
|
|
117
|
+
Client-side deviations and transport details, listed in full. Items producing no signal in the output are marked _(silent)_.
|
|
118
|
+
|
|
119
|
+
1. over-length queries are trimmed rather than rejected, and every removal is reported; a single token longer than the whole budget is cut mid-token;
|
|
120
|
+
2. query whitespace is normalised only when the query must be rewritten, and that is reported in the same line;
|
|
121
|
+
3. leading/trailing whitespace is stripped from `query`, `ori_question` and `localization` before use _(silent — the API rejects all-whitespace queries anyway)_;
|
|
122
|
+
4. result body text (extracted content, else `summary`, else `snippet`) is capped at 4,000 chars per result; the true length is printed;
|
|
123
|
+
5. `count` is clamped to 1–50 instead of the API returning 400 — the schema declares the range too, and clamping is the fallback if a host passes values through unvalidated; `details.requestedCount` still shows the original ask;
|
|
124
|
+
6. results without an `http(s)` URL are discarded defensively — the count is reported and the price still reflects the raw response;
|
|
125
|
+
7. `site_icon` is surfaced in `details` but not rendered;
|
|
126
|
+
8. `ori_question` longer than 128 code points is cut, and the cut is reported in the output and `details`;
|
|
127
|
+
9. `deepseek_fetch` blocks local/private addresses (docs impose no such rule);
|
|
128
|
+
10. Fetch Markdown is unescaped heuristically (docs only promise Markdown) _(silent; never inside code)_;
|
|
129
|
+
11. `max_chars` truncates Fetch output and clamps out-of-range values (again, the schema declares the range), both reported;
|
|
130
|
+
12. a 200 response with empty Fetch content is treated as an error (docs route empty extraction to 404);
|
|
131
|
+
13. `date` is displayed as `YYYY-MM-DD` while the API returns RFC 3339 — the full timestamp stays in `details`;
|
|
132
|
+
14. the Fetch `url` is sent in its normalised form (`URL.toString()`: IDN punycoding, lowercased host, added `/`), and the 8192-char limit is checked against both the raw and the normalised form;
|
|
133
|
+
15. redirects of the API request itself are not followed (`redirect: "error"` on the call to `api.deepseek.com`), so a 3xx from the API surfaces as a fetch error and is retried like a network failure (item 16). Target-page redirects are DeepSeek's concern, not this client's _(silent)_;
|
|
134
|
+
16. network failures and timeouts are retried beyond the documented `408/429/5xx` list (they are unbilled) _(silent)_;
|
|
135
|
+
17. `retry-after` support is an inference — the docs never mention the header — and the delay is capped at 5 s _(silent)_;
|
|
136
|
+
18. the 128- and 8192-character limits are counted in code points. The docs say "1 to 128 Unicode characters" for `query` and "characters" elsewhere, without fixing code points vs UTF-16 units _(silent)_;
|
|
137
|
+
19. client request timeouts are 30 s (search) and 60 s (fetch) **per attempt**, so a call that keeps timing out can run ~90 s (search) or ~180 s (fetch) plus backoff before the third attempt fails; the docs describe no response deadline _(silent)_;
|
|
138
|
+
20. a 200 search body without a `results` array is rejected as an invalid response instead of being reported as "No results";
|
|
139
|
+
21. `localization` must be an IP literal — the docs only say "an optional IP address", so a country code is rejected client-side;
|
|
140
|
+
22. upstream error messages are sliced to 400 characters before display _(silent)_;
|
|
141
|
+
23. result fields of an unexpected type are ignored rather than passed through, so one malformed result degrades instead of failing the call _(silent)_;
|
|
142
|
+
24. `recency` windows are fixed client-side at 1 / 7 / 30 / 365 days for `day` / `week` / `month` / `year`.
|
|
143
|
+
|
|
144
|
+
Transport details, for completeness: requests send `Accept: application/json` alongside the two documented headers; retry backoff is 500 ms then 1 s when no `retry-after` is present (two sleeps for three attempts); and a 200 response with a non-JSON body fails immediately without a retry.
|
|
145
|
+
|
|
146
|
+
If DeepSeek changes the API, bump `DOCS_SNAPSHOT` and update the affected tests.
|
|
147
|
+
|
|
148
|
+
## Development
|
|
149
|
+
|
|
150
|
+
Requires Node >= 22.6 (the test runner uses `--experimental-strip-types`).
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
npm test # node:test, 68 tests, mocked HTTP — no API key or network needed
|
|
154
|
+
npm run typecheck # tsc --noEmit, strict
|
|
155
|
+
npm run pack:check # tarball contents (no tests, no devDependencies)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The suite covers query trimming order and the 128 cap, caller-spacing preservation, documented validation rules, metadata rendering (`authority: -1`, score annotation, summary/snippet/content precedence, malformed fields), retry classification for every documented status, key redaction, URL safety including encoding bypasses, abort handling, and both tool call shapes.
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
MIT. Issues and source: <https://github.com/DumbMice/pi-deepseek-websearch>.
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dumbmice/pi-deepseek-websearch",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Web search and page fetch for pi via DeepSeek's Search & Fetch APIs: publication dates, authority, rerank score, ori_question, count up to 50, and graceful 128-char handling. Coexists with pi-web-access.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "yangbangcheng",
|
|
9
|
+
"email": "bangchengyang@hotmail.com"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/DumbMice/pi-deepseek-websearch#readme",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/DumbMice/pi-deepseek-websearch.git"
|
|
15
|
+
},
|
|
16
|
+
"bugs": "https://github.com/DumbMice/pi-deepseek-websearch/issues",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"pi-package",
|
|
19
|
+
"pi-extension",
|
|
20
|
+
"pi-coding-agent",
|
|
21
|
+
"pi",
|
|
22
|
+
"deepseek",
|
|
23
|
+
"web-search",
|
|
24
|
+
"web-fetch",
|
|
25
|
+
"search-api",
|
|
26
|
+
"llm-tools",
|
|
27
|
+
"websearch",
|
|
28
|
+
"deepseek-search"
|
|
29
|
+
],
|
|
30
|
+
"files": [
|
|
31
|
+
"src",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE",
|
|
34
|
+
"CHANGELOG.md"
|
|
35
|
+
],
|
|
36
|
+
"pi": {
|
|
37
|
+
"extensions": [
|
|
38
|
+
"./src/index.ts"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"test": "node --experimental-strip-types --no-warnings --test test/index.test.ts",
|
|
43
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
44
|
+
"pack:check": "npm pack --dry-run"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=22.6"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^22.20.5",
|
|
51
|
+
"typescript": "^5.9.3"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dumbmice/pi-deepseek-websearch — web search and page fetch over DeepSeek's Search & Fetch APIs.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: pi-web-access maps DeepSeek's Search API onto a generic provider
|
|
5
|
+
* interface, which drops `date`, `authority` and `rerank_score`, never sends
|
|
6
|
+
* `ori_question`, clamps results to 20 without exposing `count`, does not retry, and
|
|
7
|
+
* fails the whole call (without routing onward) on queries over 128 characters.
|
|
8
|
+
* This extension registers its own tools, so it coexists with pi-web-access.
|
|
9
|
+
*
|
|
10
|
+
* Tools:
|
|
11
|
+
* deepseek_search — POST /v1/tools/search
|
|
12
|
+
* deepseek_fetch — POST /v1/tools/fetch
|
|
13
|
+
*
|
|
14
|
+
* Auth: pi's `deepseek` provider credentials (ctx.modelRegistry), then DEEPSEEK_API_KEY.
|
|
15
|
+
* Endpoint override: PI_DEEPSEEK_BASE_URL, then DEEPSEEK_SEARCH_BASE_URL.
|
|
16
|
+
*
|
|
17
|
+
* Docs: https://api-docs.deepseek.com/guides/search
|
|
18
|
+
* API reference snapshot used for validation: 2026-10-30.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { isIP } from "node:net";
|
|
22
|
+
|
|
23
|
+
export const DOCS_SNAPSHOT = "2026-10-30";
|
|
24
|
+
export const PRICE_PER_SUCCESSFUL_REQUEST_USD = 0.00075;
|
|
25
|
+
export const MAX_QUERY_CHARS = 128;
|
|
26
|
+
|
|
27
|
+
const DEFAULT_BASE_URL = "https://api.deepseek.com";
|
|
28
|
+
const SEARCH_TIMEOUT_MS = 30_000;
|
|
29
|
+
const FETCH_TIMEOUT_MS = 60_000;
|
|
30
|
+
const MAX_ATTEMPTS = 3;
|
|
31
|
+
const MAX_ORI_QUESTION_CHARS = 128;
|
|
32
|
+
const MAX_RESULT_CONTENT_CHARS = 4_000;
|
|
33
|
+
const MAX_FETCH_CONTENT_CHARS = 20_000;
|
|
34
|
+
|
|
35
|
+
export interface SearchOptions {
|
|
36
|
+
query: string;
|
|
37
|
+
count: number;
|
|
38
|
+
need_content: boolean;
|
|
39
|
+
ori_question?: string;
|
|
40
|
+
localization?: string;
|
|
41
|
+
allowedDomains: string[];
|
|
42
|
+
blockedDomains: string[];
|
|
43
|
+
lang?: string;
|
|
44
|
+
recency?: "day" | "week" | "month" | "year";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface DeepSeekResult {
|
|
48
|
+
rerank_score?: number;
|
|
49
|
+
title?: string;
|
|
50
|
+
url?: string;
|
|
51
|
+
snippet?: string;
|
|
52
|
+
summary?: string;
|
|
53
|
+
content?: string;
|
|
54
|
+
site_name?: string | null;
|
|
55
|
+
site_icon?: string | null;
|
|
56
|
+
date?: string | null;
|
|
57
|
+
authority?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
class DeepSeekHttpError extends Error {
|
|
61
|
+
readonly status: number;
|
|
62
|
+
constructor(status: number, message: string) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = "DeepSeekHttpError";
|
|
65
|
+
this.status = status;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ---------------------------------------------------------------- transport */
|
|
70
|
+
|
|
71
|
+
const RECENCY_DAYS: Record<NonNullable<SearchOptions["recency"]>, number> = {
|
|
72
|
+
day: 1,
|
|
73
|
+
week: 7,
|
|
74
|
+
month: 30,
|
|
75
|
+
year: 365,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const REDACT = (text: string, key: string | undefined): string =>
|
|
79
|
+
key && key.length > 8 ? text.split(key).join("[redacted]") : text;
|
|
80
|
+
|
|
81
|
+
const charLength = (value: string): number => Array.from(value).length;
|
|
82
|
+
|
|
83
|
+
export function baseUrl(): string {
|
|
84
|
+
const configured =
|
|
85
|
+
process.env.PI_DEEPSEEK_BASE_URL ?? process.env.DEEPSEEK_SEARCH_BASE_URL ?? DEFAULT_BASE_URL;
|
|
86
|
+
return configured.replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function resolveApiKey(ctx: any): Promise<string> {
|
|
90
|
+
let fromPi: unknown;
|
|
91
|
+
try {
|
|
92
|
+
fromPi = await ctx?.modelRegistry?.getApiKeyForProvider?.("deepseek");
|
|
93
|
+
} catch (err) {
|
|
94
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
95
|
+
throw new Error(`Could not read pi's \`deepseek\` provider credentials: ${reason}`);
|
|
96
|
+
}
|
|
97
|
+
const key =
|
|
98
|
+
typeof fromPi === "string" && fromPi.trim() ? fromPi.trim() : process.env.DEEPSEEK_API_KEY?.trim();
|
|
99
|
+
if (!key) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
"DeepSeek API key not found. Either:\n" +
|
|
102
|
+
" 1. Configure the `deepseek` provider in pi (/login), or\n" +
|
|
103
|
+
" 2. Set DEEPSEEK_API_KEY in the environment\n" +
|
|
104
|
+
"Get a key at https://platform.deepseek.com/api_keys",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return key;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Documented per-status guidance, so a failure is actionable instead of opaque. */
|
|
111
|
+
export function errorHint(status: number, path: "/v1/tools/search" | "/v1/tools/fetch"): string {
|
|
112
|
+
if (status === 401)
|
|
113
|
+
return "\nHint: the DeepSeek API key is missing or invalid (pi `deepseek` provider or DEEPSEEK_API_KEY).";
|
|
114
|
+
if (status === 402)
|
|
115
|
+
return "\nHint: the account has insufficient balance — top up at https://platform.deepseek.com/top_up and retry.";
|
|
116
|
+
if (status === 403 && path === "/v1/tools/fetch")
|
|
117
|
+
return "\nHint: DeepSeek's Fetch security policy rejected this URL.";
|
|
118
|
+
if (status === 404 && path === "/v1/tools/fetch")
|
|
119
|
+
return "\nHint: no usable page content (target unavailable, blocked, unsupported type, or an error page).";
|
|
120
|
+
if (status === 413)
|
|
121
|
+
return path === "/v1/tools/fetch"
|
|
122
|
+
? "\nHint: the request body was too large; shorten the URL."
|
|
123
|
+
: "\nHint: the request body was too large; shorten the query.";
|
|
124
|
+
if (status === 429)
|
|
125
|
+
return "\nHint: rate limit (100 QPS search / 25 QPS fetch account-wide, plus a per-domain fetch limit). Retry after a pause.";
|
|
126
|
+
return "";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Docs: "For HTTP 408, 429, or 5xx errors, wait and retry." Every other status fails fast
|
|
131
|
+
* ("Do not retry other 4xx errors without changing the request.").
|
|
132
|
+
*/
|
|
133
|
+
function isRetryableStatus(status: number): boolean {
|
|
134
|
+
return status === 408 || status === 429 || status >= 500;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function retryDelayMs(response: Response, attempt: number): number {
|
|
138
|
+
const RETRY_CAP_MS = 5_000;
|
|
139
|
+
const header = response.headers.get("retry-after");
|
|
140
|
+
if (header) {
|
|
141
|
+
const seconds = Number(header);
|
|
142
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, RETRY_CAP_MS);
|
|
143
|
+
const at = Date.parse(header);
|
|
144
|
+
if (Number.isFinite(at)) return Math.min(Math.max(at - Date.now(), 0), RETRY_CAP_MS);
|
|
145
|
+
}
|
|
146
|
+
return 500 * 2 ** attempt;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Preserve the caller's abort reason; hosts use it to tell cancellation from failure. */
|
|
150
|
+
function abortError(signal: AbortSignal): Error {
|
|
151
|
+
return signal.reason instanceof Error ? signal.reason : new Error("Aborted");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
|
|
155
|
+
new Promise((resolve, reject) => {
|
|
156
|
+
if (signal?.aborted) return reject(abortError(signal));
|
|
157
|
+
const timer = setTimeout(() => {
|
|
158
|
+
signal?.removeEventListener("abort", onAbort);
|
|
159
|
+
resolve();
|
|
160
|
+
}, ms);
|
|
161
|
+
const onAbort = () => {
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
reject(abortError(signal!));
|
|
164
|
+
};
|
|
165
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
async function postDeepSeek<T>(
|
|
169
|
+
path: "/v1/tools/search" | "/v1/tools/fetch",
|
|
170
|
+
body: Record<string, unknown>,
|
|
171
|
+
apiKey: string,
|
|
172
|
+
signal: AbortSignal | undefined,
|
|
173
|
+
timeoutMs: number,
|
|
174
|
+
): Promise<T> {
|
|
175
|
+
if (signal?.aborted) throw abortError(signal);
|
|
176
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
177
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
178
|
+
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
179
|
+
let response: Response;
|
|
180
|
+
try {
|
|
181
|
+
response = await fetch(`${baseUrl()}${path}`, {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: {
|
|
184
|
+
Authorization: `Bearer ${apiKey}`,
|
|
185
|
+
"Content-Type": "application/json",
|
|
186
|
+
Accept: "application/json",
|
|
187
|
+
},
|
|
188
|
+
body: JSON.stringify(body),
|
|
189
|
+
redirect: "error",
|
|
190
|
+
signal: requestSignal,
|
|
191
|
+
});
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (signal?.aborted) throw abortError(signal);
|
|
194
|
+
const failure =
|
|
195
|
+
timeout.aborted
|
|
196
|
+
? new Error(`DeepSeek ${path} timed out after ${Math.round(timeoutMs / 1000)}s`)
|
|
197
|
+
: new Error(
|
|
198
|
+
`DeepSeek ${path} request failed: ${REDACT(err instanceof Error ? err.message : String(err), apiKey)}`,
|
|
199
|
+
);
|
|
200
|
+
if (attempt < MAX_ATTEMPTS - 1) {
|
|
201
|
+
await sleep(500 * 2 ** attempt, signal);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
throw failure;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (response.ok) {
|
|
208
|
+
try {
|
|
209
|
+
return (await response.json()) as T;
|
|
210
|
+
} catch {
|
|
211
|
+
throw new Error(`DeepSeek ${path} returned invalid JSON`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const raw = await response.text().catch(() => "");
|
|
216
|
+
let message = raw;
|
|
217
|
+
try {
|
|
218
|
+
const parsed = JSON.parse(raw) as { error?: { message?: string } };
|
|
219
|
+
message = parsed.error?.message?.trim() || raw;
|
|
220
|
+
} catch {}
|
|
221
|
+
message = REDACT(message.slice(0, 400), apiKey);
|
|
222
|
+
|
|
223
|
+
const error = new DeepSeekHttpError(
|
|
224
|
+
response.status,
|
|
225
|
+
`DeepSeek ${path} failed (HTTP ${response.status}): ${message}${errorHint(response.status, path)}`,
|
|
226
|
+
);
|
|
227
|
+
if (isRetryableStatus(response.status) && attempt < MAX_ATTEMPTS - 1) {
|
|
228
|
+
await sleep(retryDelayMs(response, attempt), signal);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
throw new Error(`DeepSeek ${path} failed`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* ------------------------------------------------------------------- query */
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Trim to the API's hard character budget (docs: 1-128 Unicode characters including
|
|
240
|
+
* operators). Filters are shed first — recency, then language, then domains — and only
|
|
241
|
+
* then trailing free-text words. Every removal is reported so the caller can see that
|
|
242
|
+
* the request differs from what was asked for.
|
|
243
|
+
*/
|
|
244
|
+
export function fitQuery(options: SearchOptions): { query: string; dropped: string[] } {
|
|
245
|
+
const dropped: string[] = [];
|
|
246
|
+
const filters: string[] = [];
|
|
247
|
+
if (options.allowedDomains.length > 0) {
|
|
248
|
+
filters.push(options.allowedDomains.map((d) => `site:${d}`).join(" OR "));
|
|
249
|
+
}
|
|
250
|
+
for (const domain of options.blockedDomains) filters.push(`-site:${domain}`);
|
|
251
|
+
if (options.lang) filters.push(`lang:${options.lang}`);
|
|
252
|
+
if (options.recency) {
|
|
253
|
+
const days = RECENCY_DAYS[options.recency];
|
|
254
|
+
if (days === undefined) {
|
|
255
|
+
throw new Error(`recency must be one of ${Object.keys(RECENCY_DAYS).join(", ")}.`);
|
|
256
|
+
}
|
|
257
|
+
filters.push(`after:${new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10)}`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const raw = options.query.trim();
|
|
261
|
+
const join = (words: string[], activeFilters: string[]): string =>
|
|
262
|
+
[words.join(" "), ...activeFilters].filter(Boolean).join(" ");
|
|
263
|
+
|
|
264
|
+
// Docs: "The query is forwarded with its search syntax intact." Keep the caller's
|
|
265
|
+
// spacing and operator text whenever the assembled query already fits.
|
|
266
|
+
const untouched = [raw, ...filters].filter(Boolean).join(" ");
|
|
267
|
+
if (charLength(untouched) <= MAX_QUERY_CHARS) return { query: untouched, dropped };
|
|
268
|
+
|
|
269
|
+
// Only a query that cannot fit is rewritten, token by token, and every change is reported.
|
|
270
|
+
// An unescaped quoted phrase is one token: slicing mid-phrase would change its meaning.
|
|
271
|
+
// Backslash-escaped quotes are not special-cased (only reachable on over-length queries).
|
|
272
|
+
const matchedTokens = raw.match(/"[^"]*"|\S+/g);
|
|
273
|
+
const tokens: string[] = matchedTokens ? [...matchedTokens] : [raw];
|
|
274
|
+
const quotesWereBalanced = ((raw.match(/"/g) ?? []).length % 2) === 0;
|
|
275
|
+
if (raw !== tokens.join(" ")) dropped.push("query whitespace normalised");
|
|
276
|
+
let words = tokens;
|
|
277
|
+
let active = [...filters];
|
|
278
|
+
|
|
279
|
+
const overBudget = () => charLength(join(words, active)) > MAX_QUERY_CHARS;
|
|
280
|
+
const dropFilter = (predicate: (filter: string) => boolean, label: (filter: string) => string): void => {
|
|
281
|
+
const index = active.findIndex(predicate);
|
|
282
|
+
if (index < 0 || !overBudget()) return;
|
|
283
|
+
const [removed] = active.splice(index, 1);
|
|
284
|
+
dropped.push(label(removed!));
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
dropFilter((f) => f.startsWith("after:"), (f) => `recency (${f})`);
|
|
288
|
+
dropFilter((f) => f.startsWith("lang:"), (f) => `language (${f})`);
|
|
289
|
+
while (overBudget() && active.length > 0) {
|
|
290
|
+
const removed = active.pop()!;
|
|
291
|
+
dropped.push(`domain filter (${removed.slice(0, 40)})`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const wordsBefore = words.join(" ");
|
|
295
|
+
while (words.length > 1 && overBudget()) words = words.slice(0, -1);
|
|
296
|
+
// Never leave a dangling operator (`site:`, `-site:`, `after:`, a bare `-`) or an
|
|
297
|
+
// unbalanced quote behind: DeepSeek would answer 400 and no retry would help.
|
|
298
|
+
const danglingOperator = /^(?:-|-[A-Za-z]+:|[A-Za-z]+:)$/;
|
|
299
|
+
const quoteCount = () => (words.join(" ").match(/"/g) ?? []).length;
|
|
300
|
+
while (
|
|
301
|
+
words.length > 1 &&
|
|
302
|
+
(danglingOperator.test(words[words.length - 1]!) || (quotesWereBalanced && quoteCount() % 2 === 1))
|
|
303
|
+
) {
|
|
304
|
+
words = words.slice(0, -1);
|
|
305
|
+
}
|
|
306
|
+
if (words.join(" ") !== wordsBefore && !dropped.includes("trailing query words")) {
|
|
307
|
+
dropped.push("trailing query words");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let query = join(words, active);
|
|
311
|
+
if (charLength(query) > MAX_QUERY_CHARS) {
|
|
312
|
+
// A single token longer than the whole budget: cutting it is unavoidable, so do it
|
|
313
|
+
// by code point and report it rather than sending an invalid request.
|
|
314
|
+
query = Array.from(query).slice(0, MAX_QUERY_CHARS).join("").trim();
|
|
315
|
+
// A cut can land on a bare operator (`-`, `:`), which the API would reject.
|
|
316
|
+
while (query.length > 0 && /[-:]$/.test(query)) query = Array.from(query).slice(0, -1).join("").trimEnd();
|
|
317
|
+
if (quotesWereBalanced && (query.match(/"/g) ?? []).length % 2 === 1) {
|
|
318
|
+
// The cut left one unmatched quote: remove that quote rather than invent a pair.
|
|
319
|
+
const balanced = Array.from(query);
|
|
320
|
+
const lastQuote = balanced.lastIndexOf('"');
|
|
321
|
+
if (lastQuote >= 0) balanced.splice(lastQuote, 1);
|
|
322
|
+
query = balanced.join("").trimEnd();
|
|
323
|
+
}
|
|
324
|
+
dropped.push("query tail");
|
|
325
|
+
}
|
|
326
|
+
return { query, dropped };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Docs: 1-128 Unicode characters and must not be all whitespace. */
|
|
330
|
+
export function assertQueryText(value: string): string {
|
|
331
|
+
const trimmed = value.trim();
|
|
332
|
+
if (trimmed.length === 0) throw new Error("query must not be empty or all whitespace.");
|
|
333
|
+
return trimmed;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Docs: `localization` is "an optional IP address used to localize search results". */
|
|
337
|
+
export function assertLocalization(value: string): string {
|
|
338
|
+
const trimmed = value.trim();
|
|
339
|
+
if (isIP(trimmed) === 0)
|
|
340
|
+
throw new Error(`localization must be an IP address, got ${JSON.stringify(trimmed)}.`);
|
|
341
|
+
return trimmed;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Normalise a WHATWG hostname: drop IPv6 brackets, case, and trailing dots
|
|
346
|
+
* (`localhost.` and `localhost` are the same host to a resolver).
|
|
347
|
+
*/
|
|
348
|
+
function normalizeHost(hostname: string): string {
|
|
349
|
+
return hostname.replace(/^\[|\]$/g, "").replace(/\.+$/, "").toLowerCase();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** `::ffff:127.0.0.1`, `::ffff:7f00:1`, `::7f00:1` and NAT64 `64:ff9b::7f00:1` all mean 127.0.0.1. */
|
|
353
|
+
function embeddedIpv4(host: string): string | null {
|
|
354
|
+
const tail = host.match(/^(?:64:ff9b::|::ffff:|::)(.+)$/)?.[1];
|
|
355
|
+
if (!tail) return null;
|
|
356
|
+
if (tail.includes(".")) return tail;
|
|
357
|
+
const groups = tail.split(":");
|
|
358
|
+
if (groups.length !== 2) return null;
|
|
359
|
+
const hi = Number.parseInt(groups[0]!, 16);
|
|
360
|
+
const lo = Number.parseInt(groups[1]!, 16);
|
|
361
|
+
if (!Number.isFinite(hi) || !Number.isFinite(lo)) return null;
|
|
362
|
+
return [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff].join(".");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** RFC 1918, loopback, link-local, CGNAT, "this network" and benchmarking ranges. */
|
|
366
|
+
function isPrivateIpv4(ip: string): boolean {
|
|
367
|
+
const parts = ip.split(".").map(Number);
|
|
368
|
+
if (parts.length !== 4) return false;
|
|
369
|
+
if (parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
|
|
370
|
+
const [a, b] = parts as [number, number, number, number];
|
|
371
|
+
return (
|
|
372
|
+
a === 0 ||
|
|
373
|
+
a === 10 ||
|
|
374
|
+
a === 127 ||
|
|
375
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
376
|
+
(a === 169 && b === 254) ||
|
|
377
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
378
|
+
(a === 192 && b === 168) ||
|
|
379
|
+
(a === 198 && (b === 18 || b === 19))
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function isPrivateHost(hostname: string): boolean {
|
|
384
|
+
const host = normalizeHost(hostname);
|
|
385
|
+
const candidate = embeddedIpv4(host) ?? host;
|
|
386
|
+
if (candidate === "localhost" || candidate.endsWith(".localhost")) return true;
|
|
387
|
+
if (isIP(candidate) === 4) return isPrivateIpv4(candidate);
|
|
388
|
+
if (isIP(candidate) === 6) {
|
|
389
|
+
if (candidate === "::" || candidate === "::1") return true;
|
|
390
|
+
if (/^f[cd][0-9a-f]{0,2}::?/.test(candidate)) return true; // fc00::/7 unique-local
|
|
391
|
+
if (/^fe[89ab][0-9a-f]?::?/.test(candidate)) return true; // fe80::/10 link-local
|
|
392
|
+
}
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Docs: http(s), a host, no credentials, at most 8192 characters. */
|
|
397
|
+
export function assertFetchableUrl(raw: string): URL {
|
|
398
|
+
if (charLength(raw) > 8192) throw new Error("url must be at most 8192 characters.");
|
|
399
|
+
let url: URL;
|
|
400
|
+
try {
|
|
401
|
+
url = new URL(raw);
|
|
402
|
+
} catch {
|
|
403
|
+
throw new Error("url must be a valid HTTP(S) URL with a host.");
|
|
404
|
+
}
|
|
405
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("url must use http or https.");
|
|
406
|
+
if (url.username || url.password) throw new Error("url must not contain credentials.");
|
|
407
|
+
// The request sends the normalised form, so the limit must hold for that form too
|
|
408
|
+
// (IDN punycoding can make it longer than what the caller passed).
|
|
409
|
+
if (charLength(url.toString()) > 8192) throw new Error("url must be at most 8192 characters.");
|
|
410
|
+
|
|
411
|
+
// Extra safety beyond the docs: DeepSeek fetches server-side, so refuse to hand it
|
|
412
|
+
// intranet targets. Only literal local/private addresses and localhost names are
|
|
413
|
+
// caught here; a public hostname that resolves privately is not (no DNS lookup).
|
|
414
|
+
if (isPrivateHost(url.hostname)) throw new Error("url must not target a local or private address.");
|
|
415
|
+
return url;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** DeepSeek Fetch over-escapes punctuation that was already plain text. */
|
|
419
|
+
export function cleanMarkdown(markdown: string): string {
|
|
420
|
+
let fence: { char: string; length: number } | null = null;
|
|
421
|
+
return markdown
|
|
422
|
+
.split("\n")
|
|
423
|
+
.map((line) => {
|
|
424
|
+
const marker = line.match(/^(\s*)(`{3,}|~{3,})/);
|
|
425
|
+
if (marker) {
|
|
426
|
+
const token = marker[2]!;
|
|
427
|
+
if (fence === null) {
|
|
428
|
+
fence = { char: token[0]!, length: token.length };
|
|
429
|
+
} else if (
|
|
430
|
+
token[0] === fence.char &&
|
|
431
|
+
token.length >= fence.length &&
|
|
432
|
+
line.slice(marker[0].length).trim() === ""
|
|
433
|
+
) {
|
|
434
|
+
fence = null;
|
|
435
|
+
}
|
|
436
|
+
return line;
|
|
437
|
+
}
|
|
438
|
+
// Inside a fenced block, escapes are real syntax: leave the line untouched.
|
|
439
|
+
if (fence !== null) return line;
|
|
440
|
+
return line
|
|
441
|
+
.split(/(`+[^`]*`+)/g)
|
|
442
|
+
.map((part, index) =>
|
|
443
|
+
index % 2 === 0 ? part.replace(/\\([/.,:()&+#>|!_\-"'])/g, "$1") : part,
|
|
444
|
+
)
|
|
445
|
+
.join("");
|
|
446
|
+
})
|
|
447
|
+
.join("\n");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/* ---------------------------------------------------------------- rendering */
|
|
451
|
+
|
|
452
|
+
export function formatResult(result: DeepSeekResult, index: number, withContent: boolean): string {
|
|
453
|
+
// Response fields are trusted to exist but not to have the documented type: a single
|
|
454
|
+
// malformed result must degrade, not destroy the whole answer.
|
|
455
|
+
const text = (value: unknown): string => (typeof value === "string" ? value.trim() : "");
|
|
456
|
+
const lines: string[] = [];
|
|
457
|
+
lines.push(`${index + 1}. ${text(result.title) || "(untitled)"}`);
|
|
458
|
+
lines.push(` ${text(result.url)}`);
|
|
459
|
+
|
|
460
|
+
const meta: string[] = [];
|
|
461
|
+
const siteName = text(result.site_name);
|
|
462
|
+
if (siteName) meta.push(siteName);
|
|
463
|
+
const date = text(result.date);
|
|
464
|
+
if (date) meta.push(date.slice(0, 10));
|
|
465
|
+
if (typeof result.authority === "number" && Number.isFinite(result.authority)) {
|
|
466
|
+
// Docs: 0-4, where -1 means the tier is unknown.
|
|
467
|
+
meta.push(result.authority < 0 ? "authority unknown" : `authority ${result.authority}/4`);
|
|
468
|
+
}
|
|
469
|
+
if (typeof result.rerank_score === "number" && Number.isFinite(result.rerank_score)) {
|
|
470
|
+
meta.push(`score ${result.rerank_score.toFixed(2)}${result.rerank_score >= 1.5 ? " (high quality)" : ""}`);
|
|
471
|
+
}
|
|
472
|
+
if (meta.length > 0) lines.push(` ${meta.join(" · ")}`);
|
|
473
|
+
|
|
474
|
+
const content = typeof result.content === "string" ? result.content.trim() : "";
|
|
475
|
+
const summary = typeof result.summary === "string" ? result.summary.trim() : "";
|
|
476
|
+
const snippet = typeof result.snippet === "string" ? result.snippet.trim() : "";
|
|
477
|
+
const body = withContent && content ? content : summary || snippet;
|
|
478
|
+
if (body) {
|
|
479
|
+
if (charLength(body) > MAX_RESULT_CONTENT_CHARS) {
|
|
480
|
+
const kept = Array.from(body).slice(0, MAX_RESULT_CONTENT_CHARS).join("");
|
|
481
|
+
lines.push(kept.replace(/^/gm, " "));
|
|
482
|
+
lines.push(` … content truncated (${charLength(body)} chars total)`);
|
|
483
|
+
} else {
|
|
484
|
+
lines.push(body.replace(/^/gm, " "));
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return lines.join("\n");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/* ------------------------------------------------------------------- tools */
|
|
491
|
+
|
|
492
|
+
const SEARCH_SCHEMA = {
|
|
493
|
+
type: "object",
|
|
494
|
+
properties: {
|
|
495
|
+
query: {
|
|
496
|
+
type: "string",
|
|
497
|
+
minLength: 1,
|
|
498
|
+
description:
|
|
499
|
+
'Search query. Supports operators: "exact phrase", -word, -"phrase", site:a.com OR site:b.com, -site:c.com, lang:en,zh, after:2025-01-01, before:2026-06-01. Queries are capped at 128 chars including operators; anything longer is trimmed automatically (filters shed first, then trailing words) and the removals are reported in the output — do not pre-shorten.',
|
|
500
|
+
},
|
|
501
|
+
count: {
|
|
502
|
+
type: "integer",
|
|
503
|
+
minimum: 1,
|
|
504
|
+
maximum: 50,
|
|
505
|
+
description: "Results to return (default 10). Does not change the per-request price.",
|
|
506
|
+
},
|
|
507
|
+
ori_question: {
|
|
508
|
+
type: "string",
|
|
509
|
+
description:
|
|
510
|
+
"The user's original question. Improves query understanding and ranking; pass it as-is even if the query is a shortened form. Values over 128 characters are cut and the cut is reported; this ranking signal is unavailable through generic web-search wrappers.",
|
|
511
|
+
},
|
|
512
|
+
need_content: {
|
|
513
|
+
type: "boolean",
|
|
514
|
+
description:
|
|
515
|
+
"Return extracted page text when the API provides it (falls back to the summary, then the snippet) — up to 4,000 chars per result, with any truncation reported. Same price per request; uses more context.",
|
|
516
|
+
},
|
|
517
|
+
allowed_domains: {
|
|
518
|
+
type: "array",
|
|
519
|
+
items: { type: "string" },
|
|
520
|
+
description:
|
|
521
|
+
"Restrict results to these domains (compiled into site: OR site:). Counts toward the 128-char query cap, so a long domain list can be dropped for very long queries — any drop is reported in the output.",
|
|
522
|
+
},
|
|
523
|
+
blocked_domains: {
|
|
524
|
+
type: "array",
|
|
525
|
+
items: { type: "string" },
|
|
526
|
+
description:
|
|
527
|
+
"Exclude these domains (compiled into -site:). Counts toward the 128-char query cap and may be dropped for very long queries; any drop is reported.",
|
|
528
|
+
},
|
|
529
|
+
lang: { type: "string", description: 'Language codes, e.g. "zh,en" (becomes lang:).' },
|
|
530
|
+
recency: {
|
|
531
|
+
type: "string",
|
|
532
|
+
enum: ["day", "week", "month", "year"],
|
|
533
|
+
description: "Published within this window (becomes after:<date>).",
|
|
534
|
+
},
|
|
535
|
+
localization: {
|
|
536
|
+
type: "string",
|
|
537
|
+
description: "Optional IPv4/IPv6 literal used to localize results (e.g. 203.0.113.7); country codes are rejected.",
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
required: ["query"],
|
|
541
|
+
additionalProperties: false,
|
|
542
|
+
} as const;
|
|
543
|
+
|
|
544
|
+
const FETCH_SCHEMA = {
|
|
545
|
+
type: "object",
|
|
546
|
+
properties: {
|
|
547
|
+
url: {
|
|
548
|
+
type: "string",
|
|
549
|
+
description:
|
|
550
|
+
"Absolute public HTTP(S) URL (≤8192 chars) with no embedded credentials, read as Markdown. Local and private addresses are refused.",
|
|
551
|
+
},
|
|
552
|
+
max_chars: {
|
|
553
|
+
type: "integer",
|
|
554
|
+
minimum: 500,
|
|
555
|
+
maximum: 200_000,
|
|
556
|
+
description: `Return at most this many characters (default ${MAX_FETCH_CONTENT_CHARS}). Values outside 500-200000 are clamped, and the clamp is reported in the output.`,
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
required: ["url"],
|
|
560
|
+
additionalProperties: false,
|
|
561
|
+
} as const;
|
|
562
|
+
|
|
563
|
+
export default function (pi: any): void {
|
|
564
|
+
pi.registerTool({
|
|
565
|
+
name: "deepseek_search",
|
|
566
|
+
label: "DeepSeek Search",
|
|
567
|
+
description:
|
|
568
|
+
"Search the live web with DeepSeek's search engine (in-house index, reranked on relevance + authority + freshness). " +
|
|
569
|
+
"Returns title, URL, site, publication date, authority tier (0-4, or 'unknown') and rerank score, plus a model-oriented summary or, with need_content, extracted page text. " +
|
|
570
|
+
"Supports count (1-50), ori_question for ranking, domain/lang/date filters, and localization. " +
|
|
571
|
+
"Choose this instead of a generic web_search when the answer depends on when something was published, you need authoritative sources, you want 21-50 results, or you can pass the user's original question as ori_question. " +
|
|
572
|
+
"Search is billed per successful request ($0.00075); a query that returns nothing costs nothing.",
|
|
573
|
+
promptSnippet: "Search the web via DeepSeek's search engine (dates, authority, ori_question)",
|
|
574
|
+
parameters: SEARCH_SCHEMA,
|
|
575
|
+
async execute(
|
|
576
|
+
_toolCallId: string,
|
|
577
|
+
params: any = {},
|
|
578
|
+
signal: AbortSignal | undefined,
|
|
579
|
+
_onUpdate: unknown,
|
|
580
|
+
ctx: any,
|
|
581
|
+
) {
|
|
582
|
+
const rawCount = Number(params.count ?? 10);
|
|
583
|
+
const requestedCount = Number.isFinite(rawCount) ? Math.floor(rawCount) : 10;
|
|
584
|
+
const domains = (value: unknown, label: string): string[] => {
|
|
585
|
+
if (value === undefined || value === null) return [];
|
|
586
|
+
if (!Array.isArray(value)) throw new Error(`${label} must be an array of domain names.`);
|
|
587
|
+
return value
|
|
588
|
+
.filter((entry): entry is string => typeof entry === "string")
|
|
589
|
+
.map((entry) => entry.trim())
|
|
590
|
+
.filter(Boolean);
|
|
591
|
+
};
|
|
592
|
+
const oriChars = Array.from(
|
|
593
|
+
typeof params.ori_question === "string" ? params.ori_question.trim() : "",
|
|
594
|
+
);
|
|
595
|
+
const oriQuestion = oriChars.length > 0 ? oriChars.slice(0, MAX_ORI_QUESTION_CHARS).join("") : undefined;
|
|
596
|
+
const oriQuestionTruncated = oriChars.length > MAX_ORI_QUESTION_CHARS;
|
|
597
|
+
const options: SearchOptions = {
|
|
598
|
+
query: assertQueryText(String(params.query ?? "")),
|
|
599
|
+
count: Math.min(50, Math.max(1, requestedCount)),
|
|
600
|
+
need_content: params.need_content === true,
|
|
601
|
+
allowedDomains: domains(params.allowed_domains, "allowed_domains"),
|
|
602
|
+
blockedDomains: domains(params.blocked_domains, "blocked_domains"),
|
|
603
|
+
...(oriQuestion ? { ori_question: oriQuestion } : {}),
|
|
604
|
+
...(typeof params.lang === "string" && params.lang.trim() ? { lang: params.lang.trim() } : {}),
|
|
605
|
+
...(params.recency ? { recency: params.recency } : {}),
|
|
606
|
+
...(typeof params.localization === "string" && params.localization.trim()
|
|
607
|
+
? { localization: assertLocalization(params.localization) }
|
|
608
|
+
: {}),
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
// Validate and trim first: a bad parameter should not be masked by a missing key.
|
|
612
|
+
const { query, dropped } = fitQuery(options);
|
|
613
|
+
const apiKey = await resolveApiKey(ctx);
|
|
614
|
+
|
|
615
|
+
const body: Record<string, unknown> = { query, count: options.count };
|
|
616
|
+
if (options.need_content) body.need_content = true;
|
|
617
|
+
if (options.ori_question) body.ori_question = options.ori_question;
|
|
618
|
+
if (options.localization) body.localization = options.localization;
|
|
619
|
+
|
|
620
|
+
const data = await postDeepSeek<{ results?: DeepSeekResult[] }>(
|
|
621
|
+
"/v1/tools/search",
|
|
622
|
+
body,
|
|
623
|
+
apiKey,
|
|
624
|
+
signal,
|
|
625
|
+
SEARCH_TIMEOUT_MS,
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
// Docs: billable when the request returns HTTP 200 *and* a non-empty results
|
|
629
|
+
// array — so cost follows the raw array, not what survives client-side filtering.
|
|
630
|
+
// A 200 without a results array is a broken response, not an empty result set.
|
|
631
|
+
if (!data || typeof data !== "object" || !Array.isArray((data as { results?: unknown }).results)) {
|
|
632
|
+
throw new Error("DeepSeek /v1/tools/search returned an invalid response: expected a results array");
|
|
633
|
+
}
|
|
634
|
+
const rawResults = (data as { results: DeepSeekResult[] }).results;
|
|
635
|
+
const results = rawResults.filter(
|
|
636
|
+
(entry) => typeof entry?.url === "string" && /^https?:\/\//i.test(entry.url),
|
|
637
|
+
);
|
|
638
|
+
const discarded = rawResults.length - results.length;
|
|
639
|
+
|
|
640
|
+
const header: string[] = [
|
|
641
|
+
`DeepSeek search · ${results.length} result${results.length === 1 ? "" : "s"}`,
|
|
642
|
+
];
|
|
643
|
+
header.push(`query: ${query}`);
|
|
644
|
+
if (options.ori_question) {
|
|
645
|
+
header.push(
|
|
646
|
+
`ori_question: ${options.ori_question}${oriQuestionTruncated ? ` (cut to ${MAX_ORI_QUESTION_CHARS} chars)` : ""}`,
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
if (dropped.length > 0)
|
|
650
|
+
header.push(`trimmed to fit ${MAX_QUERY_CHARS} chars: ${dropped.join("; ")}`);
|
|
651
|
+
if (discarded > 0)
|
|
652
|
+
header.push(`discarded ${discarded} unusable result${discarded === 1 ? "" : "s"} (no http(s) URL)`);
|
|
653
|
+
|
|
654
|
+
const text =
|
|
655
|
+
results.length === 0
|
|
656
|
+
? `${header.join("\n")}\n\nNo results. Try a broader query, drop filters, or use a multi-provider search tool.`
|
|
657
|
+
: `${header.join("\n")}\n\n${results
|
|
658
|
+
.map((result, index) => formatResult(result, index, options.need_content))
|
|
659
|
+
.join("\n\n")}`;
|
|
660
|
+
|
|
661
|
+
return {
|
|
662
|
+
content: [{ type: "text", text }],
|
|
663
|
+
details: {
|
|
664
|
+
query,
|
|
665
|
+
dropped,
|
|
666
|
+
count: results.length,
|
|
667
|
+
requestedCount,
|
|
668
|
+
discarded,
|
|
669
|
+
oriQuestionTruncated,
|
|
670
|
+
needContent: options.need_content,
|
|
671
|
+
estimatedCostUsd: rawResults.length > 0 ? PRICE_PER_SUCCESSFUL_REQUEST_USD : 0,
|
|
672
|
+
results: results.map((result) => ({
|
|
673
|
+
title: typeof result.title === "string" ? result.title : "",
|
|
674
|
+
url: result.url,
|
|
675
|
+
site: typeof result.site_name === "string" ? result.site_name : null,
|
|
676
|
+
siteIcon: typeof result.site_icon === "string" ? result.site_icon : null,
|
|
677
|
+
date: typeof result.date === "string" ? result.date : null,
|
|
678
|
+
authority:
|
|
679
|
+
typeof result.authority === "number" && Number.isFinite(result.authority)
|
|
680
|
+
? result.authority
|
|
681
|
+
: null,
|
|
682
|
+
score:
|
|
683
|
+
typeof result.rerank_score === "number" && Number.isFinite(result.rerank_score)
|
|
684
|
+
? result.rerank_score
|
|
685
|
+
: null,
|
|
686
|
+
})),
|
|
687
|
+
},
|
|
688
|
+
};
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
pi.registerTool({
|
|
693
|
+
name: "deepseek_fetch",
|
|
694
|
+
label: "DeepSeek Fetch",
|
|
695
|
+
description:
|
|
696
|
+
"Read one page through DeepSeek's Fetch API and get it back as Markdown (already extracted, rendered server-side). " +
|
|
697
|
+
"Use after deepseek_search when a summary is not enough, or for a specific URL you already know. " +
|
|
698
|
+
"Billed the same $0.00075 per successful fetch; failures and empty extractions are free.",
|
|
699
|
+
promptSnippet: "Fetch a URL as Markdown via DeepSeek (server-side rendering + extraction)",
|
|
700
|
+
parameters: FETCH_SCHEMA,
|
|
701
|
+
async execute(
|
|
702
|
+
_toolCallId: string,
|
|
703
|
+
params: any = {},
|
|
704
|
+
signal: AbortSignal | undefined,
|
|
705
|
+
_onUpdate: unknown,
|
|
706
|
+
ctx: any,
|
|
707
|
+
) {
|
|
708
|
+
const url = assertFetchableUrl(String(params.url ?? ""));
|
|
709
|
+
const requestedChars = Number(params.max_chars ?? MAX_FETCH_CONTENT_CHARS);
|
|
710
|
+
const requested = Number.isFinite(requestedChars) ? Math.floor(requestedChars) : MAX_FETCH_CONTENT_CHARS;
|
|
711
|
+
const maxChars = Math.min(200_000, Math.max(500, requested));
|
|
712
|
+
const clamped = maxChars !== requested;
|
|
713
|
+
const apiKey = await resolveApiKey(ctx);
|
|
714
|
+
|
|
715
|
+
const data = await postDeepSeek<{ title?: unknown; content?: unknown }>(
|
|
716
|
+
"/v1/tools/fetch",
|
|
717
|
+
{ url: url.toString() },
|
|
718
|
+
apiKey,
|
|
719
|
+
signal,
|
|
720
|
+
FETCH_TIMEOUT_MS,
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
const title = typeof data?.title === "string" ? data.title.trim() : "";
|
|
724
|
+
const content = typeof data?.content === "string" ? cleanMarkdown(data.content).trim() : "";
|
|
725
|
+
if (!content) {
|
|
726
|
+
throw new Error(`DeepSeek Fetch returned no usable content for ${url.toString()}`);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const total = charLength(content);
|
|
730
|
+
const body = total > maxChars ? Array.from(content).slice(0, maxChars).join("") : content;
|
|
731
|
+
const text = [
|
|
732
|
+
`DeepSeek fetch · ${total} chars${total > maxChars ? ` (truncated to ${maxChars})` : ""}${clamped ? ` (max_chars clamped to ${maxChars})` : ""}`,
|
|
733
|
+
title ? `title: ${title}` : "",
|
|
734
|
+
url.toString(),
|
|
735
|
+
"",
|
|
736
|
+
body,
|
|
737
|
+
]
|
|
738
|
+
.filter((line) => line !== "")
|
|
739
|
+
.join("\n");
|
|
740
|
+
|
|
741
|
+
return {
|
|
742
|
+
content: [{ type: "text", text }],
|
|
743
|
+
details: {
|
|
744
|
+
url: url.toString(),
|
|
745
|
+
title,
|
|
746
|
+
chars: total,
|
|
747
|
+
maxChars,
|
|
748
|
+
truncated: total > maxChars,
|
|
749
|
+
estimatedCostUsd: PRICE_PER_SUCCESSFUL_REQUEST_USD,
|
|
750
|
+
},
|
|
751
|
+
};
|
|
752
|
+
},
|
|
753
|
+
});
|
|
754
|
+
}
|