@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/gemini.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Provider,
|
|
3
|
+
ProviderCapabilities,
|
|
4
|
+
ProviderContext,
|
|
5
|
+
ProviderModel,
|
|
6
|
+
ProviderProfile,
|
|
7
|
+
SearchOption,
|
|
8
|
+
SearchRequest,
|
|
9
|
+
SearchResponse,
|
|
10
|
+
SearchResult,
|
|
11
|
+
SearchWarning,
|
|
12
|
+
} from "./contracts";
|
|
13
|
+
import { createProviderError } from "./errors";
|
|
14
|
+
import { cancelResponseBody } from "./http";
|
|
15
|
+
import { executeGroundedSearch } from "./grounding";
|
|
16
|
+
import { httpSource, objectValue, optionalString, type SearchHttpFetch } from "./provider-http";
|
|
17
|
+
import { modelAuthHeaders, type ModelExecution } from "./model-selection";
|
|
18
|
+
import { validateSearchRequest } from "./search";
|
|
19
|
+
|
|
20
|
+
export const GEMINI_GENERATE_CONTENT_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta";
|
|
21
|
+
export const DEFAULT_GEMINI_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
22
|
+
const GEMINI_GROUNDING_REDIRECT_HOST = "vertexaisearch.cloud.google.com";
|
|
23
|
+
const GEMINI_GROUNDING_REDIRECT_TIMEOUT_MS = 5_000;
|
|
24
|
+
const MAX_GEMINI_ANSWER_LENGTH = 8_000;
|
|
25
|
+
|
|
26
|
+
export interface GeminiAdapterOptions {
|
|
27
|
+
readonly endpoint?: string;
|
|
28
|
+
readonly fetchImpl?: SearchHttpFetch;
|
|
29
|
+
readonly maxResponseBytes?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const capabilities: ProviderCapabilities = {
|
|
33
|
+
freshness: true,
|
|
34
|
+
semantic: true,
|
|
35
|
+
nativeGrounding: true,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const profile: ProviderProfile = {
|
|
39
|
+
auth: "modelRegistry",
|
|
40
|
+
costModel: "usage-based",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export interface GeminiRequestPlan {
|
|
44
|
+
readonly body: Record<string, unknown>;
|
|
45
|
+
readonly appliedOptions: readonly SearchOption[];
|
|
46
|
+
readonly warnings: readonly SearchWarning[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function buildGeminiRequest(request: SearchRequest): GeminiRequestPlan {
|
|
50
|
+
const normalized = validateSearchRequest(request);
|
|
51
|
+
if (normalized.domains?.include?.length || normalized.domains?.exclude?.length) {
|
|
52
|
+
throw createProviderError({ provider: "gemini", kind: "unsupported", message: "Gemini grounding does not expose hard domain filters", retryable: false });
|
|
53
|
+
}
|
|
54
|
+
if (normalized.dateRange !== undefined || normalized.social !== undefined) {
|
|
55
|
+
throw createProviderError({ provider: "gemini", kind: "unsupported", message: "Gemini grounding does not expose exact date-range or dedicated social/X constraints", retryable: false });
|
|
56
|
+
}
|
|
57
|
+
const warnings: SearchWarning[] = [];
|
|
58
|
+
if (normalized.mode === "keyword") {
|
|
59
|
+
warnings.push({ code: "unsupported-option", option: "mode", message: "Gemini grounding uses semantic retrieval rather than keyword-only ranking" });
|
|
60
|
+
}
|
|
61
|
+
if (normalized.mode === "fresh") {
|
|
62
|
+
warnings.push({ code: "unsupported-option", option: "mode", message: "Gemini grounding can use current sources but does not guarantee a freshness-only ranking" });
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
body: {
|
|
66
|
+
contents: [{ role: "user", parts: [{ text: normalized.mode === "fresh" ? `Prefer current sources. ${normalized.query}` : normalized.query }] }],
|
|
67
|
+
tools: [{ google_search: {} }],
|
|
68
|
+
},
|
|
69
|
+
appliedOptions: ["maxResults", "mode"],
|
|
70
|
+
warnings,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isGroundingRedirectUrl(url: string): boolean {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = new URL(url);
|
|
77
|
+
return parsed.protocol === "https:" && parsed.hostname.toLowerCase() === GEMINI_GROUNDING_REDIRECT_HOST && parsed.pathname.startsWith("/grounding-api-redirect/");
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function endpointFor(model: ProviderModel, override?: string): string {
|
|
84
|
+
const base = (override ?? (model.baseUrl.trim().length > 0 ? model.baseUrl : GEMINI_GENERATE_CONTENT_ENDPOINT)).replace(/\/+$/, "");
|
|
85
|
+
if (base.endsWith(":generateContent")) return base;
|
|
86
|
+
return `${base}/models/${encodeURIComponent(model.id)}:generateContent`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function authHeaders(execution: ModelExecution): Readonly<Record<string, string>> {
|
|
90
|
+
const headers = modelAuthHeaders(execution, { bearerApiKey: false });
|
|
91
|
+
if (execution.auth.apiKey !== undefined && execution.auth.apiKey.trim().length > 0) headers.set("x-goog-api-key", execution.auth.apiKey);
|
|
92
|
+
if (!headers.has("x-goog-api-key")) {
|
|
93
|
+
throw createProviderError({ provider: "gemini", kind: "auth", message: "Gemini authentication returned no API key", retryable: false });
|
|
94
|
+
}
|
|
95
|
+
return Object.fromEntries(headers.entries());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function resolveGroundingUrl(url: string, signal: AbortSignal, fetchImpl: SearchHttpFetch): Promise<string> {
|
|
99
|
+
if (signal.aborted) throw createProviderError({ provider: "gemini", kind: "canceled", message: "Search canceled", retryable: false });
|
|
100
|
+
let parsed: URL;
|
|
101
|
+
try {
|
|
102
|
+
parsed = new URL(url);
|
|
103
|
+
} catch {
|
|
104
|
+
return url;
|
|
105
|
+
}
|
|
106
|
+
if (!isGroundingRedirectUrl(url)) return url;
|
|
107
|
+
const controller = new AbortController();
|
|
108
|
+
const onAbort = () => controller.abort(signal.reason);
|
|
109
|
+
const timeoutId = setTimeout(() => controller.abort(new DOMException("Gemini grounding URL resolution timed out", "TimeoutError")), GEMINI_GROUNDING_REDIRECT_TIMEOUT_MS);
|
|
110
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
111
|
+
try {
|
|
112
|
+
const response = await fetchImpl(url, { method: "HEAD", redirect: "manual", signal: controller.signal });
|
|
113
|
+
const location = response.headers.get("location");
|
|
114
|
+
await cancelResponseBody(response);
|
|
115
|
+
if (location === null || location.trim().length === 0) return url;
|
|
116
|
+
const target = httpSource(new URL(location, url).toString(), "gemini");
|
|
117
|
+
return target?.url ?? url;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (signal.aborted) throw createProviderError({ provider: "gemini", kind: "canceled", message: "Search canceled", retryable: false, cause: error });
|
|
120
|
+
return url;
|
|
121
|
+
} finally {
|
|
122
|
+
clearTimeout(timeoutId);
|
|
123
|
+
signal.removeEventListener("abort", onAbort);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function normalizeGeminiResponse(payload: unknown, request: SearchRequest, signal: AbortSignal, fetchImpl: SearchHttpFetch): Promise<SearchResponse> {
|
|
128
|
+
const normalized = validateSearchRequest(request);
|
|
129
|
+
const root = objectValue(payload, "response", "gemini");
|
|
130
|
+
const promptFeedback = root.promptFeedback;
|
|
131
|
+
if (promptFeedback !== undefined) {
|
|
132
|
+
const feedback = objectValue(promptFeedback, "promptFeedback", "gemini");
|
|
133
|
+
const blockReason = optionalString(feedback.blockReason);
|
|
134
|
+
if (blockReason !== undefined) {
|
|
135
|
+
throw createProviderError({ provider: "gemini", kind: "http", message: `Gemini grounding was blocked (${blockReason})`, retryable: false });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!Array.isArray(root.candidates)) {
|
|
139
|
+
throw createProviderError({ provider: "gemini", kind: "malformed", message: "Gemini returned no candidates array", retryable: false });
|
|
140
|
+
}
|
|
141
|
+
const results: SearchResult[] = [];
|
|
142
|
+
const answerParts: string[] = [];
|
|
143
|
+
const answerCitationUrls = new Set<string>();
|
|
144
|
+
const groundingQueries = new Set<string>();
|
|
145
|
+
const maxResults = normalized.maxResults ?? 10;
|
|
146
|
+
const seen = new Set<string>();
|
|
147
|
+
const warnings: SearchWarning[] = [];
|
|
148
|
+
let discarded = 0;
|
|
149
|
+
let redirectAttempts = 0;
|
|
150
|
+
for (const candidateValue of root.candidates) {
|
|
151
|
+
const candidate = objectValue(candidateValue, "candidates[]", "gemini");
|
|
152
|
+
const finishReason = optionalString(candidate.finishReason);
|
|
153
|
+
if (finishReason !== undefined && finishReason !== "STOP" && finishReason !== "UNSPECIFIED") {
|
|
154
|
+
warnings.push({ code: "partial-results", message: `Gemini grounding candidate finished with ${finishReason}` });
|
|
155
|
+
}
|
|
156
|
+
const content = candidate.content;
|
|
157
|
+
if (content !== null && typeof content === "object" && !Array.isArray(content)) {
|
|
158
|
+
const parts = (content as Record<string, unknown>).parts;
|
|
159
|
+
if (Array.isArray(parts)) {
|
|
160
|
+
for (const part of parts) {
|
|
161
|
+
if (part !== null && typeof part === "object" && !Array.isArray(part) && typeof (part as Record<string, unknown>).text === "string") answerParts.push((part as Record<string, unknown>).text as string);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const metadata = candidate.groundingMetadata;
|
|
166
|
+
if (metadata === undefined) continue;
|
|
167
|
+
const grounding = objectValue(metadata, "groundingMetadata", "gemini");
|
|
168
|
+
if (Array.isArray(grounding.webSearchQueries)) {
|
|
169
|
+
for (const query of grounding.webSearchQueries) if (typeof query === "string" && query.trim().length > 0) groundingQueries.add(query.trim());
|
|
170
|
+
}
|
|
171
|
+
if (!Array.isArray(grounding.groundingChunks)) continue;
|
|
172
|
+
const chunkUrls: Array<string | undefined> = [];
|
|
173
|
+
for (const chunkValue of grounding.groundingChunks) {
|
|
174
|
+
const chunk = objectValue(chunkValue, "groundingChunks[]", "gemini");
|
|
175
|
+
const web = chunk.web;
|
|
176
|
+
if (web === undefined) {
|
|
177
|
+
chunkUrls.push(undefined);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const source = objectValue(web, "groundingChunks[].web", "gemini");
|
|
181
|
+
const parsed = httpSource(source.uri ?? source.url, "gemini");
|
|
182
|
+
if (parsed === undefined) {
|
|
183
|
+
discarded += 1;
|
|
184
|
+
chunkUrls.push(undefined);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
let canonicalUrl = parsed.url;
|
|
188
|
+
if (isGroundingRedirectUrl(parsed.url) && redirectAttempts < maxResults) {
|
|
189
|
+
redirectAttempts += 1;
|
|
190
|
+
canonicalUrl = await resolveGroundingUrl(parsed.url, signal, fetchImpl);
|
|
191
|
+
}
|
|
192
|
+
const canonical = httpSource(canonicalUrl, "gemini");
|
|
193
|
+
if (canonical === undefined) {
|
|
194
|
+
discarded += 1;
|
|
195
|
+
chunkUrls.push(undefined);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
chunkUrls.push(canonical.url);
|
|
199
|
+
if (seen.has(canonical.url)) continue;
|
|
200
|
+
seen.add(canonical.url);
|
|
201
|
+
if (results.length < maxResults) {
|
|
202
|
+
results.push({
|
|
203
|
+
url: canonical.url,
|
|
204
|
+
...(canonical.url === parsed.url ? {} : { sourceUrl: parsed.url }),
|
|
205
|
+
title: optionalString(source.title, 500),
|
|
206
|
+
domain: canonical.domain,
|
|
207
|
+
provider: "gemini",
|
|
208
|
+
searchQuery: normalized.query,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (Array.isArray(grounding.groundingSupports)) {
|
|
213
|
+
for (const supportValue of grounding.groundingSupports) {
|
|
214
|
+
if (supportValue === null || typeof supportValue !== "object" || Array.isArray(supportValue)) continue;
|
|
215
|
+
const indices = (supportValue as Record<string, unknown>).groundingChunkIndices;
|
|
216
|
+
if (!Array.isArray(indices)) continue;
|
|
217
|
+
for (const index of indices) {
|
|
218
|
+
if (typeof index === "number" && Number.isInteger(index) && index >= 0) {
|
|
219
|
+
const url = chunkUrls[index];
|
|
220
|
+
if (url !== undefined) answerCitationUrls.add(url);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (results.length === 0) {
|
|
227
|
+
throw createProviderError({ provider: "gemini", kind: "malformed", message: "Gemini returned no parseable grounding URLs", retryable: false });
|
|
228
|
+
}
|
|
229
|
+
if (discarded > 0) warnings.push({ code: "partial-results", message: `Gemini discarded ${discarded} malformed grounding entr${discarded === 1 ? "y" : "ies"}` });
|
|
230
|
+
const usage = root.usageMetadata;
|
|
231
|
+
const usageRecord = usage === undefined ? undefined : objectValue(usage, "usageMetadata", "gemini");
|
|
232
|
+
const inputTokens = typeof usageRecord?.promptTokenCount === "number" && Number.isFinite(usageRecord.promptTokenCount) ? usageRecord.promptTokenCount : undefined;
|
|
233
|
+
const outputTokens = typeof usageRecord?.candidatesTokenCount === "number" && Number.isFinite(usageRecord.candidatesTokenCount) ? usageRecord.candidatesTokenCount : undefined;
|
|
234
|
+
const totalTokens = typeof usageRecord?.totalTokenCount === "number" && Number.isFinite(usageRecord.totalTokenCount) ? usageRecord.totalTokenCount : undefined;
|
|
235
|
+
const answerText = answerParts.join(" ").replace(/\s+/g, " ").trim().slice(0, MAX_GEMINI_ANSWER_LENGTH);
|
|
236
|
+
const resultByUrl = new Map(results.map((result) => [result.url, result]));
|
|
237
|
+
const citations = [...answerCitationUrls].map((url) => resultByUrl.get(url)).filter((result): result is SearchResult => result !== undefined).map((result) => ({ url: result.url, ...(result.title === undefined ? {} : { title: result.title }) }));
|
|
238
|
+
const answer = normalized.answerMode !== "evidence" && answerText.length > 0 && citations.length > 0
|
|
239
|
+
? { text: answerText, contentTrust: "untrusted" as const, provider: "gemini" as const, citations }
|
|
240
|
+
: undefined;
|
|
241
|
+
const usageDetails = inputTokens === undefined && outputTokens === undefined && totalTokens === undefined && groundingQueries.size === 0
|
|
242
|
+
? undefined
|
|
243
|
+
: {
|
|
244
|
+
...(inputTokens === undefined ? {} : { inputTokens }),
|
|
245
|
+
...(outputTokens === undefined ? {} : { outputTokens }),
|
|
246
|
+
...(totalTokens === undefined ? {} : { totalTokens }),
|
|
247
|
+
...(groundingQueries.size === 0 ? {} : { searchQueries: groundingQueries.size }),
|
|
248
|
+
};
|
|
249
|
+
return {
|
|
250
|
+
query: normalized.query,
|
|
251
|
+
results,
|
|
252
|
+
...(answer === undefined ? {} : { answer }),
|
|
253
|
+
provider: "gemini",
|
|
254
|
+
appliedOptions: [],
|
|
255
|
+
warnings,
|
|
256
|
+
...(usageDetails === undefined ? {} : { usage: usageDetails }),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export class GeminiProvider implements Provider {
|
|
261
|
+
readonly id = "gemini" as const;
|
|
262
|
+
readonly capabilities = capabilities;
|
|
263
|
+
readonly profile = profile;
|
|
264
|
+
private readonly endpoint?: string;
|
|
265
|
+
private readonly fetchImpl: SearchHttpFetch;
|
|
266
|
+
private readonly maxResponseBytes: number;
|
|
267
|
+
|
|
268
|
+
constructor(options: GeminiAdapterOptions = {}) {
|
|
269
|
+
this.endpoint = options.endpoint;
|
|
270
|
+
this.fetchImpl = options.fetchImpl ?? (fetch as SearchHttpFetch);
|
|
271
|
+
this.maxResponseBytes = options.maxResponseBytes ?? DEFAULT_GEMINI_RESPONSE_BYTES;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async search(request: SearchRequest, signal: AbortSignal, context: ProviderContext): Promise<SearchResponse> {
|
|
275
|
+
const normalized = validateSearchRequest(request);
|
|
276
|
+
const plan = buildGeminiRequest(normalized);
|
|
277
|
+
return executeGroundedSearch({
|
|
278
|
+
provider: this.id,
|
|
279
|
+
modelProvider: "google",
|
|
280
|
+
api: "google-generative-ai",
|
|
281
|
+
request: normalized,
|
|
282
|
+
signal,
|
|
283
|
+
context,
|
|
284
|
+
fetchImpl: this.fetchImpl,
|
|
285
|
+
maxResponseBytes: this.maxResponseBytes,
|
|
286
|
+
// Gemini addresses the model in the URL, not the body.
|
|
287
|
+
includeModel: false,
|
|
288
|
+
endpointFor: (model) => endpointFor(model, this.endpoint),
|
|
289
|
+
headersFor: authHeaders,
|
|
290
|
+
plan,
|
|
291
|
+
normalize: (payload, current, grounding) => normalizeGeminiResponse(payload, current, grounding.signal, this.fetchImpl),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function createGeminiProvider(options: GeminiAdapterOptions = {}): GeminiProvider {
|
|
297
|
+
return new GeminiProvider(options);
|
|
298
|
+
}
|
package/src/grounding.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ProviderContext,
|
|
3
|
+
ProviderId,
|
|
4
|
+
ProviderModel,
|
|
5
|
+
ProviderRateLimitInfo,
|
|
6
|
+
ProviderUsage,
|
|
7
|
+
SearchOption,
|
|
8
|
+
SearchRequest,
|
|
9
|
+
SearchResponse,
|
|
10
|
+
SearchWarning,
|
|
11
|
+
} from "./contracts";
|
|
12
|
+
import { createProviderError } from "./errors";
|
|
13
|
+
import { modelAuthHeaders, selectModelExecution, type ModelExecution } from "./model-selection";
|
|
14
|
+
import { postJson, type SearchHttpFetch } from "./provider-http";
|
|
15
|
+
import { validateSearchRequest } from "./search";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Shared helpers for model-mediated (native grounding) search adapters.
|
|
19
|
+
*
|
|
20
|
+
* Each native API keeps its own request/response mapping because tool names,
|
|
21
|
+
* citation shapes, and billing fields differ per vendor. What is genuinely
|
|
22
|
+
* common — model resolution through Pi's registry, bearer auth, endpoint
|
|
23
|
+
* joining, usage/rate-limit merging, and final response assembly — lives
|
|
24
|
+
* here so a new adapter is capabilities + build + normalize plus fixtures.
|
|
25
|
+
*
|
|
26
|
+
* OpenAI's streaming Responses transport stays bespoke in `openai.ts`; the
|
|
27
|
+
* `postJson` flow here covers Gemini, xAI, Codex, Anthropic, and Meta.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface GroundingPlan {
|
|
31
|
+
readonly body: Record<string, unknown>;
|
|
32
|
+
readonly appliedOptions: readonly SearchOption[];
|
|
33
|
+
readonly warnings: readonly SearchWarning[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Token-count usage shared by Responses-family payloads. */
|
|
37
|
+
export function tokenUsage(
|
|
38
|
+
inputTokens: unknown,
|
|
39
|
+
outputTokens: unknown,
|
|
40
|
+
totalTokens: unknown,
|
|
41
|
+
): ProviderUsage | undefined {
|
|
42
|
+
const clean = (value: unknown): number | undefined =>
|
|
43
|
+
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
44
|
+
const input = clean(inputTokens);
|
|
45
|
+
const output = clean(outputTokens);
|
|
46
|
+
const total = clean(totalTokens);
|
|
47
|
+
if (input === undefined && output === undefined && total === undefined) return undefined;
|
|
48
|
+
return {
|
|
49
|
+
...(input === undefined ? {} : { inputTokens: input }),
|
|
50
|
+
...(output === undefined ? {} : { outputTokens: output }),
|
|
51
|
+
...(total === undefined ? {} : { totalTokens: total, billedUnits: total, billedUnit: "tokens" }),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Merge provider-reported usage with observed transport rate limits. */
|
|
56
|
+
export function usageWithRateLimits(
|
|
57
|
+
usage: ProviderUsage | undefined,
|
|
58
|
+
rateLimits: ProviderRateLimitInfo | undefined,
|
|
59
|
+
): ProviderUsage | undefined {
|
|
60
|
+
if (usage === undefined && rateLimits === undefined) return undefined;
|
|
61
|
+
return { ...usage, ...(rateLimits === undefined ? {} : { rateLimits }) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Join a model base URL with an API suffix without doubling the path. */
|
|
65
|
+
export function appendEndpointSuffix(base: string, suffix: string): string {
|
|
66
|
+
const trimmed = base.replace(/\/+$/, "");
|
|
67
|
+
return trimmed.endsWith(suffix) ? trimmed : `${trimmed}${suffix}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Validate a resolved endpoint URL without assuming a vendor path layout. */
|
|
71
|
+
export function assertHttpEndpoint(candidate: string, provider: ProviderId, label: string): string {
|
|
72
|
+
let url: URL;
|
|
73
|
+
try {
|
|
74
|
+
url = new URL(candidate);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
throw createProviderError({ provider, kind: "badRequest", message: `${label} is not a valid URL`, retryable: false, cause: error });
|
|
77
|
+
}
|
|
78
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
79
|
+
throw createProviderError({ provider, kind: "badRequest", message: `${label} must use HTTP or HTTPS`, retryable: false });
|
|
80
|
+
}
|
|
81
|
+
return url.toString();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Bearer auth derived from the model registry; never reads Pi auth globally. */
|
|
85
|
+
export function bearerAuthHeaders(execution: ModelExecution, provider: ProviderId): Readonly<Record<string, string>> {
|
|
86
|
+
const headers = modelAuthHeaders(execution);
|
|
87
|
+
if (!headers.has("authorization")) {
|
|
88
|
+
throw createProviderError({ provider, kind: "auth", message: "Model authentication returned no authorization header", retryable: false });
|
|
89
|
+
}
|
|
90
|
+
return Object.fromEntries(headers.entries());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface GroundedSearchOptions {
|
|
94
|
+
readonly provider: ProviderId;
|
|
95
|
+
readonly modelProvider: string;
|
|
96
|
+
readonly api: string;
|
|
97
|
+
readonly request: SearchRequest;
|
|
98
|
+
readonly signal: AbortSignal;
|
|
99
|
+
readonly context: ProviderContext;
|
|
100
|
+
readonly fetchImpl: SearchHttpFetch;
|
|
101
|
+
readonly maxResponseBytes: number;
|
|
102
|
+
/**
|
|
103
|
+
* Inject the execution model id into the request body. Gemini addresses
|
|
104
|
+
* the model in the URL instead, so it opts out. Defaults to true.
|
|
105
|
+
*/
|
|
106
|
+
readonly includeModel?: boolean;
|
|
107
|
+
readonly endpointFor: (model: ProviderModel) => string;
|
|
108
|
+
readonly headersFor: (execution: ModelExecution) => Readonly<Record<string, string>>;
|
|
109
|
+
readonly plan: GroundingPlan;
|
|
110
|
+
readonly normalize: (
|
|
111
|
+
payload: unknown,
|
|
112
|
+
normalized: SearchRequest,
|
|
113
|
+
context: { readonly signal: AbortSignal },
|
|
114
|
+
) => Promise<SearchResponse> | SearchResponse;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Run one non-streaming grounded-model search: resolve the execution model,
|
|
119
|
+
* POST the plan body, normalize the payload, and attach execution metadata.
|
|
120
|
+
* Callers supply only vendor-specific pieces.
|
|
121
|
+
*/
|
|
122
|
+
export async function executeGroundedSearch(options: GroundedSearchOptions): Promise<SearchResponse> {
|
|
123
|
+
const normalized = validateSearchRequest(options.request);
|
|
124
|
+
if (options.signal.aborted) {
|
|
125
|
+
throw createProviderError({ provider: options.provider, kind: "canceled", message: "Search canceled", retryable: false });
|
|
126
|
+
}
|
|
127
|
+
const execution = await selectModelExecution({
|
|
128
|
+
searchProvider: options.provider,
|
|
129
|
+
modelProvider: options.modelProvider,
|
|
130
|
+
api: options.api,
|
|
131
|
+
request: normalized,
|
|
132
|
+
context: options.context,
|
|
133
|
+
// The router rejects an explicit provider hint without an executionModel
|
|
134
|
+
// before dispatch when the active model is incompatible, and the native
|
|
135
|
+
// alias requires a compatible active model. So a request that reaches an
|
|
136
|
+
// adapter without an executionModel is either active-compatible (the
|
|
137
|
+
// normal active-match path) or a router-initiated registry fallback
|
|
138
|
+
// (DESIGN backend-resolution step 2), where a registry model is already
|
|
139
|
+
// confirmed available.
|
|
140
|
+
...(normalized.executionModel === undefined ? { allowRegistryFallback: true } : {}),
|
|
141
|
+
});
|
|
142
|
+
if (options.signal.aborted) {
|
|
143
|
+
throw createProviderError({ provider: options.provider, kind: "canceled", message: "Search canceled", retryable: false });
|
|
144
|
+
}
|
|
145
|
+
const result = await postJson({
|
|
146
|
+
provider: options.provider,
|
|
147
|
+
url: options.endpointFor(execution.model),
|
|
148
|
+
headers: options.headersFor(execution),
|
|
149
|
+
body: options.includeModel === false ? options.plan.body : { ...options.plan.body, model: execution.model.id },
|
|
150
|
+
signal: options.signal,
|
|
151
|
+
fetchImpl: options.fetchImpl,
|
|
152
|
+
maxResponseBytes: options.maxResponseBytes,
|
|
153
|
+
});
|
|
154
|
+
const response = await options.normalize(result.payload, normalized, { signal: options.signal });
|
|
155
|
+
const usage = usageWithRateLimits(response.usage, result.rateLimits);
|
|
156
|
+
return {
|
|
157
|
+
...response,
|
|
158
|
+
...(response.answer === undefined ? {} : { answer: { ...response.answer, executionModel: execution.model.id } }),
|
|
159
|
+
...(response.requestId === undefined && result.requestId === undefined
|
|
160
|
+
? {}
|
|
161
|
+
: { requestId: response.requestId ?? result.requestId }),
|
|
162
|
+
...(usage === undefined ? {} : { usage }),
|
|
163
|
+
executionModel: execution.model.id,
|
|
164
|
+
appliedOptions: options.plan.appliedOptions,
|
|
165
|
+
warnings: [...options.plan.warnings, ...response.warnings],
|
|
166
|
+
};
|
|
167
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Maximum response bytes are enforced while reading, not from Content-Length alone. */
|
|
2
|
+
export class ResponseBodyTooLargeError extends Error {
|
|
3
|
+
readonly bytesRead: number;
|
|
4
|
+
readonly maxBytes: number;
|
|
5
|
+
|
|
6
|
+
constructor(bytesRead: number, maxBytes: number) {
|
|
7
|
+
super(`Response body exceeded ${maxBytes} bytes`);
|
|
8
|
+
this.name = "ResponseBodyTooLargeError";
|
|
9
|
+
this.bytesRead = bytesRead;
|
|
10
|
+
this.maxBytes = maxBytes;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Release an unused response body before returning a provider error. */
|
|
15
|
+
export async function cancelResponseBody(response: Response): Promise<void> {
|
|
16
|
+
try {
|
|
17
|
+
await response.body?.cancel();
|
|
18
|
+
} catch {
|
|
19
|
+
// Preserve the provider status/error; body teardown is best effort.
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function abortError(signal: AbortSignal): Error {
|
|
24
|
+
return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Read a response body with cancellation and a hard byte bound. */
|
|
28
|
+
export async function readBoundedResponseText(
|
|
29
|
+
response: Response,
|
|
30
|
+
maxBytes: number,
|
|
31
|
+
signal?: AbortSignal,
|
|
32
|
+
): Promise<string> {
|
|
33
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 1) {
|
|
34
|
+
throw new RangeError("maxBytes must be a positive integer");
|
|
35
|
+
}
|
|
36
|
+
if (signal?.aborted) throw abortError(signal);
|
|
37
|
+
const contentLength = response.headers.get("content-length");
|
|
38
|
+
if (contentLength !== null) {
|
|
39
|
+
const parsedLength = Number(contentLength);
|
|
40
|
+
if (Number.isFinite(parsedLength) && parsedLength > maxBytes) {
|
|
41
|
+
await cancelResponseBody(response);
|
|
42
|
+
throw new ResponseBodyTooLargeError(parsedLength, maxBytes);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (response.body === null) return "";
|
|
46
|
+
|
|
47
|
+
const reader = response.body.getReader();
|
|
48
|
+
const decoder = new TextDecoder();
|
|
49
|
+
let bytesRead = 0;
|
|
50
|
+
const onAbort = () => {
|
|
51
|
+
void reader.cancel(signal?.reason);
|
|
52
|
+
};
|
|
53
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
54
|
+
try {
|
|
55
|
+
const chunks: string[] = [];
|
|
56
|
+
while (true) {
|
|
57
|
+
if (signal?.aborted) throw abortError(signal);
|
|
58
|
+
const { done, value } = await reader.read();
|
|
59
|
+
if (signal?.aborted) throw abortError(signal);
|
|
60
|
+
if (done) break;
|
|
61
|
+
bytesRead += value.byteLength;
|
|
62
|
+
if (bytesRead > maxBytes) {
|
|
63
|
+
throw new ResponseBodyTooLargeError(bytesRead, maxBytes);
|
|
64
|
+
}
|
|
65
|
+
chunks.push(decoder.decode(value, { stream: true }));
|
|
66
|
+
}
|
|
67
|
+
chunks.push(decoder.decode());
|
|
68
|
+
return chunks.join("");
|
|
69
|
+
} finally {
|
|
70
|
+
signal?.removeEventListener("abort", onAbort);
|
|
71
|
+
try {
|
|
72
|
+
await reader.cancel();
|
|
73
|
+
} catch {
|
|
74
|
+
// The body is already being torn down. Preserve the original failure.
|
|
75
|
+
}
|
|
76
|
+
reader.releaseLock();
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { createAnthropicProvider } from "./anthropic";
|
|
3
|
+
import { createBraveProvider, BraveQuotaTracker } from "./brave";
|
|
4
|
+
import { createBraveAnswersProvider } from "./brave-answers";
|
|
5
|
+
import { createCodexProvider } from "./codex";
|
|
6
|
+
import { createExaProvider } from "./exa";
|
|
7
|
+
import { createGeminiProvider } from "./gemini";
|
|
8
|
+
import { registerWebFetch } from "./fetch-tool";
|
|
9
|
+
import { createOpenAIProvider } from "./openai";
|
|
10
|
+
import { createParallelProvider } from "./parallel";
|
|
11
|
+
import { createParallelResponsesProvider } from "./parallel-responses";
|
|
12
|
+
import { createMetaProvider } from "./meta";
|
|
13
|
+
import { createSearchRouter } from "./router";
|
|
14
|
+
import { createXProvider } from "./x";
|
|
15
|
+
import { createXAIProvider } from "./xai";
|
|
16
|
+
import { adaptLocalLlamaPayload, isLocalOpenAICompatibleModel } from "./local-llama-compat";
|
|
17
|
+
import { availableProviderHints } from "./provider-availability";
|
|
18
|
+
import { registerWebResearch } from "./research-tool";
|
|
19
|
+
import { registerWebSearch } from "./search-tool";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 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.
|
|
26
|
+
*/
|
|
27
|
+
export default function (pi: ExtensionAPI): void {
|
|
28
|
+
const braveKey = process.env.BRAVE_API_KEY;
|
|
29
|
+
// Brave is a conservative last-resort path: pace starts at 1 RPS and never claims
|
|
30
|
+
// to know the account's billing balance. Set `=0` only with explicit metered
|
|
31
|
+
// opt-in when that pacing is intentionally not wanted.
|
|
32
|
+
const braveFreeOnly = process.env.PI_SEARCH_BRAVE_FREE_ONLY !== "0";
|
|
33
|
+
const braveCapacity = new BraveQuotaTracker({ minimumIntervalMs: braveFreeOnly ? 1_000 : 0 });
|
|
34
|
+
const brave = createBraveProvider({ apiKey: braveKey, capacityTracker: braveCapacity });
|
|
35
|
+
const openai = createOpenAIProvider({ provider: "openai" });
|
|
36
|
+
const codex = createCodexProvider();
|
|
37
|
+
const gemini = createGeminiProvider();
|
|
38
|
+
const xai = createXAIProvider({ tool: "web_search" });
|
|
39
|
+
const xaiX = createXAIProvider({ tool: "x_search" });
|
|
40
|
+
const anthropic = createAnthropicProvider();
|
|
41
|
+
const meta = createMetaProvider();
|
|
42
|
+
const exaKey = process.env.EXA_API_KEY;
|
|
43
|
+
const parallelKey = process.env.PARALLEL_API_KEY;
|
|
44
|
+
const xToken = process.env.X_API_BEARER_TOKEN;
|
|
45
|
+
const exa = createExaProvider({ apiKey: exaKey });
|
|
46
|
+
const parallel = createParallelProvider({ apiKey: parallelKey });
|
|
47
|
+
const x = createXProvider({ bearerToken: xToken });
|
|
48
|
+
// Opt-in answer-synthesis providers: the enable gate alone controls
|
|
49
|
+
// registration so each failure names its actual missing piece. A gate
|
|
50
|
+
// without a key dispatches to the adapter, which reports the missing key;
|
|
51
|
+
// a key without the gate never registers the provider at all.
|
|
52
|
+
const parallelResponsesEnabled = process.env.PI_SEARCH_ENABLE_PARALLEL_RESPONSES === "1";
|
|
53
|
+
const braveAnswersEnabled = process.env.PI_SEARCH_ENABLE_BRAVE_ANSWERS === "1";
|
|
54
|
+
const parallelResponses = createParallelResponsesProvider({ apiKey: parallelKey });
|
|
55
|
+
const braveAnswers = createBraveAnswersProvider({ apiKey: braveKey });
|
|
56
|
+
const billingPolicy = process.env.PI_SEARCH_ALLOW_METERED === "1"
|
|
57
|
+
? "allow-configured-metered"
|
|
58
|
+
: process.env.PI_SEARCH_PREFER_FREE === "1" ? "prefer-free" : "free-only";
|
|
59
|
+
const braveFreeCapacityConfigured = braveFreeOnly;
|
|
60
|
+
const route = createSearchRouter({
|
|
61
|
+
openai,
|
|
62
|
+
openaiCodex: codex,
|
|
63
|
+
gemini,
|
|
64
|
+
xai,
|
|
65
|
+
xaiX,
|
|
66
|
+
anthropic,
|
|
67
|
+
meta,
|
|
68
|
+
exa,
|
|
69
|
+
parallel,
|
|
70
|
+
parallelResponses,
|
|
71
|
+
braveAnswers,
|
|
72
|
+
x,
|
|
73
|
+
brave,
|
|
74
|
+
braveConfigured: braveKey !== undefined && braveKey.trim().length > 0,
|
|
75
|
+
exaConfigured: exaKey !== undefined && exaKey.trim().length > 0,
|
|
76
|
+
parallelConfigured: parallelKey !== undefined && parallelKey.trim().length > 0,
|
|
77
|
+
parallelResponsesConfigured: parallelResponsesEnabled,
|
|
78
|
+
braveAnswersConfigured: braveAnswersEnabled,
|
|
79
|
+
xConfigured: xToken !== undefined && xToken.trim().length > 0,
|
|
80
|
+
braveFreeCapacityConfigured,
|
|
81
|
+
braveCapacity,
|
|
82
|
+
billingPolicy,
|
|
83
|
+
});
|
|
84
|
+
// Expose only provider hints this installation can actually dispatch so
|
|
85
|
+
// the tool schemas never advertise options whose keys or gates are absent.
|
|
86
|
+
const availableHints = availableProviderHints({
|
|
87
|
+
brave: braveKey !== undefined && braveKey.trim().length > 0,
|
|
88
|
+
braveAnswers: braveAnswersEnabled,
|
|
89
|
+
exa: exaKey !== undefined && exaKey.trim().length > 0,
|
|
90
|
+
parallel: parallelKey !== undefined && parallelKey.trim().length > 0,
|
|
91
|
+
parallelResponses: parallelResponsesEnabled,
|
|
92
|
+
x: xToken !== undefined && xToken.trim().length > 0,
|
|
93
|
+
});
|
|
94
|
+
pi.on("before_provider_request", (event, context) => {
|
|
95
|
+
if (!isLocalOpenAICompatibleModel(context.model)) return;
|
|
96
|
+
return adaptLocalLlamaPayload(event.payload);
|
|
97
|
+
});
|
|
98
|
+
registerWebSearch(pi, route, { availableProviderHints: availableHints });
|
|
99
|
+
registerWebFetch(pi);
|
|
100
|
+
registerWebResearch(pi, route, { availableProviderHints: availableHints });
|
|
101
|
+
}
|