@nijaru/pi-search 0.0.4 → 0.0.5
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/README.md +36 -7
- package/package.json +4 -4
- package/src/contracts.ts +2 -0
- package/src/deepseek.ts +308 -0
- package/src/index.ts +9 -3
- package/src/provider-availability.ts +2 -0
- package/src/router.ts +65 -15
package/README.md
CHANGED
|
@@ -32,8 +32,12 @@ Pi tools:
|
|
|
32
32
|
automatically.
|
|
33
33
|
- Other/local models use configured Exa automatically only when metered search
|
|
34
34
|
is allowed; Exa returns semantic results with excerpts and reported usage.
|
|
35
|
+
Configured DeepSeek is the cheaper second metered path (structured dates and
|
|
36
|
+
excerpts through `site:`/`after:`/`before:` operators), selected after Exa
|
|
37
|
+
under metered routing.
|
|
35
38
|
- In free-only mode, configured Brave is the conservative paced keyword/fresh
|
|
36
|
-
path. Set `PI_SEARCH_PREFER_FREE=1` to prefer it before metered
|
|
39
|
+
path. Set `PI_SEARCH_PREFER_FREE=1` to prefer it before metered providers,
|
|
40
|
+
with one metered fallback after a safe Brave failure.
|
|
37
41
|
Parallel, Cloudflare Web Search, and the official X API remain explicit providers.
|
|
38
42
|
- Automatic routing permits at most one visible fallback after a safe
|
|
39
43
|
authentication, rate-limit, or unavailable failure. Network, timeout, and
|
|
@@ -55,11 +59,14 @@ registered only when their key is present at construction, an unset
|
|
|
55
59
|
variable simply leaves that provider disabled.
|
|
56
60
|
|
|
57
61
|
```bash
|
|
58
|
-
# Optional metered
|
|
62
|
+
# Optional metered search for non-native/local models
|
|
59
63
|
export EXA_API_KEY=...
|
|
60
|
-
#
|
|
64
|
+
# Cheaper second metered path; selected after Exa when metered routing is allowed.
|
|
65
|
+
export DEEPSEEK_API_KEY=... # $0.75 per 1K successful requests
|
|
66
|
+
# Allow metered providers (Exa, DeepSeek) in automatic routing:
|
|
61
67
|
# export PI_SEARCH_ALLOW_METERED=1
|
|
62
|
-
# Prefer admitted free-mode Brave before
|
|
68
|
+
# Prefer admitted free-mode Brave before metered providers, with one metered
|
|
69
|
+
# fallback after a safe Brave failure:
|
|
63
70
|
# export PI_SEARCH_PREFER_FREE=1
|
|
64
71
|
|
|
65
72
|
# Optional last-resort keyword/fresh search
|
|
@@ -70,7 +77,7 @@ export BRAVE_API_KEY=...
|
|
|
70
77
|
# metered Brave with the opt-in below.
|
|
71
78
|
export PI_SEARCH_BRAVE_FREE_ONLY=1
|
|
72
79
|
# Free mode does not inspect account billing or guarantee no paid overage.
|
|
73
|
-
# Explicit `provider: "exa"` remains an intentional per-call opt-in.
|
|
80
|
+
# Explicit `provider: "exa"` or `"deepseek"` remains an intentional per-call opt-in.
|
|
74
81
|
|
|
75
82
|
# Explicit secondary providers
|
|
76
83
|
export PARALLEL_API_KEY=...
|
|
@@ -99,9 +106,11 @@ model alias rather than full Flash/Pro or legacy model IDs. Explicit
|
|
|
99
106
|
compatible registry model with `executionModel` even when another model is
|
|
100
107
|
active; this makes model and billing choice visible. Pi's `/login xai` subscription flow is supported by the
|
|
101
108
|
registry for Grok web/X search. A provider hint such as `provider: "exa"`,
|
|
102
|
-
`"parallel"`, or `"x"` is strict and never falls through to
|
|
109
|
+
`"deepseek"`, `"parallel"`, or `"x"` is strict and never falls through to
|
|
110
|
+
another provider.
|
|
103
111
|
Without a hint, the router selects available native search first, then Exa,
|
|
104
|
-
then Brave when the corresponding credentials and hard
|
|
112
|
+
then DeepSeek, then Brave when the corresponding credentials and hard
|
|
113
|
+
constraints permit it.
|
|
105
114
|
Set `includeContent: true` on `web_search` only when bounded excerpts from
|
|
106
115
|
selected source pages are needed; this reuses the safe local fetch path. For
|
|
107
116
|
`web_research`, use `executionModel` with an explicit model-mediated `provider`
|
|
@@ -141,6 +150,26 @@ with saved/exportable Pi transcripts unless contractually permitted. Clarify
|
|
|
141
150
|
those rights before using Ceramic. Zero Data Retention describes provider
|
|
142
151
|
handling, not permission to retain results yourself.
|
|
143
152
|
|
|
153
|
+
### DeepSeek Search
|
|
154
|
+
|
|
155
|
+
DeepSeek Search is a direct metered provider (`provider: "deepseek"`). The
|
|
156
|
+
adapter composes domain and inclusive publication-date filters into DeepSeek's
|
|
157
|
+
documented `site:`, `-site:`, `after:`, and `before:` query operators, and
|
|
158
|
+
requests the model-oriented summary rather than full page content; readable
|
|
159
|
+
pages remain `web_fetch`'s job. DeepSeek caps queries at 128 characters
|
|
160
|
+
including those operators, so a long filtered query is rejected before
|
|
161
|
+
dispatch instead of being truncated. Requests that need social, media,
|
|
162
|
+
location, context-size, token-budget, or live-access controls are rejected
|
|
163
|
+
rather than degraded. Date operators have day granularity, so the provider
|
|
164
|
+
window is widened to whole days and a warning is reported; time-of-day bounds
|
|
165
|
+
are then enforced locally on results that report a date. Results with a known
|
|
166
|
+
publication date are filtered to the requested inclusive range; undated
|
|
167
|
+
results remain visible with an explicit warning.
|
|
168
|
+
|
|
169
|
+
The adapter reads `DEEPSEEK_API_KEY` from the environment, like the other
|
|
170
|
+
direct providers. A DeepSeek login stored only in Pi's model registry does not
|
|
171
|
+
register it.
|
|
172
|
+
|
|
144
173
|
### Local and virtual models
|
|
145
174
|
|
|
146
175
|
Research queries retain the 2,000-character runtime limit. The tool schema
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nijaru/pi-search",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "Provider-neutral web search and fetching tools for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,9 +41,9 @@
|
|
|
41
41
|
"live:smoke": "bun scripts/live-smoke.ts"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@earendil-works/pi-ai": "1.
|
|
45
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
46
|
-
"@earendil-works/pi-tui": "1.
|
|
44
|
+
"@earendil-works/pi-ai": "1.3.0",
|
|
45
|
+
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
46
|
+
"@earendil-works/pi-tui": "1.3.0",
|
|
47
47
|
"@types/bun": "1.3.14",
|
|
48
48
|
"@types/turndown": "^5.0.6",
|
|
49
49
|
"typebox": "1.3.10",
|
package/src/contracts.ts
CHANGED
|
@@ -566,6 +566,7 @@ export type ProviderId =
|
|
|
566
566
|
| "gemini"
|
|
567
567
|
| "brave"
|
|
568
568
|
| "exa"
|
|
569
|
+
| "deepseek"
|
|
569
570
|
| "cloudflare"
|
|
570
571
|
| "parallel"
|
|
571
572
|
| "parallel-responses"
|
|
@@ -584,6 +585,7 @@ export const SEARCH_PROVIDER_HINT_IDS = [
|
|
|
584
585
|
"gemini",
|
|
585
586
|
"brave",
|
|
586
587
|
"exa",
|
|
588
|
+
"deepseek",
|
|
587
589
|
"cloudflare",
|
|
588
590
|
"parallel",
|
|
589
591
|
"parallel-responses",
|
package/src/deepseek.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Provider,
|
|
3
|
+
ProviderCapabilities,
|
|
4
|
+
ProviderContext,
|
|
5
|
+
ProviderProfile,
|
|
6
|
+
ProviderRateLimitInfo,
|
|
7
|
+
SearchOption,
|
|
8
|
+
SearchRequest,
|
|
9
|
+
SearchResponse,
|
|
10
|
+
SearchResult,
|
|
11
|
+
SearchWarning,
|
|
12
|
+
} from "./contracts";
|
|
13
|
+
import { createProviderError } from "./errors";
|
|
14
|
+
import { httpSource, objectValue, optionalString, optionalTimestamp, postJson, requireApiKey, type SearchHttpFetch } from "./provider-http";
|
|
15
|
+
import { validateSearchRequest } from "./search";
|
|
16
|
+
|
|
17
|
+
export const DEEPSEEK_SEARCH_ENDPOINT = "https://api.deepseek.com/v1/tools/search";
|
|
18
|
+
export const DEFAULT_DEEPSEEK_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
19
|
+
/** Current published price for one successful Search API request. */
|
|
20
|
+
export const DEEPSEEK_ESTIMATED_SEARCH_COST_USD = 0.00075;
|
|
21
|
+
/** DeepSeek counts query characters, including any operator clauses. */
|
|
22
|
+
export const DEEPSEEK_MAX_QUERY_LENGTH = 128;
|
|
23
|
+
const DEEPSEEK_MAX_SCORE = 3;
|
|
24
|
+
|
|
25
|
+
export interface DeepSeekAdapterOptions {
|
|
26
|
+
readonly apiKey?: string;
|
|
27
|
+
readonly endpoint?: string;
|
|
28
|
+
readonly fetchImpl?: SearchHttpFetch;
|
|
29
|
+
readonly maxResponseBytes?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// DeepSeek's search index is a general web index with its own authority and
|
|
33
|
+
// freshness ranking. It exposes no dedicated social, media, context, or
|
|
34
|
+
// location controls, so those requests are rejected rather than degraded.
|
|
35
|
+
const capabilities: ProviderCapabilities = {
|
|
36
|
+
keyword: true,
|
|
37
|
+
freshness: true,
|
|
38
|
+
excerpts: true,
|
|
39
|
+
domainFilter: true,
|
|
40
|
+
dateFilter: true,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const profile: ProviderProfile = {
|
|
44
|
+
auth: "environment",
|
|
45
|
+
costModel: "per-request",
|
|
46
|
+
estimatedCostUsd: DEEPSEEK_ESTIMATED_SEARCH_COST_USD,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function unsupported(message: string): never {
|
|
50
|
+
throw createProviderError({ provider: "deepseek", kind: "unsupported", message, retryable: false });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function acceptRequestOptions(request: SearchRequest): void {
|
|
54
|
+
if (request.social !== undefined) unsupported("DeepSeek search does not provide dedicated social/X constraints");
|
|
55
|
+
if (request.executionModel !== undefined) unsupported("DeepSeek search is a direct provider and does not accept an execution model");
|
|
56
|
+
if (request.searchContextSize !== undefined) unsupported("DeepSeek search does not expose provider-native search context sizing");
|
|
57
|
+
if (request.returnTokenBudget !== undefined) unsupported("DeepSeek search does not expose a returned-token budget");
|
|
58
|
+
if (request.externalWebAccess !== undefined) unsupported("DeepSeek search does not expose live/cache-only selection");
|
|
59
|
+
if (request.userLocation !== undefined) unsupported("DeepSeek search does not expose a user location contract");
|
|
60
|
+
if (request.imageSettings !== undefined) unsupported("DeepSeek search does not expose image result settings");
|
|
61
|
+
if (request.searchContentTypes !== undefined) unsupported("DeepSeek search does not expose content-type selection");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The UTC calendar day for an ISO-8601 date or instant; operators are day-based. */
|
|
65
|
+
function utcDatePart(value: string): string {
|
|
66
|
+
return new Date(Date.parse(value)).toISOString().slice(0, 10);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Shift a YYYY-MM-DD date in UTC. */
|
|
70
|
+
function shiftDate(day: string, deltaDays: number): string {
|
|
71
|
+
const [year, month, date] = day.split("-").map(Number);
|
|
72
|
+
return new Date(Date.UTC(year!, month! - 1, date! + deltaDays)).toISOString().slice(0, 10);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* DeepSeek's `after:`/`before:` operators are date boundaries, while the
|
|
77
|
+
* request contract uses inclusive bounds. Widen each boundary by one day so
|
|
78
|
+
* the provider window cannot exclude the requested boundary days; the exact
|
|
79
|
+
* inclusive range is then enforced locally for results that report a date.
|
|
80
|
+
*/
|
|
81
|
+
function dateOperators(request: SearchRequest): { readonly operators: string[]; readonly widened: boolean } {
|
|
82
|
+
const operators: string[] = [];
|
|
83
|
+
let widened = false;
|
|
84
|
+
const from = request.dateRange?.from;
|
|
85
|
+
const to = request.dateRange?.to;
|
|
86
|
+
if (from !== undefined) {
|
|
87
|
+
if (from.includes("T")) widened = true;
|
|
88
|
+
operators.push(`after:${shiftDate(utcDatePart(from), -1)}`);
|
|
89
|
+
}
|
|
90
|
+
if (to !== undefined) {
|
|
91
|
+
if (to.includes("T")) widened = true;
|
|
92
|
+
operators.push(`before:${shiftDate(utcDatePart(to), 1)}`);
|
|
93
|
+
}
|
|
94
|
+
return { operators, widened };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function domainClauses(request: SearchRequest): string[] {
|
|
98
|
+
const include = request.domains?.include ?? [];
|
|
99
|
+
const exclude = request.domains?.exclude ?? [];
|
|
100
|
+
const clauses: string[] = [];
|
|
101
|
+
// DeepSeek documents multiple allowed sites as `site:a OR site:b` clauses
|
|
102
|
+
// joined by OR rather than a parenthesized group.
|
|
103
|
+
if (include.length > 0) clauses.push(include.map((domain) => `site:${domain}`).join(" OR "));
|
|
104
|
+
clauses.push(...exclude.map((domain) => `-site:${domain}`));
|
|
105
|
+
return clauses;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface DeepSeekRequestPlan {
|
|
109
|
+
readonly body: Record<string, unknown>;
|
|
110
|
+
readonly appliedOptions: readonly SearchOption[];
|
|
111
|
+
readonly warnings: readonly SearchWarning[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function buildDeepSeekRequest(request: SearchRequest): DeepSeekRequestPlan {
|
|
115
|
+
const normalized = validateSearchRequest(request);
|
|
116
|
+
acceptRequestOptions(normalized);
|
|
117
|
+
|
|
118
|
+
const appliedOptions: SearchOption[] = ["maxResults", "mode"];
|
|
119
|
+
const warnings: SearchWarning[] = [];
|
|
120
|
+
if (normalized.domains?.include !== undefined || normalized.domains?.exclude !== undefined) appliedOptions.push("domains");
|
|
121
|
+
if (normalized.dateRange !== undefined) appliedOptions.push("dateRange");
|
|
122
|
+
|
|
123
|
+
if (normalized.mode === "keyword") {
|
|
124
|
+
warnings.push({ code: "unsupported-option", option: "mode", message: "DeepSeek search reranks by its own relevance and authority signals; keyword-only ranking is not guaranteed" });
|
|
125
|
+
}
|
|
126
|
+
if (normalized.mode === "fresh" && normalized.dateRange?.from === undefined) {
|
|
127
|
+
warnings.push({ code: "unsupported-option", option: "mode", message: "DeepSeek search can return recent sources but this request does not impose a recent-date floor" });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const dates = dateOperators(normalized);
|
|
131
|
+
if (dates.widened) {
|
|
132
|
+
warnings.push({ code: "unsupported-option", option: "dateRange", message: "DeepSeek date operators have day granularity; time-of-day bounds are enforced locally on results that report a date" });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const query = [normalized.query, ...domainClauses(normalized), ...dates.operators].filter((part) => part.length > 0).join(" ");
|
|
136
|
+
// Count Unicode code points, matching DeepSeek's own limit rather than
|
|
137
|
+
// JavaScript's UTF-16 length.
|
|
138
|
+
if ([...query].length > DEEPSEEK_MAX_QUERY_LENGTH) {
|
|
139
|
+
throw createProviderError({
|
|
140
|
+
provider: "deepseek",
|
|
141
|
+
kind: "unsupported",
|
|
142
|
+
fallbackSafe: true,
|
|
143
|
+
message: `DeepSeek limits queries to ${DEEPSEEK_MAX_QUERY_LENGTH} characters including site/date operators; the composed query is ${[...query].length} characters`,
|
|
144
|
+
retryable: false,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
body: {
|
|
150
|
+
query,
|
|
151
|
+
count: normalized.maxResults ?? 10,
|
|
152
|
+
// DeepSeek returns a model-oriented summary regardless; full page
|
|
153
|
+
// content stays the job of the local web_fetch path.
|
|
154
|
+
need_content: false,
|
|
155
|
+
},
|
|
156
|
+
appliedOptions,
|
|
157
|
+
warnings,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function malformed(message: string): never {
|
|
162
|
+
throw createProviderError({ provider: "deepseek", kind: "malformed", message: `DeepSeek returned a malformed response (${message})`, retryable: false });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
166
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function scoreFromResult(value: unknown): number | undefined {
|
|
170
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
|
171
|
+
return Math.max(0, Math.min(1, value / DEEPSEEK_MAX_SCORE));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function excerptFromRecord(record: Record<string, unknown>): string | undefined {
|
|
175
|
+
// `summary` is the 600-800 character text DeepSeek intends models to read;
|
|
176
|
+
// `snippet` is the shorter display string.
|
|
177
|
+
return optionalString(record.summary, 4_000) ?? optionalString(record.snippet, 4_000);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Inclusive UTC day boundaries for date-only bounds; exact instants otherwise. */
|
|
181
|
+
function boundaryMs(value: string, endOfDay: boolean): number | undefined {
|
|
182
|
+
const milliseconds = Date.parse(value);
|
|
183
|
+
if (!Number.isFinite(milliseconds)) return undefined;
|
|
184
|
+
return endOfDay && !value.includes("T") ? milliseconds + 86_400_000 - 1 : milliseconds;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The date operators widen the provider window to protect inclusive boundary
|
|
189
|
+
* days, so enforce the exact inclusive range on results whose date is known.
|
|
190
|
+
* Results without a date cannot be checked and remain visible with a warning.
|
|
191
|
+
*/
|
|
192
|
+
function filterByDateRange(
|
|
193
|
+
results: readonly SearchResult[],
|
|
194
|
+
request: SearchRequest,
|
|
195
|
+
): { readonly results: readonly SearchResult[]; readonly removed: number; readonly unverified: number } {
|
|
196
|
+
const fromMs = request.dateRange?.from === undefined ? undefined : boundaryMs(request.dateRange.from, false);
|
|
197
|
+
const toMs = request.dateRange?.to === undefined ? undefined : boundaryMs(request.dateRange.to, true);
|
|
198
|
+
if (fromMs === undefined && toMs === undefined) return { results, removed: 0, unverified: 0 };
|
|
199
|
+
const kept: SearchResult[] = [];
|
|
200
|
+
let removed = 0;
|
|
201
|
+
let unverified = 0;
|
|
202
|
+
for (const result of results) {
|
|
203
|
+
const publishedMs = result.publishedAt === undefined ? Number.NaN : Date.parse(result.publishedAt);
|
|
204
|
+
if (!Number.isFinite(publishedMs)) {
|
|
205
|
+
unverified += 1;
|
|
206
|
+
kept.push(result);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if ((fromMs !== undefined && publishedMs < fromMs) || (toMs !== undefined && publishedMs > toMs)) {
|
|
210
|
+
removed += 1;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
kept.push(result);
|
|
214
|
+
}
|
|
215
|
+
return { results: kept, removed, unverified };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function normalizeDeepSeekResponse(
|
|
219
|
+
payload: unknown,
|
|
220
|
+
request: SearchRequest,
|
|
221
|
+
options: { readonly requestId?: string; readonly rateLimits?: ProviderRateLimitInfo } = {},
|
|
222
|
+
): SearchResponse {
|
|
223
|
+
const normalized = validateSearchRequest(request);
|
|
224
|
+
const root = objectValue(payload, "response", "deepseek");
|
|
225
|
+
if (!Array.isArray(root.results)) return malformed("results is not an array");
|
|
226
|
+
const results: SearchResult[] = [];
|
|
227
|
+
let discarded = 0;
|
|
228
|
+
for (const value of root.results) {
|
|
229
|
+
// A single malformed entry should not hide otherwise usable evidence.
|
|
230
|
+
if (!isRecord(value)) {
|
|
231
|
+
discarded += 1;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const parsed = httpSource(value.url, "deepseek");
|
|
235
|
+
if (parsed === undefined) {
|
|
236
|
+
discarded += 1;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
results.push({
|
|
240
|
+
url: parsed.url,
|
|
241
|
+
title: optionalString(value.title, 500),
|
|
242
|
+
domain: parsed.domain,
|
|
243
|
+
publishedAt: optionalTimestamp(value.date),
|
|
244
|
+
excerpt: excerptFromRecord(value),
|
|
245
|
+
provider: "deepseek",
|
|
246
|
+
searchQuery: normalized.query,
|
|
247
|
+
score: scoreFromResult(value.rerank_score),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
if (discarded > 0 && results.length === 0) return malformed("results contained no parseable HTTP URLs");
|
|
251
|
+
const dateFiltered = filterByDateRange(results, normalized);
|
|
252
|
+
const warnings: SearchWarning[] = [];
|
|
253
|
+
if (discarded > 0) {
|
|
254
|
+
warnings.push({ code: "partial-results", message: `DeepSeek discarded ${discarded} malformed result entr${discarded === 1 ? "y" : "ies"}` });
|
|
255
|
+
}
|
|
256
|
+
if (dateFiltered.removed > 0) {
|
|
257
|
+
warnings.push({ code: "partial-results", message: `DeepSeek removed ${dateFiltered.removed} result entr${dateFiltered.removed === 1 ? "y" : "ies"} outside the requested date range` });
|
|
258
|
+
}
|
|
259
|
+
if (dateFiltered.unverified > 0) {
|
|
260
|
+
warnings.push({ code: "partial-results", message: `DeepSeek could not verify the publication date of ${dateFiltered.unverified} result entr${dateFiltered.unverified === 1 ? "y" : "ies"} against the requested date range` });
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
query: normalized.query,
|
|
264
|
+
results: dateFiltered.results.slice(0, normalized.maxResults ?? 10),
|
|
265
|
+
provider: "deepseek",
|
|
266
|
+
appliedOptions: [],
|
|
267
|
+
warnings,
|
|
268
|
+
...(options.requestId === undefined ? {} : { requestId: options.requestId }),
|
|
269
|
+
...(options.rateLimits === undefined ? {} : { usage: { rateLimits: options.rateLimits } }),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export class DeepSeekProvider implements Provider {
|
|
274
|
+
readonly id = "deepseek" as const;
|
|
275
|
+
readonly capabilities = capabilities;
|
|
276
|
+
readonly profile = profile;
|
|
277
|
+
private readonly apiKey?: string;
|
|
278
|
+
private readonly endpoint: string;
|
|
279
|
+
private readonly fetchImpl: SearchHttpFetch;
|
|
280
|
+
private readonly maxResponseBytes: number;
|
|
281
|
+
|
|
282
|
+
constructor(options: DeepSeekAdapterOptions) {
|
|
283
|
+
this.apiKey = options.apiKey;
|
|
284
|
+
this.endpoint = options.endpoint ?? DEEPSEEK_SEARCH_ENDPOINT;
|
|
285
|
+
this.fetchImpl = options.fetchImpl ?? (fetch as SearchHttpFetch);
|
|
286
|
+
this.maxResponseBytes = options.maxResponseBytes ?? DEFAULT_DEEPSEEK_RESPONSE_BYTES;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async search(request: SearchRequest, signal: AbortSignal, _context: ProviderContext): Promise<SearchResponse> {
|
|
290
|
+
const normalized = validateSearchRequest(request);
|
|
291
|
+
const plan = buildDeepSeekRequest(normalized);
|
|
292
|
+
const result = await postJson({
|
|
293
|
+
provider: this.id,
|
|
294
|
+
url: this.endpoint,
|
|
295
|
+
headers: { authorization: `Bearer ${requireApiKey(this.id, this.apiKey)}` },
|
|
296
|
+
body: plan.body,
|
|
297
|
+
signal,
|
|
298
|
+
fetchImpl: this.fetchImpl,
|
|
299
|
+
maxResponseBytes: this.maxResponseBytes,
|
|
300
|
+
});
|
|
301
|
+
const response = normalizeDeepSeekResponse(result.payload, normalized, result);
|
|
302
|
+
return { ...response, appliedOptions: plan.appliedOptions, warnings: [...plan.warnings, ...response.warnings] };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function createDeepSeekProvider(options: DeepSeekAdapterOptions): DeepSeekProvider {
|
|
307
|
+
return new DeepSeekProvider(options);
|
|
308
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { createAnthropicProvider } from "./anthropic";
|
|
|
3
3
|
import { createBraveProvider, BraveQuotaTracker } from "./brave";
|
|
4
4
|
import { createBraveAnswersProvider } from "./brave-answers";
|
|
5
5
|
import { createCodexProvider } from "./codex";
|
|
6
|
+
import { createDeepSeekProvider } from "./deepseek";
|
|
6
7
|
import { createExaProvider } from "./exa";
|
|
7
8
|
import { createCloudflareProvider, type CloudflareUpstream } from "./cloudflare";
|
|
8
9
|
import { createGeminiProvider } from "./gemini";
|
|
@@ -20,9 +21,9 @@ import { registerWebSearch } from "./search-tool";
|
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Native grounding is selected for supported active models. Exa is the
|
|
23
|
-
* metered non-native default only when explicitly allowed
|
|
24
|
-
*
|
|
25
|
-
* visible fallback.
|
|
24
|
+
* metered non-native default only when explicitly allowed, DeepSeek is the
|
|
25
|
+
* cheaper second metered path, and Brave is the paced last-resort keyword
|
|
26
|
+
* path. Automatic availability failures may use one visible fallback.
|
|
26
27
|
*/
|
|
27
28
|
export default function (pi: ExtensionAPI): void {
|
|
28
29
|
const braveKey = process.env.BRAVE_API_KEY;
|
|
@@ -40,9 +41,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
40
41
|
const anthropic = createAnthropicProvider();
|
|
41
42
|
const meta = createMetaProvider();
|
|
42
43
|
const exaKey = process.env.EXA_API_KEY;
|
|
44
|
+
const deepseekKey = process.env.DEEPSEEK_API_KEY;
|
|
43
45
|
const parallelKey = process.env.PARALLEL_API_KEY;
|
|
44
46
|
const xToken = process.env.X_API_BEARER_TOKEN;
|
|
45
47
|
const exa = createExaProvider({ apiKey: exaKey });
|
|
48
|
+
const deepseek = createDeepSeekProvider({ apiKey: deepseekKey });
|
|
46
49
|
const cloudflareUpstream = process.env.PI_SEARCH_CLOUDFLARE_PROVIDER;
|
|
47
50
|
const cloudflareAccount = process.env.CLOUDFLARE_ACCOUNT_ID;
|
|
48
51
|
const cloudflareToken = process.env.CLOUDFLARE_API_TOKEN;
|
|
@@ -79,6 +82,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
79
82
|
anthropic,
|
|
80
83
|
meta,
|
|
81
84
|
exa,
|
|
85
|
+
deepseek,
|
|
82
86
|
cloudflare,
|
|
83
87
|
cloudflareConfigured,
|
|
84
88
|
parallel,
|
|
@@ -88,6 +92,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
88
92
|
brave,
|
|
89
93
|
braveConfigured: braveKey !== undefined && braveKey.trim().length > 0,
|
|
90
94
|
exaConfigured: exaKey !== undefined && exaKey.trim().length > 0,
|
|
95
|
+
deepseekConfigured: deepseekKey !== undefined && deepseekKey.trim().length > 0,
|
|
91
96
|
parallelConfigured: parallelKey !== undefined && parallelKey.trim().length > 0,
|
|
92
97
|
parallelResponsesConfigured: parallelResponsesEnabled,
|
|
93
98
|
braveAnswersConfigured: braveAnswersEnabled,
|
|
@@ -102,6 +107,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
102
107
|
brave: braveKey !== undefined && braveKey.trim().length > 0,
|
|
103
108
|
braveAnswers: braveAnswersEnabled,
|
|
104
109
|
exa: exaKey !== undefined && exaKey.trim().length > 0,
|
|
110
|
+
deepseek: deepseekKey !== undefined && deepseekKey.trim().length > 0,
|
|
105
111
|
cloudflare: cloudflareConfigured,
|
|
106
112
|
parallel: parallelKey !== undefined && parallelKey.trim().length > 0,
|
|
107
113
|
parallelResponses: parallelResponsesEnabled,
|
|
@@ -13,6 +13,7 @@ export interface ProviderHintAvailability {
|
|
|
13
13
|
readonly brave?: boolean;
|
|
14
14
|
readonly braveAnswers?: boolean;
|
|
15
15
|
readonly exa?: boolean;
|
|
16
|
+
readonly deepseek?: boolean;
|
|
16
17
|
readonly cloudflare?: boolean;
|
|
17
18
|
readonly parallel?: boolean;
|
|
18
19
|
readonly parallelResponses?: boolean;
|
|
@@ -30,6 +31,7 @@ export function availableProviderHints(availability: ProviderHintAvailability):
|
|
|
30
31
|
|| (id === "brave" && availability.brave === true)
|
|
31
32
|
|| (id === "brave-answers" && availability.braveAnswers === true)
|
|
32
33
|
|| (id === "exa" && availability.exa === true)
|
|
34
|
+
|| (id === "deepseek" && availability.deepseek === true)
|
|
33
35
|
|| (id === "cloudflare" && availability.cloudflare === true)
|
|
34
36
|
|| (id === "parallel" && availability.parallel === true)
|
|
35
37
|
|| (id === "parallel-responses" && availability.parallelResponses === true)
|
package/src/router.ts
CHANGED
|
@@ -17,6 +17,7 @@ export interface SearchRouterOptions {
|
|
|
17
17
|
readonly meta?: Provider;
|
|
18
18
|
readonly brave?: Provider;
|
|
19
19
|
readonly exa?: Provider;
|
|
20
|
+
readonly deepseek?: Provider;
|
|
20
21
|
readonly cloudflare?: Provider;
|
|
21
22
|
readonly parallel?: Provider;
|
|
22
23
|
readonly parallelResponses?: Provider;
|
|
@@ -25,6 +26,7 @@ export interface SearchRouterOptions {
|
|
|
25
26
|
/** Credential presence is supplied by the construction boundary. */
|
|
26
27
|
readonly braveConfigured?: boolean;
|
|
27
28
|
readonly exaConfigured?: boolean;
|
|
29
|
+
readonly deepseekConfigured?: boolean;
|
|
28
30
|
readonly cloudflareConfigured?: boolean;
|
|
29
31
|
readonly parallelConfigured?: boolean;
|
|
30
32
|
readonly parallelResponsesConfigured?: boolean;
|
|
@@ -129,16 +131,41 @@ function selection(provider: Provider, automatic: boolean, fallbacks: readonly P
|
|
|
129
131
|
return { provider, automatic, fallbacks: fallbacks.slice(0, 1) };
|
|
130
132
|
}
|
|
131
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Metered direct providers in selection order, gated by the billing policy.
|
|
136
|
+
* Exa comes first because it has current independent quality evidence;
|
|
137
|
+
* DeepSeek is the cheaper second candidate.
|
|
138
|
+
*/
|
|
139
|
+
function meteredCandidates(request: SearchRequest, options: SearchRouterOptions, policy: SearchBillingPolicy): readonly Provider[] {
|
|
140
|
+
if (policy === "free-only") return [];
|
|
141
|
+
const candidates: Provider[] = [];
|
|
142
|
+
if (options.exaConfigured === true && options.exa !== undefined && canServe(options.exa, request)) candidates.push(options.exa);
|
|
143
|
+
if (options.deepseekConfigured === true && options.deepseek !== undefined && canServe(options.deepseek, request)) candidates.push(options.deepseek);
|
|
144
|
+
return candidates;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A Brave path this request and policy permit, ignoring any quota window. */
|
|
148
|
+
function braveCandidate(request: SearchRequest, options: SearchRouterOptions, policy: SearchBillingPolicy): Provider | undefined {
|
|
149
|
+
const braveAllowed = policy === "allow-configured-metered" || options.braveFreeCapacityConfigured === true;
|
|
150
|
+
if (options.braveConfigured !== true || options.brave === undefined || !braveAllowed) return undefined;
|
|
151
|
+
return canServe(options.brave, request) ? options.brave : undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function directCandidates(request: SearchRequest, options: SearchRouterOptions, policy: SearchBillingPolicy): readonly Provider[] {
|
|
155
|
+
const brave = braveCandidate(request, options, policy);
|
|
156
|
+
return [
|
|
157
|
+
...meteredCandidates(request, options, policy),
|
|
158
|
+
...(brave !== undefined && options.braveCapacity?.canAttempt() !== false ? [brave] : []),
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
|
|
132
162
|
function directFallback(
|
|
133
163
|
primary: Provider,
|
|
134
164
|
request: SearchRequest,
|
|
135
165
|
options: SearchRouterOptions,
|
|
136
166
|
policy: SearchBillingPolicy,
|
|
137
167
|
): readonly Provider[] {
|
|
138
|
-
|
|
139
|
-
const braveAllowed = policy === "allow-configured-metered" || options.braveFreeCapacityConfigured === true;
|
|
140
|
-
if (primary.id !== "brave" && options.braveConfigured === true && options.brave !== undefined && braveAllowed && options.braveCapacity?.canAttempt() !== false && canServe(options.brave, request)) return [options.brave];
|
|
141
|
-
return [];
|
|
168
|
+
return directCandidates(request, options, policy).filter((provider) => provider.id !== primary.id).slice(0, 1);
|
|
142
169
|
}
|
|
143
170
|
|
|
144
171
|
function checkBraveCapacity(options: SearchRouterOptions): void {
|
|
@@ -191,6 +218,11 @@ function explicitProvider(
|
|
|
191
218
|
if (!canServe(options.exa, request)) return unavailable("Exa cannot satisfy the requested search constraints", provider);
|
|
192
219
|
return selection(options.exa, false);
|
|
193
220
|
}
|
|
221
|
+
if (provider === "deepseek") {
|
|
222
|
+
if (options.deepseek === undefined || options.deepseekConfigured !== true) return unavailable("DeepSeek search is not configured; set DEEPSEEK_API_KEY", provider);
|
|
223
|
+
if (!canServe(options.deepseek, request)) return unavailable("DeepSeek cannot satisfy the requested search constraints", provider);
|
|
224
|
+
return selection(options.deepseek, false);
|
|
225
|
+
}
|
|
194
226
|
if (provider === "cloudflare") {
|
|
195
227
|
if (options.cloudflare === undefined || options.cloudflareConfigured !== true) return unavailable("Cloudflare search is not configured; set its account ID, API token, and explicit upstream", provider);
|
|
196
228
|
if (!canServe(options.cloudflare, request)) return unavailable("Cloudflare cannot satisfy the requested search constraints", provider);
|
|
@@ -277,29 +309,47 @@ export function createSearchRouter(options: SearchRouterOptions): SearchProvider
|
|
|
277
309
|
}
|
|
278
310
|
|
|
279
311
|
const exaConfigured = options.exaConfigured === true;
|
|
280
|
-
const
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
const braveCanMatchMode = options.brave !== undefined && canServe(options.brave, normalized);
|
|
312
|
+
const meteredAllowed = policy !== "free-only";
|
|
313
|
+
const deepseekConfigured = options.deepseekConfigured === true;
|
|
284
314
|
const braveConfigured = options.braveConfigured === true;
|
|
285
315
|
const braveAllowed = policy === "allow-configured-metered" || options.braveFreeCapacityConfigured === true;
|
|
286
|
-
|
|
287
|
-
|
|
316
|
+
const brave = braveCandidate(normalized, options, policy);
|
|
317
|
+
// prefer-free chooses the admitted Brave path before any metered call, but
|
|
318
|
+
// a safe Brave failure may still use the same single metered fallback.
|
|
319
|
+
if (policy === "prefer-free" && brave !== undefined && options.braveCapacity?.canAttempt() !== false) {
|
|
288
320
|
checkBraveCapacity(options);
|
|
289
|
-
return selection(
|
|
321
|
+
return selection(brave, true, meteredCandidates(normalized, options, policy).slice(0, 1));
|
|
322
|
+
}
|
|
323
|
+
const metered = meteredCandidates(normalized, options, policy);
|
|
324
|
+
if (metered[0] !== undefined) {
|
|
325
|
+
const fallback = metered[1] ?? (brave !== undefined && options.braveCapacity?.canAttempt() !== false ? brave : undefined);
|
|
326
|
+
return selection(metered[0], true, fallback === undefined ? [] : [fallback]);
|
|
290
327
|
}
|
|
291
|
-
if (
|
|
292
|
-
if (braveCanMatchMode && braveConfigured && braveAllowed) {
|
|
328
|
+
if (brave !== undefined) {
|
|
293
329
|
checkBraveCapacity(options);
|
|
294
|
-
return selection(
|
|
330
|
+
return selection(brave, true);
|
|
295
331
|
}
|
|
296
332
|
// No direct provider was eligible; name the hard constraints the
|
|
297
333
|
// configured providers could not honor instead of blaming the retrieval
|
|
298
334
|
// mode, which never blocks selection on its own.
|
|
299
|
-
if (
|
|
335
|
+
if (meteredAllowed && exaConfigured && options.exa !== undefined) {
|
|
300
336
|
const unmet = unmetConstraintNames(normalized);
|
|
301
337
|
return unavailable(`Exa cannot satisfy the requested ${unmet.join(", ") || "search constraints"}`, "exa");
|
|
302
338
|
}
|
|
339
|
+
if (meteredAllowed && deepseekConfigured && options.deepseek !== undefined) {
|
|
340
|
+
const unmet = unmetConstraintNames(normalized);
|
|
341
|
+
return unavailable(`DeepSeek cannot satisfy the requested ${unmet.join(", ") || "search constraints"}`, "deepseek");
|
|
342
|
+
}
|
|
343
|
+
if (!meteredAllowed) {
|
|
344
|
+
const eligible = [
|
|
345
|
+
exaConfigured && options.exa !== undefined && canServe(options.exa, normalized) ? "Exa" : undefined,
|
|
346
|
+
deepseekConfigured && options.deepseek !== undefined && canServe(options.deepseek, normalized) ? "DeepSeek" : undefined,
|
|
347
|
+
].filter((name): name is string => name !== undefined);
|
|
348
|
+
if (eligible.length > 0) {
|
|
349
|
+
const names = eligible.join(" and ");
|
|
350
|
+
return unavailable(`${names} ${eligible.length === 1 ? "is" : "are"} configured and could serve this request, but metered automatic routing is disabled; set PI_SEARCH_ALLOW_METERED=1 or use an explicit provider hint`, "router");
|
|
351
|
+
}
|
|
352
|
+
}
|
|
303
353
|
if (braveConfigured && !braveAllowed) return unavailable("Brave is configured but free-mode admission is disabled; set PI_SEARCH_BRAVE_FREE_ONLY=1 or PI_SEARCH_ALLOW_METERED=1", "router");
|
|
304
354
|
if (braveConfigured && options.brave !== undefined) {
|
|
305
355
|
const unmet = unmetConstraintNames(normalized);
|