@nijaru/pi-search 0.0.3 → 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 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 Exa.
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 semantic search for non-native/local models
62
+ # Optional metered search for non-native/local models
59
63
  export EXA_API_KEY=...
60
- # Allow Exa (and deliberate metered fallback) in automatic routing:
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 Exa, but allow Exa if Brave is absent:
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 another provider.
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 constraints permit it.
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",
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.2.0",
45
- "@earendil-works/pi-coding-agent": "1.2.0",
46
- "@earendil-works/pi-tui": "1.2.0",
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/brave.ts CHANGED
@@ -25,7 +25,7 @@ export type BraveFetch = (input: string | URL | Request, init?: RequestInit) =>
25
25
  export interface BraveCapacityTracker {
26
26
  /** True when no known finite quota window currently blocks a call. */
27
27
  readonly canAttempt: () => boolean;
28
- /** Reserve a local request slot, returning false when the caller canceled. */
28
+ /** Wait for local admission, returning false when the caller canceled. */
29
29
  readonly waitForTurn?: (signal: AbortSignal) => Promise<boolean>;
30
30
  /** Record provider observations without inventing or persisting counters. */
31
31
  readonly observe: (info: ProviderRateLimitInfo | undefined) => void;
@@ -48,6 +48,8 @@ export class BraveQuotaTracker implements BraveCapacityTracker {
48
48
  private observedAtMs = 0;
49
49
  private readonly minimumIntervalMs: number;
50
50
  private nextRequestAtMs = 0;
51
+ private readonly waiting = new Set<{ signal: AbortSignal; finish: (admitted: boolean) => void }>();
52
+ private admissionTimer: ReturnType<typeof setTimeout> | undefined;
51
53
 
52
54
  constructor(options: BraveQuotaTrackerOptions = {}) {
53
55
  const minimumIntervalMs = options.minimumIntervalMs ?? 0;
@@ -57,30 +59,48 @@ export class BraveQuotaTracker implements BraveCapacityTracker {
57
59
  this.minimumIntervalMs = minimumIntervalMs;
58
60
  }
59
61
 
60
- async waitForTurn(signal: AbortSignal): Promise<boolean> {
61
- if (signal.aborted) return false;
62
- const now = Date.now();
63
- const startAt = Math.max(now, this.nextRequestAtMs);
64
- this.nextRequestAtMs = startAt + this.minimumIntervalMs;
65
- const delayMs = startAt - now;
66
- if (delayMs <= 0) return true;
62
+ waitForTurn(signal: AbortSignal): Promise<boolean> {
63
+ if (signal.aborted) return Promise.resolve(false);
64
+ if (this.minimumIntervalMs === 0) return Promise.resolve(true);
67
65
  return new Promise<boolean>((resolve) => {
68
- let settled = false;
69
- let timer: ReturnType<typeof setTimeout> | undefined;
70
- const finish = (admitted: boolean): void => {
71
- if (settled) return;
72
- settled = true;
73
- if (timer !== undefined) clearTimeout(timer);
74
- signal.removeEventListener("abort", onAbort);
75
- resolve(admitted);
66
+ const waiter = {
67
+ signal,
68
+ finish: (admitted: boolean): void => {
69
+ if (!this.waiting.delete(waiter)) return;
70
+ signal.removeEventListener("abort", onAbort);
71
+ if (this.waiting.size === 0 && this.admissionTimer !== undefined) {
72
+ clearTimeout(this.admissionTimer);
73
+ this.admissionTimer = undefined;
74
+ }
75
+ resolve(admitted);
76
+ },
76
77
  };
77
- const onAbort = (): void => finish(false);
78
- timer = setTimeout(() => finish(true), delayMs);
78
+ const onAbort = (): void => { waiter.finish(false); };
79
+ this.waiting.add(waiter);
79
80
  signal.addEventListener("abort", onAbort, { once: true });
80
81
  if (signal.aborted) onAbort();
82
+ this.admitNext();
81
83
  });
82
84
  }
83
85
 
86
+ private admitNext(): void {
87
+ if (this.admissionTimer !== undefined) return;
88
+ for (const waiter of this.waiting) {
89
+ if (waiter.signal.aborted) { waiter.finish(false); continue; }
90
+ const now = Date.now(), delayMs = this.nextRequestAtMs - now;
91
+ if (delayMs > 0) {
92
+ this.admissionTimer = setTimeout(() => {
93
+ this.admissionTimer = undefined;
94
+ this.admitNext();
95
+ }, delayMs);
96
+ return;
97
+ }
98
+ // Only actual admissions consume spacing. Late timers cannot release a burst.
99
+ this.nextRequestAtMs = now + this.minimumIntervalMs;
100
+ waiter.finish(true);
101
+ }
102
+ }
103
+
84
104
  canAttempt(): boolean {
85
105
  const info = this.snapshot();
86
106
  if (info === undefined) return true;
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",
@@ -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; Brave is the paced
24
- * last-resort keyword path. Automatic availability failures may use one
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
- if (policy !== "free-only" && primary.id !== "exa" && options.exaConfigured === true && options.exa !== undefined && canServe(options.exa, request)) return [options.exa];
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 exaAllowed = policy !== "free-only";
281
- const exaCanMatchMode = exaAllowed && options.exa !== undefined && canServe(options.exa, normalized);
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
- // prefer-free chooses the admitted Brave path before a metered Exa call.
287
- if (policy === "prefer-free" && braveCanMatchMode && braveConfigured && braveAllowed && options.braveCapacity?.canAttempt() !== false) {
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(options.brave!, true);
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 (exaConfigured && exaCanMatchMode) return selection(options.exa!, true, directFallback(options.exa!, normalized, options, policy));
292
- if (braveCanMatchMode && braveConfigured && braveAllowed) {
328
+ if (brave !== undefined) {
293
329
  checkBraveCapacity(options);
294
- return selection(options.brave!, true);
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 (exaAllowed && exaConfigured && options.exa !== undefined) {
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);