@nijaru/pi-search 0.0.1
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/LICENSE +21 -0
- package/README.md +165 -0
- package/THIRD_PARTY_NOTICES.md +31 -0
- package/package.json +69 -0
- package/src/anthropic.ts +350 -0
- package/src/anydoc-worker.mjs +35 -0
- package/src/anydoc.ts +161 -0
- package/src/brave-answers.ts +396 -0
- package/src/brave.ts +441 -0
- package/src/codex.ts +314 -0
- package/src/contracts.ts +644 -0
- package/src/direct-transport.ts +141 -0
- package/src/errors.ts +185 -0
- package/src/exa.ts +204 -0
- package/src/fetch-errors.ts +134 -0
- package/src/fetch-extractor-worker.mjs +85 -0
- package/src/fetch-tool.ts +166 -0
- package/src/fetcher.ts +451 -0
- package/src/fixtures/sample.csv +2 -0
- package/src/fixtures/sample.docx +0 -0
- package/src/fixtures/sample.epub +0 -0
- package/src/fixtures/sample.odt +0 -0
- package/src/fixtures/sample.pdf +32 -0
- package/src/fixtures/sample.pptx +0 -0
- package/src/fixtures/sample.rtf +1 -0
- package/src/fixtures/sample.xlsx +0 -0
- package/src/gemini.ts +298 -0
- package/src/grounding.ts +167 -0
- package/src/http.ts +78 -0
- package/src/index.ts +101 -0
- package/src/local-llama-compat.ts +134 -0
- package/src/meta.ts +137 -0
- package/src/model-selection.ts +134 -0
- package/src/openai.ts +850 -0
- package/src/parallel-responses.ts +260 -0
- package/src/parallel.ts +173 -0
- package/src/pdf.ts +153 -0
- package/src/provider-availability.ts +35 -0
- package/src/provider-http.ts +203 -0
- package/src/render-text.ts +8 -0
- package/src/research-tool.ts +487 -0
- package/src/router.ts +302 -0
- package/src/search-cleanup.ts +219 -0
- package/src/search-tool.ts +551 -0
- package/src/search.ts +420 -0
- package/src/ssrf.ts +302 -0
- package/src/url-rendering.ts +21 -0
- package/src/x.ts +188 -0
- package/src/xai.ts +254 -0
- package/src/youtube.ts +224 -0
package/src/router.ts
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Provider, ProviderId, SearchProviderSelection, SearchRequest } from "./contracts";
|
|
3
|
+
import { createProviderError } from "./errors";
|
|
4
|
+
import type { BraveCapacityTracker } from "./brave";
|
|
5
|
+
import { validateSearchRequest } from "./search";
|
|
6
|
+
|
|
7
|
+
/** Billing choices are an installation policy, not provider fallbacks. */
|
|
8
|
+
export type SearchBillingPolicy = "free-only" | "prefer-free" | "allow-configured-metered";
|
|
9
|
+
|
|
10
|
+
export interface SearchRouterOptions {
|
|
11
|
+
readonly openai?: Provider;
|
|
12
|
+
readonly openaiCodex?: Provider;
|
|
13
|
+
readonly gemini?: Provider;
|
|
14
|
+
readonly xai?: Provider;
|
|
15
|
+
readonly xaiX?: Provider;
|
|
16
|
+
readonly anthropic?: Provider;
|
|
17
|
+
readonly meta?: Provider;
|
|
18
|
+
readonly brave?: Provider;
|
|
19
|
+
readonly exa?: Provider;
|
|
20
|
+
readonly parallel?: Provider;
|
|
21
|
+
readonly parallelResponses?: Provider;
|
|
22
|
+
readonly braveAnswers?: Provider;
|
|
23
|
+
readonly x?: Provider;
|
|
24
|
+
/** Credential presence is supplied by the construction boundary. */
|
|
25
|
+
readonly braveConfigured?: boolean;
|
|
26
|
+
readonly exaConfigured?: boolean;
|
|
27
|
+
readonly parallelConfigured?: boolean;
|
|
28
|
+
readonly parallelResponsesConfigured?: boolean;
|
|
29
|
+
readonly braveAnswersConfigured?: boolean;
|
|
30
|
+
readonly xConfigured?: boolean;
|
|
31
|
+
/** Explicit user assertion that Brave calls are covered by free capacity. */
|
|
32
|
+
readonly braveFreeCapacityConfigured?: boolean;
|
|
33
|
+
readonly braveCapacity?: BraveCapacityTracker;
|
|
34
|
+
/** Defaults to free-only to prevent an unexpected metered request. */
|
|
35
|
+
readonly billingPolicy?: SearchBillingPolicy;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type SearchProviderResolver = (request: SearchRequest, context: ExtensionContext) => SearchProviderSelection;
|
|
39
|
+
|
|
40
|
+
function unavailable(message: string, provider: ProviderId = "router"): never {
|
|
41
|
+
throw createProviderError({ provider, kind: "unavailable", message, retryable: false });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function canServe(provider: Provider, request: SearchRequest): boolean {
|
|
45
|
+
// Search modes are retrieval hints. Providers that cannot guarantee a hint
|
|
46
|
+
// must report that limitation in their response; hard constraints and
|
|
47
|
+
// explicit model-mediated options must not be silently dropped.
|
|
48
|
+
if ((request.domains?.include?.length || request.domains?.exclude?.length) && provider.capabilities.domainFilter !== true) return false;
|
|
49
|
+
if (request.executionModel !== undefined && provider.profile.auth !== "modelRegistry") return false;
|
|
50
|
+
if (request.dateRange !== undefined && provider.capabilities.dateFilter !== true) return false;
|
|
51
|
+
if (request.social !== undefined) {
|
|
52
|
+
if (provider.capabilities.social !== true) return false;
|
|
53
|
+
if ((request.social.includeHandles !== undefined || request.social.excludeHandles !== undefined) && provider.capabilities.handleFilter !== true) return false;
|
|
54
|
+
if ((request.social.understandImages === true || request.social.understandVideos === true) && provider.capabilities.mediaUnderstanding !== true) return false;
|
|
55
|
+
}
|
|
56
|
+
if (request.searchContextSize !== undefined && provider.capabilities.searchContextSize !== true) return false;
|
|
57
|
+
if (request.returnTokenBudget !== undefined && provider.capabilities.returnTokenBudget !== true) return false;
|
|
58
|
+
if (request.externalWebAccess !== undefined && provider.capabilities.externalWebAccess !== true) return false;
|
|
59
|
+
if (request.userLocation !== undefined && provider.capabilities.userLocation !== true) return false;
|
|
60
|
+
if (request.searchContentTypes !== undefined && provider.capabilities.searchContentTypes !== true) return false;
|
|
61
|
+
if (request.imageSettings !== undefined && provider.capabilities.imageSettings !== true) return false;
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Table-driven native registry: adding a model-mediated adapter is one row
|
|
67
|
+
* here plus the option field above. The Pi model-registry `provider`/`api`
|
|
68
|
+
* pair is the join key adapters also use in `selectModelExecution`.
|
|
69
|
+
*/
|
|
70
|
+
interface NativeRegistryEntry {
|
|
71
|
+
readonly modelProvider: string;
|
|
72
|
+
readonly api: string;
|
|
73
|
+
readonly option: "openai" | "openaiCodex" | "gemini" | "xai" | "xaiX" | "anthropic" | "meta";
|
|
74
|
+
readonly label: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const NATIVE_REGISTRY: Readonly<Record<string, NativeRegistryEntry>> = {
|
|
78
|
+
openai: { modelProvider: "openai", api: "openai-responses", option: "openai", label: "OpenAI" },
|
|
79
|
+
"openai-codex": { modelProvider: "openai-codex", api: "openai-codex-responses", option: "openaiCodex", label: "Codex" },
|
|
80
|
+
gemini: { modelProvider: "google", api: "google-generative-ai", option: "gemini", label: "Gemini" },
|
|
81
|
+
xai: { modelProvider: "xai", api: "openai-responses", option: "xai", label: "xAI web" },
|
|
82
|
+
"xai-x": { modelProvider: "xai", api: "openai-responses", option: "xaiX", label: "xAI X" },
|
|
83
|
+
anthropic: { modelProvider: "anthropic", api: "anthropic-messages", option: "anthropic", label: "Anthropic" },
|
|
84
|
+
meta: { modelProvider: "meta", api: "openai-responses", option: "meta", label: "Meta" },
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function nativeEntry(provider: ProviderId): NativeRegistryEntry | undefined {
|
|
88
|
+
return NATIVE_REGISTRY[provider];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function nativeModelCompatible(provider: ProviderId, model: ExtensionContext["model"] | undefined): boolean {
|
|
92
|
+
if (model === undefined) return false;
|
|
93
|
+
const entry = nativeEntry(provider);
|
|
94
|
+
if (entry === undefined) return false;
|
|
95
|
+
return model.provider === entry.modelProvider && model.api === entry.api;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function availableNativeModel(provider: ProviderId, context: ExtensionContext, requestedModel?: string): boolean {
|
|
99
|
+
const compatible = (model: NonNullable<ExtensionContext["model"]>): boolean => nativeModelCompatible(provider, model);
|
|
100
|
+
if (requestedModel !== undefined && context.model !== undefined && compatible(context.model) && context.model.id === requestedModel) return true;
|
|
101
|
+
if (requestedModel === undefined && nativeModelCompatible(provider, context.model)) return true;
|
|
102
|
+
try {
|
|
103
|
+
return context.modelRegistry.getAvailable().some((model) => compatible(model) && (requestedModel === undefined || model.id === requestedModel));
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Active-model dispatch order: the active model's own grounding first. */
|
|
110
|
+
const ACTIVE_NATIVE_IDS = ["openai-codex", "openai", "gemini", "xai", "anthropic", "meta"] as const;
|
|
111
|
+
|
|
112
|
+
/** Registry fallback order: preserve Pi's built-in search for other active models. */
|
|
113
|
+
const REGISTRY_NATIVE_IDS = ["openai-codex", "openai"] as const;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve the provider for an active-model native id. xAI is the one native
|
|
117
|
+
* family whose choice depends on the request: X-specific constraints select
|
|
118
|
+
* the X grounding tool over web search.
|
|
119
|
+
*/
|
|
120
|
+
function activeNativeProvider(id: (typeof ACTIVE_NATIVE_IDS)[number], request: SearchRequest, options: SearchRouterOptions): Provider | undefined {
|
|
121
|
+
if (id === "xai" && (request.social !== undefined || request.dateRange !== undefined)) return options.xaiX;
|
|
122
|
+
return options[NATIVE_REGISTRY[id]!.option];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function selection(provider: Provider, automatic: boolean, fallbacks: readonly Provider[] = []): SearchProviderSelection {
|
|
126
|
+
return { provider, automatic, fallbacks: fallbacks.slice(0, 1) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function directFallback(
|
|
130
|
+
primary: Provider,
|
|
131
|
+
request: SearchRequest,
|
|
132
|
+
options: SearchRouterOptions,
|
|
133
|
+
policy: SearchBillingPolicy,
|
|
134
|
+
): readonly Provider[] {
|
|
135
|
+
if (policy !== "free-only" && primary.id !== "exa" && options.exaConfigured === true && options.exa !== undefined && canServe(options.exa, request)) return [options.exa];
|
|
136
|
+
const braveAllowed = policy === "allow-configured-metered" || options.braveFreeCapacityConfigured === true;
|
|
137
|
+
if (primary.id !== "brave" && options.braveConfigured === true && options.brave !== undefined && braveAllowed && canServe(options.brave, request)) return [options.brave];
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function checkBraveCapacity(options: SearchRouterOptions): void {
|
|
142
|
+
if (options.braveCapacity !== undefined && !options.braveCapacity.canAttempt()) {
|
|
143
|
+
const info = options.braveCapacity.snapshot();
|
|
144
|
+
throw createProviderError({ provider: "brave", kind: "rateLimit", message: "Brave quota window is exhausted", retryable: true, rateLimits: info, retryAfterMs: info?.retryAfterMs });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function explicitProvider(
|
|
149
|
+
request: SearchRequest,
|
|
150
|
+
context: ExtensionContext,
|
|
151
|
+
options: SearchRouterOptions,
|
|
152
|
+
policy: SearchBillingPolicy,
|
|
153
|
+
): SearchProviderSelection {
|
|
154
|
+
const provider = request.providerHint;
|
|
155
|
+
if (provider === undefined) return unavailable("No provider was selected");
|
|
156
|
+
if (request.executionModel !== undefined && provider === "native") return unavailable("executionModel requires an explicit model-mediated provider, not the native alias", provider);
|
|
157
|
+
if (provider === "native") {
|
|
158
|
+
// Web-first native ids in registry order; xAI resolves last because the
|
|
159
|
+
// active model must choose between its web and X grounding tools.
|
|
160
|
+
for (const id of ["openai", "openai-codex", "gemini", "anthropic", "meta"] as const) {
|
|
161
|
+
const candidate = options[NATIVE_REGISTRY[id]!.option];
|
|
162
|
+
if (candidate !== undefined && nativeModelCompatible(id, context.model) && canServe(candidate, request)) return selection(candidate, false);
|
|
163
|
+
}
|
|
164
|
+
if (nativeModelCompatible("xai", context.model)) {
|
|
165
|
+
const selected = request.social !== undefined || request.dateRange !== undefined ? options.xaiX : options.xai;
|
|
166
|
+
if (selected !== undefined && canServe(selected, request)) return selection(selected, false);
|
|
167
|
+
}
|
|
168
|
+
return unavailable("Native search requires an active supported grounded model that can satisfy the requested constraints", provider);
|
|
169
|
+
}
|
|
170
|
+
const native = nativeEntry(provider);
|
|
171
|
+
if (native !== undefined) {
|
|
172
|
+
const selected = options[native.option];
|
|
173
|
+
if (selected === undefined) return unavailable(`${native.label} search is not registered`, provider);
|
|
174
|
+
if (!nativeModelCompatible(provider, context.model) && request.executionModel === undefined) return unavailable(`Cross-provider ${native.label} search requires an explicit executionModel`, provider);
|
|
175
|
+
if (!availableNativeModel(provider, context, request.executionModel)) {
|
|
176
|
+
return unavailable(
|
|
177
|
+
request.executionModel === undefined
|
|
178
|
+
? `${native.label} native search requires an available model`
|
|
179
|
+
: `Requested ${native.label} executionModel is not available`,
|
|
180
|
+
provider,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (!canServe(selected, request)) return unavailable(`${native.label} search cannot satisfy the requested search constraints`, provider);
|
|
184
|
+
return selection(selected, false);
|
|
185
|
+
}
|
|
186
|
+
if (provider === "exa") {
|
|
187
|
+
if (options.exa === undefined || options.exaConfigured !== true) return unavailable("Exa search is not configured", provider);
|
|
188
|
+
if (!canServe(options.exa, request)) return unavailable("Exa cannot satisfy the requested search constraints", provider);
|
|
189
|
+
return selection(options.exa, false);
|
|
190
|
+
}
|
|
191
|
+
if (provider === "parallel") {
|
|
192
|
+
if (options.parallel === undefined || options.parallelConfigured !== true) return unavailable("Parallel search is not configured", provider);
|
|
193
|
+
if (!canServe(options.parallel, request)) return unavailable("Parallel cannot satisfy the requested search constraints", provider);
|
|
194
|
+
return selection(options.parallel, false);
|
|
195
|
+
}
|
|
196
|
+
// Opt-in synthesis providers: explicit hint only, never automatic, never
|
|
197
|
+
// the native alias. Registered only behind their enable gates.
|
|
198
|
+
if (provider === "parallel-responses") {
|
|
199
|
+
if (options.parallelResponses === undefined || options.parallelResponsesConfigured !== true) return unavailable("Parallel Responses is not enabled; set PI_SEARCH_ENABLE_PARALLEL_RESPONSES=1", provider);
|
|
200
|
+
if (!canServe(options.parallelResponses, request)) return unavailable("Parallel Responses cannot satisfy the requested search constraints", provider);
|
|
201
|
+
return selection(options.parallelResponses, false);
|
|
202
|
+
}
|
|
203
|
+
if (provider === "brave-answers") {
|
|
204
|
+
if (options.braveAnswers === undefined || options.braveAnswersConfigured !== true) return unavailable("Brave Answers is not enabled; set PI_SEARCH_ENABLE_BRAVE_ANSWERS=1", provider);
|
|
205
|
+
if (!canServe(options.braveAnswers, request)) return unavailable("Brave Answers cannot satisfy the requested search constraints", provider);
|
|
206
|
+
return selection(options.braveAnswers, false);
|
|
207
|
+
}
|
|
208
|
+
if (provider === "x") {
|
|
209
|
+
if (options.x === undefined || options.xConfigured !== true) return unavailable("X API search is not configured", provider);
|
|
210
|
+
if (!canServe(options.x, request)) return unavailable("X API search cannot satisfy the requested search constraints", provider);
|
|
211
|
+
return selection(options.x, false);
|
|
212
|
+
}
|
|
213
|
+
if (provider === "brave") {
|
|
214
|
+
if (options.brave === undefined || options.braveConfigured !== true) return unavailable("Brave search is not configured", provider);
|
|
215
|
+
if (policy !== "allow-configured-metered" && options.braveFreeCapacityConfigured !== true) return unavailable("Brave free-mode admission is disabled; set PI_SEARCH_BRAVE_FREE_ONLY=1 or PI_SEARCH_ALLOW_METERED=1", provider);
|
|
216
|
+
checkBraveCapacity(options);
|
|
217
|
+
if (!canServe(options.brave, request)) return unavailable("Brave cannot satisfy the requested search constraints", provider);
|
|
218
|
+
return selection(options.brave, false);
|
|
219
|
+
}
|
|
220
|
+
return unavailable(`Search provider ${provider} is not configured`, provider);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Human-readable names of the hard constraints a request carries. */
|
|
224
|
+
function unmetConstraintNames(request: SearchRequest): readonly string[] {
|
|
225
|
+
const names: string[] = [];
|
|
226
|
+
if (request.domains?.include?.length || request.domains?.exclude?.length) names.push("domain filters");
|
|
227
|
+
if (request.dateRange !== undefined) names.push("a date range");
|
|
228
|
+
if (request.social !== undefined) names.push("social/X constraints");
|
|
229
|
+
if (request.searchContextSize !== undefined) names.push("search context size");
|
|
230
|
+
if (request.returnTokenBudget !== undefined) names.push("a returned-token budget");
|
|
231
|
+
if (request.externalWebAccess !== undefined) names.push("live-access selection");
|
|
232
|
+
if (request.userLocation !== undefined) names.push("a user location");
|
|
233
|
+
if (request.searchContentTypes !== undefined) names.push("content-type selection");
|
|
234
|
+
if (request.imageSettings !== undefined) names.push("image result settings");
|
|
235
|
+
return names;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Select one primary provider plus at most one automatic alternative. */
|
|
239
|
+
export function createSearchRouter(options: SearchRouterOptions): SearchProviderResolver {
|
|
240
|
+
const policy = options.billingPolicy ?? "free-only";
|
|
241
|
+
return (request, context) => {
|
|
242
|
+
const normalized = validateSearchRequest(request);
|
|
243
|
+
if (normalized.executionModel !== undefined && (normalized.providerHint === undefined || normalized.providerHint === "native")) {
|
|
244
|
+
return unavailable("executionModel requires an explicit model-mediated provider hint", "router");
|
|
245
|
+
}
|
|
246
|
+
if (normalized.providerHint !== undefined) return explicitProvider(normalized, context, options, policy);
|
|
247
|
+
|
|
248
|
+
// A compatible active native model is the primary choice. A model using a
|
|
249
|
+
// different API variant (for example OpenAI chat completions) is not a
|
|
250
|
+
// grounded search backend; continue to registry/native or direct routing
|
|
251
|
+
// instead of making the user configure a provider manually. When the
|
|
252
|
+
// active model's native tool cannot honor a hard constraint, continue to
|
|
253
|
+
// the next eligible path rather than failing after dispatch.
|
|
254
|
+
for (const id of ACTIVE_NATIVE_IDS) {
|
|
255
|
+
if (!nativeModelCompatible(id, context.model)) continue;
|
|
256
|
+
const candidate = activeNativeProvider(id, normalized, options);
|
|
257
|
+
if (candidate !== undefined && canServe(candidate, normalized)) {
|
|
258
|
+
return selection(candidate, true, directFallback(candidate, normalized, options, policy));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Preserve Pi's built-in search when an authenticated same-provider model
|
|
263
|
+
// exists, even if the active model is OpenRouter, Anthropic, or local.
|
|
264
|
+
for (const id of REGISTRY_NATIVE_IDS) {
|
|
265
|
+
const candidate = options[NATIVE_REGISTRY[id]!.option];
|
|
266
|
+
if (candidate !== undefined && availableNativeModel(id, context) && canServe(candidate, normalized)) {
|
|
267
|
+
return selection(candidate, true, directFallback(candidate, normalized, options, policy));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const exaConfigured = options.exaConfigured === true;
|
|
272
|
+
const exaAllowed = policy !== "free-only";
|
|
273
|
+
const exaCanMatchMode = exaAllowed && options.exa !== undefined && canServe(options.exa, normalized);
|
|
274
|
+
|
|
275
|
+
const braveCanMatchMode = options.brave !== undefined && canServe(options.brave, normalized);
|
|
276
|
+
const braveConfigured = options.braveConfigured === true;
|
|
277
|
+
const braveAllowed = policy === "allow-configured-metered" || options.braveFreeCapacityConfigured === true;
|
|
278
|
+
// prefer-free chooses the admitted Brave path before a metered Exa call.
|
|
279
|
+
if (policy === "prefer-free" && braveCanMatchMode && braveConfigured && braveAllowed) {
|
|
280
|
+
checkBraveCapacity(options);
|
|
281
|
+
return selection(options.brave!, true);
|
|
282
|
+
}
|
|
283
|
+
if (exaConfigured && exaCanMatchMode) return selection(options.exa!, true, directFallback(options.exa!, normalized, options, policy));
|
|
284
|
+
if (braveCanMatchMode && braveConfigured && braveAllowed) {
|
|
285
|
+
checkBraveCapacity(options);
|
|
286
|
+
return selection(options.brave!, true);
|
|
287
|
+
}
|
|
288
|
+
// No direct provider was eligible; name the hard constraints the
|
|
289
|
+
// configured providers could not honor instead of blaming the retrieval
|
|
290
|
+
// mode, which never blocks selection on its own.
|
|
291
|
+
if (exaAllowed && exaConfigured && options.exa !== undefined) {
|
|
292
|
+
const unmet = unmetConstraintNames(normalized);
|
|
293
|
+
return unavailable(`Exa cannot satisfy the requested ${unmet.join(", ") || "search constraints"}`, "exa");
|
|
294
|
+
}
|
|
295
|
+
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");
|
|
296
|
+
if (braveConfigured && options.brave !== undefined) {
|
|
297
|
+
const unmet = unmetConstraintNames(normalized);
|
|
298
|
+
return unavailable(`Brave cannot satisfy the requested ${unmet.join(", ") || "search constraints"}`, "brave");
|
|
299
|
+
}
|
|
300
|
+
return unavailable("No eligible search provider is configured; configure a provider or use an active grounded model", "router");
|
|
301
|
+
};
|
|
302
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type { ProviderId, SearchAnswer, SearchCitation, SearchRequest, SearchResponse, SearchResult, SearchWarning } from "./contracts";
|
|
2
|
+
|
|
3
|
+
export const MAX_SEARCH_URL_LENGTH = 8_192;
|
|
4
|
+
export const MAX_SEARCH_TITLE_LENGTH = 500;
|
|
5
|
+
export const MAX_SEARCH_EXCERPT_LENGTH = 4_000;
|
|
6
|
+
export const MAX_SEARCH_DOMAIN_LENGTH = 253;
|
|
7
|
+
export const MAX_SEARCH_SOURCE_ID_LENGTH = 500;
|
|
8
|
+
export const MAX_SEARCH_QUERY_LENGTH = 2_000;
|
|
9
|
+
const MAX_SEARCH_ANSWER_LENGTH = 8_000;
|
|
10
|
+
const MAX_SEARCH_CITATIONS = 20;
|
|
11
|
+
// A run this deep is almost always an accidentally nested URL encoding. Keep
|
|
12
|
+
// one safe path prefix rather than surfacing or fetching the encoded tail.
|
|
13
|
+
const REPEATED_PERCENT_ENCODING = /%(?:25){3,}/i;
|
|
14
|
+
|
|
15
|
+
export interface CanonicalSearchUrl {
|
|
16
|
+
readonly url: string;
|
|
17
|
+
readonly domain: string;
|
|
18
|
+
readonly sourceUrl?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function boundedString(value: unknown, maxLength: number): string | undefined {
|
|
22
|
+
if (typeof value !== "string") return undefined;
|
|
23
|
+
const trimmed = value.trim();
|
|
24
|
+
if (trimmed.length === 0) return undefined;
|
|
25
|
+
return trimmed.slice(0, maxLength);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Normalize URL identity while preserving opaque encoding except for bounded nested-encoding tails. */
|
|
29
|
+
export function normalizeSearchUrl(value: unknown): CanonicalSearchUrl | undefined {
|
|
30
|
+
if (typeof value !== "string") return undefined;
|
|
31
|
+
const raw = value.trim();
|
|
32
|
+
if (raw.length === 0 || raw.length > MAX_SEARCH_URL_LENGTH) return undefined;
|
|
33
|
+
try {
|
|
34
|
+
const parsed = new URL(raw);
|
|
35
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return undefined;
|
|
36
|
+
if (parsed.username || parsed.password) return undefined;
|
|
37
|
+
const repeatedEncoding = REPEATED_PERCENT_ENCODING.exec(parsed.pathname);
|
|
38
|
+
if (repeatedEncoding !== null && repeatedEncoding.index > 0) {
|
|
39
|
+
// The prefix is the useful page path. Dropping the encoded suffix is
|
|
40
|
+
// safer than repeatedly decoding reserved URL characters or sending
|
|
41
|
+
// a provider-generated encoding bomb through source enrichment.
|
|
42
|
+
parsed.pathname = parsed.pathname.slice(0, repeatedEncoding.index);
|
|
43
|
+
parsed.search = "";
|
|
44
|
+
}
|
|
45
|
+
parsed.hash = "";
|
|
46
|
+
parsed.hostname = parsed.hostname.toLowerCase().replace(/\.$/, "");
|
|
47
|
+
const url = parsed.href;
|
|
48
|
+
if (url.length > MAX_SEARCH_URL_LENGTH) return undefined;
|
|
49
|
+
const domain = parsed.hostname.toLowerCase().replace(/\.$/, "");
|
|
50
|
+
if (domain.length === 0 || domain.length > MAX_SEARCH_DOMAIN_LENGTH) return undefined;
|
|
51
|
+
return {
|
|
52
|
+
url,
|
|
53
|
+
domain,
|
|
54
|
+
...(url === raw ? {} : { sourceUrl: raw }),
|
|
55
|
+
};
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Return the conservative identity used to deduplicate search and fetch URLs. */
|
|
62
|
+
export function searchUrlIdentity(value: unknown): string | undefined {
|
|
63
|
+
return normalizeSearchUrl(value)?.url;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Match a canonical result hostname against an exact host or its subdomains. */
|
|
67
|
+
export function matchesSearchDomain(hostname: string, domain: string): boolean {
|
|
68
|
+
const normalizedHostname = hostname.toLowerCase().replace(/\.$/, "");
|
|
69
|
+
const normalizedDomain = domain.toLowerCase().replace(/\.$/, "");
|
|
70
|
+
return normalizedHostname === normalizedDomain || normalizedHostname.endsWith(`.${normalizedDomain}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resultMatchesDomainFilter(result: SearchResult, request: SearchRequest): boolean {
|
|
74
|
+
const include = request.domains?.include;
|
|
75
|
+
const exclude = request.domains?.exclude;
|
|
76
|
+
if (include !== undefined && include.length > 0 && !include.some((domain) => matchesSearchDomain(result.domain ?? "", domain))) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
return exclude === undefined || !exclude.some((domain) => matchesSearchDomain(result.domain ?? "", domain));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function optionalTimestamp(value: unknown): string | undefined {
|
|
83
|
+
const candidate = boundedString(value, 100);
|
|
84
|
+
return candidate !== undefined && Number.isFinite(Date.parse(candidate)) ? candidate : undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function optionalScore(value: unknown): number | undefined {
|
|
88
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
|
89
|
+
return Math.max(0, Math.min(1, value));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeResult(value: unknown, request: SearchRequest, provider: ProviderId): SearchResult | undefined {
|
|
93
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
94
|
+
const result = value as Partial<SearchResult>;
|
|
95
|
+
const parsed = normalizeSearchUrl(result.url);
|
|
96
|
+
if (parsed === undefined) return undefined;
|
|
97
|
+
const title = boundedString(result.title, MAX_SEARCH_TITLE_LENGTH);
|
|
98
|
+
const publishedAt = optionalTimestamp(result.publishedAt);
|
|
99
|
+
const excerpt = boundedString(result.excerpt, MAX_SEARCH_EXCERPT_LENGTH);
|
|
100
|
+
const sourceId = boundedString(result.sourceId, MAX_SEARCH_SOURCE_ID_LENGTH);
|
|
101
|
+
const score = optionalScore(result.score);
|
|
102
|
+
const suppliedSource = normalizeSearchUrl(result.sourceUrl);
|
|
103
|
+
const sourcePage = normalizeSearchUrl(result.sourcePageUrl);
|
|
104
|
+
const sourceUrl = suppliedSource === undefined
|
|
105
|
+
? parsed.sourceUrl
|
|
106
|
+
: suppliedSource.url === parsed.url
|
|
107
|
+
? parsed.sourceUrl
|
|
108
|
+
: boundedString(result.sourceUrl, MAX_SEARCH_URL_LENGTH);
|
|
109
|
+
return {
|
|
110
|
+
url: parsed.url,
|
|
111
|
+
...(sourceUrl === undefined ? {} : { sourceUrl }),
|
|
112
|
+
...(sourcePage === undefined || sourcePage.url === parsed.url ? {} : { sourcePageUrl: sourcePage.url }),
|
|
113
|
+
...(title === undefined ? {} : { title }),
|
|
114
|
+
domain: parsed.domain,
|
|
115
|
+
...(publishedAt === undefined ? {} : { publishedAt }),
|
|
116
|
+
...(excerpt === undefined ? {} : { excerpt }),
|
|
117
|
+
provider,
|
|
118
|
+
searchQuery: request.query,
|
|
119
|
+
...(sourceId === undefined ? {} : { sourceId }),
|
|
120
|
+
...(score === undefined ? {} : { score }),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeAnswer(answer: SearchAnswer | undefined, provider: ProviderId, results: readonly SearchResult[], request: SearchRequest): SearchAnswer | undefined {
|
|
125
|
+
if (answer === undefined || request.answerMode === "evidence") return undefined;
|
|
126
|
+
const text = boundedString(answer.text, MAX_SEARCH_ANSWER_LENGTH);
|
|
127
|
+
if (text === undefined) return undefined;
|
|
128
|
+
const resultUrls = new Set(results.map((result) => result.url));
|
|
129
|
+
const citations: SearchCitation[] = [];
|
|
130
|
+
for (const citationValue of Array.isArray(answer.citations) ? answer.citations : []) {
|
|
131
|
+
if (typeof citationValue !== "object" || citationValue === null || Array.isArray(citationValue)) continue;
|
|
132
|
+
const citation = citationValue as Partial<SearchCitation>;
|
|
133
|
+
const parsed = normalizeSearchUrl(citation.url);
|
|
134
|
+
if (parsed === undefined || !resultUrls.has(parsed.url) || citations.some((item) => item.url === parsed.url)) continue;
|
|
135
|
+
const title = boundedString(citation.title, MAX_SEARCH_TITLE_LENGTH);
|
|
136
|
+
const sourceId = boundedString(citation.sourceId, MAX_SEARCH_SOURCE_ID_LENGTH);
|
|
137
|
+
const startIndex = typeof citation.startIndex === "number" && Number.isInteger(citation.startIndex) && citation.startIndex >= 0 && citation.startIndex <= text.length ? citation.startIndex : undefined;
|
|
138
|
+
const endIndex = typeof citation.endIndex === "number" && Number.isInteger(citation.endIndex) && citation.endIndex >= (startIndex ?? 0) && citation.endIndex <= text.length ? citation.endIndex : undefined;
|
|
139
|
+
citations.push({
|
|
140
|
+
url: parsed.url,
|
|
141
|
+
...(title === undefined ? {} : { title }),
|
|
142
|
+
...(sourceId === undefined ? {} : { sourceId }),
|
|
143
|
+
...(startIndex === undefined ? {} : { startIndex }),
|
|
144
|
+
...(endIndex === undefined ? {} : { endIndex }),
|
|
145
|
+
});
|
|
146
|
+
if (citations.length >= MAX_SEARCH_CITATIONS) break;
|
|
147
|
+
}
|
|
148
|
+
if (citations.length === 0) return undefined;
|
|
149
|
+
return {
|
|
150
|
+
text,
|
|
151
|
+
contentTrust: "untrusted",
|
|
152
|
+
provider,
|
|
153
|
+
...(typeof answer.executionModel === "string" ? { executionModel: answer.executionModel.slice(0, 500) } : {}),
|
|
154
|
+
citations,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function mergeResult(current: SearchResult, candidate: SearchResult): SearchResult {
|
|
159
|
+
return {
|
|
160
|
+
...current,
|
|
161
|
+
...(current.sourceUrl === undefined && candidate.sourceUrl !== undefined ? { sourceUrl: candidate.sourceUrl } : {}),
|
|
162
|
+
...(current.sourcePageUrl === undefined && candidate.sourcePageUrl !== undefined ? { sourcePageUrl: candidate.sourcePageUrl } : {}),
|
|
163
|
+
...(current.title === undefined && candidate.title !== undefined ? { title: candidate.title } : {}),
|
|
164
|
+
...(current.publishedAt === undefined && candidate.publishedAt !== undefined ? { publishedAt: candidate.publishedAt } : {}),
|
|
165
|
+
...(current.excerpt === undefined && candidate.excerpt !== undefined ? { excerpt: candidate.excerpt } : {}),
|
|
166
|
+
...(current.sourceId === undefined && candidate.sourceId !== undefined ? { sourceId: candidate.sourceId } : {}),
|
|
167
|
+
...(current.score === undefined && candidate.score !== undefined ? { score: candidate.score } : {}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Normalize provider output once at the shared search boundary. */
|
|
172
|
+
export function cleanupSearchResponse(response: SearchResponse, request: SearchRequest, provider: ProviderId): SearchResponse {
|
|
173
|
+
const candidates = Array.isArray(response.results) ? response.results : [];
|
|
174
|
+
const results = new Map<string, SearchResult>();
|
|
175
|
+
let discarded = Array.isArray(response.results) ? 0 : 1;
|
|
176
|
+
let domainDiscarded = 0;
|
|
177
|
+
for (const value of candidates) {
|
|
178
|
+
const normalized = normalizeResult(value, request, provider);
|
|
179
|
+
if (normalized === undefined) {
|
|
180
|
+
discarded += 1;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (!resultMatchesDomainFilter(normalized, request)) {
|
|
184
|
+
domainDiscarded += 1;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const current = results.get(normalized.url);
|
|
188
|
+
results.set(normalized.url, current === undefined ? normalized : mergeResult(current, normalized));
|
|
189
|
+
}
|
|
190
|
+
const warnings: SearchWarning[] = Array.isArray(response.warnings) ? [...response.warnings] : [];
|
|
191
|
+
if (discarded > 0) {
|
|
192
|
+
warnings.push({
|
|
193
|
+
code: "partial-results",
|
|
194
|
+
message: `Search discarded ${discarded} malformed result entr${discarded === 1 ? "y" : "ies"}`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (domainDiscarded > 0) {
|
|
198
|
+
warnings.push({
|
|
199
|
+
code: "partial-results",
|
|
200
|
+
message: `Search removed ${domainDiscarded} result entr${domainDiscarded === 1 ? "y" : "ies"} outside the requested domain constraints`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
const normalizedResults = [...results.values()].slice(0, request.maxResults ?? 10);
|
|
204
|
+
const appliedOptions = [...response.appliedOptions];
|
|
205
|
+
if ((request.domains?.include?.length ?? 0) > 0 || (request.domains?.exclude?.length ?? 0) > 0) {
|
|
206
|
+
if (!appliedOptions.includes("domains")) appliedOptions.push("domains");
|
|
207
|
+
}
|
|
208
|
+
const answer = normalizeAnswer(response.answer, provider, normalizedResults, request);
|
|
209
|
+
const { answer: _ignoredAnswer, ...responseWithoutAnswer } = response;
|
|
210
|
+
return {
|
|
211
|
+
...responseWithoutAnswer,
|
|
212
|
+
query: request.query,
|
|
213
|
+
provider,
|
|
214
|
+
results: normalizedResults,
|
|
215
|
+
...(answer === undefined ? {} : { answer }),
|
|
216
|
+
appliedOptions,
|
|
217
|
+
warnings,
|
|
218
|
+
};
|
|
219
|
+
}
|