@nijaru/pi-search 0.0.1 → 0.0.3
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 +44 -9
- package/package.json +6 -6
- package/src/anthropic-auth.ts +108 -0
- package/src/anthropic.ts +64 -48
- package/src/brave-answers.ts +9 -2
- package/src/brave.ts +9 -6
- package/src/cloudflare.ts +175 -0
- package/src/codex.ts +4 -4
- package/src/contracts.ts +21 -3
- package/src/errors.ts +15 -1
- package/src/gemini.ts +10 -3
- package/src/grounding.ts +17 -3
- package/src/index.ts +17 -5
- package/src/model-selection.ts +23 -5
- package/src/openai.ts +32 -35
- package/src/parallel-responses.ts +1 -0
- package/src/parallel.ts +4 -1
- package/src/provider-availability.ts +2 -0
- package/src/provider-http.ts +3 -2
- package/src/research-tool.ts +47 -30
- package/src/router.ts +10 -2
- package/src/search-cleanup.ts +7 -6
- package/src/search-tool.ts +38 -8
- package/src/search.ts +2 -1
- package/src/x.ts +2 -2
- package/src/xai.ts +1 -1
- package/src/local-llama-compat.ts +0 -134
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Provider,
|
|
3
|
+
ProviderCapabilities,
|
|
4
|
+
ProviderContext,
|
|
5
|
+
ProviderProfile,
|
|
6
|
+
SearchOption,
|
|
7
|
+
SearchRequest,
|
|
8
|
+
SearchResponse,
|
|
9
|
+
SearchResult,
|
|
10
|
+
SearchWarning,
|
|
11
|
+
} from "./contracts";
|
|
12
|
+
import { createProviderError } from "./errors";
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_SEARCH_PROVIDER_RESPONSE_BYTES,
|
|
15
|
+
objectValue,
|
|
16
|
+
optionalString,
|
|
17
|
+
postJson,
|
|
18
|
+
type JsonResponse,
|
|
19
|
+
type SearchHttpFetch,
|
|
20
|
+
} from "./provider-http";
|
|
21
|
+
import { validateSearchRequest } from "./search";
|
|
22
|
+
import { normalizeSearchUrl } from "./search-cleanup";
|
|
23
|
+
|
|
24
|
+
export type CloudflareUpstream = "ceramic" | "exa" | "linkup";
|
|
25
|
+
|
|
26
|
+
export interface CloudflareAdapterOptions {
|
|
27
|
+
/** Required even though Cloudflare itself defaults to Ceramic. */
|
|
28
|
+
readonly upstream: CloudflareUpstream;
|
|
29
|
+
readonly accountId?: string;
|
|
30
|
+
readonly apiToken?: string;
|
|
31
|
+
readonly gatewayId?: string;
|
|
32
|
+
/** An explicit alias makes Cloudflare fail rather than fall back to gateway credits. */
|
|
33
|
+
readonly byokAlias?: string;
|
|
34
|
+
readonly fetchImpl?: SearchHttpFetch;
|
|
35
|
+
readonly maxResponseBytes?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const capabilities: ProviderCapabilities = { semantic: true, excerpts: true };
|
|
39
|
+
// BYOK agreements and gateway credits do not establish actual billed costs.
|
|
40
|
+
const profile: ProviderProfile = { auth: "environment", costModel: "unknown" };
|
|
41
|
+
const unsupportedOptions = [
|
|
42
|
+
"domains", "dateRange", "social", "executionModel", "searchContextSize",
|
|
43
|
+
"returnTokenBudget", "externalWebAccess", "userLocation", "searchContentTypes", "imageSettings",
|
|
44
|
+
] as const satisfies readonly SearchOption[];
|
|
45
|
+
|
|
46
|
+
function configurationError(message: string, kind: "auth" | "badRequest" = "badRequest"): never {
|
|
47
|
+
throw createProviderError({ provider: "cloudflare", kind, message, retryable: false });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function requiredSetting(value: string | undefined, name: string, kind: "auth" | "badRequest"): string {
|
|
51
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
52
|
+
return configurationError(`Cloudflare ${name} is not configured`, kind);
|
|
53
|
+
}
|
|
54
|
+
return value.trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function malformed(message: string): never {
|
|
58
|
+
throw createProviderError({ provider: "cloudflare", kind: "malformed", message: `Cloudflare returned a malformed response (${message})`, retryable: false });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
62
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeResponse(result: JsonResponse, request: SearchRequest, upstream: CloudflareUpstream): SearchResponse {
|
|
66
|
+
const root = objectValue(result.payload, "response", "cloudflare");
|
|
67
|
+
if (!Array.isArray(root.items)) return malformed("items is not an array");
|
|
68
|
+
const metadata = isRecord(root.metadata) ? root.metadata : {};
|
|
69
|
+
const query = request.query;
|
|
70
|
+
const results: SearchResult[] = [];
|
|
71
|
+
let discarded = 0;
|
|
72
|
+
for (const item of root.items) {
|
|
73
|
+
const parsed = isRecord(item) ? normalizeSearchUrl(item.url) : undefined;
|
|
74
|
+
if (!isRecord(item) || parsed === undefined) {
|
|
75
|
+
discarded += 1;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
results.push({
|
|
79
|
+
...parsed,
|
|
80
|
+
title: optionalString(item.title, 500),
|
|
81
|
+
excerpt: optionalString(item.description, 4_000),
|
|
82
|
+
// Keep both the gateway service and the selected upstream inspectable.
|
|
83
|
+
provider: "cloudflare",
|
|
84
|
+
upstreamProvider: upstream,
|
|
85
|
+
searchQuery: query,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (discarded > 0 && results.length === 0) return malformed("items contained no parseable HTTP URLs");
|
|
89
|
+
const requestId = result.requestId ?? optionalString(metadata.requestId, 500);
|
|
90
|
+
const latencyMs = typeof metadata.latencyMs === "number" && Number.isFinite(metadata.latencyMs) && metadata.latencyMs >= 0 ? metadata.latencyMs : undefined;
|
|
91
|
+
// imageUrl/faviconUrl have no evidence-result contract; lastModifiedDate is
|
|
92
|
+
// not a publication date. Do not mislabel these optional documented fields.
|
|
93
|
+
return {
|
|
94
|
+
query,
|
|
95
|
+
results: results.slice(0, request.maxResults ?? 10),
|
|
96
|
+
provider: "cloudflare",
|
|
97
|
+
upstreamProvider: upstream,
|
|
98
|
+
appliedOptions: ["maxResults"],
|
|
99
|
+
warnings: discarded > 0 ? [{ code: "partial-results", message: `Cloudflare discarded ${discarded} malformed result entr${discarded === 1 ? "y" : "ies"}` }] : [],
|
|
100
|
+
...(requestId === undefined ? {} : { requestId }),
|
|
101
|
+
...(latencyMs === undefined ? {} : { latencyMs }),
|
|
102
|
+
...(result.rateLimits === undefined ? {} : { usage: { rateLimits: result.rateLimits } }),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class CloudflareProvider implements Provider {
|
|
107
|
+
readonly id = "cloudflare" as const;
|
|
108
|
+
readonly capabilities = capabilities;
|
|
109
|
+
readonly profile = profile;
|
|
110
|
+
private readonly upstream: CloudflareUpstream;
|
|
111
|
+
private readonly accountId?: string;
|
|
112
|
+
private readonly apiToken?: string;
|
|
113
|
+
private readonly gatewayId: string;
|
|
114
|
+
private readonly byokAlias?: string;
|
|
115
|
+
private readonly fetchImpl: SearchHttpFetch;
|
|
116
|
+
private readonly maxResponseBytes: number;
|
|
117
|
+
|
|
118
|
+
constructor(options: CloudflareAdapterOptions) {
|
|
119
|
+
this.upstream = options.upstream;
|
|
120
|
+
this.accountId = options.accountId;
|
|
121
|
+
this.apiToken = options.apiToken;
|
|
122
|
+
this.gatewayId = options.gatewayId ?? "default";
|
|
123
|
+
this.byokAlias = options.byokAlias;
|
|
124
|
+
this.fetchImpl = options.fetchImpl ?? (fetch as SearchHttpFetch);
|
|
125
|
+
this.maxResponseBytes = options.maxResponseBytes ?? DEFAULT_SEARCH_PROVIDER_RESPONSE_BYTES;
|
|
126
|
+
if (!Number.isInteger(this.maxResponseBytes) || this.maxResponseBytes < 1) {
|
|
127
|
+
throw new Error("Cloudflare maxResponseBytes must be a positive integer");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async search(request: SearchRequest, signal: AbortSignal, _context: ProviderContext): Promise<SearchResponse> {
|
|
132
|
+
const normalized = validateSearchRequest(request);
|
|
133
|
+
if (normalized.query.length > 1_024) return configurationError("Cloudflare search query must be at most 1024 characters");
|
|
134
|
+
if ((normalized.maxResults ?? 10) > 10) return configurationError("Cloudflare search maxResults must be at most 10");
|
|
135
|
+
for (const option of unsupportedOptions) {
|
|
136
|
+
if (normalized[option] !== undefined) {
|
|
137
|
+
throw createProviderError({ provider: this.id, kind: "unsupported", message: `Cloudflare Web Search does not support ${option}`, retryable: false });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!["ceramic", "exa", "linkup"].includes(this.upstream)) {
|
|
141
|
+
return configurationError("Cloudflare upstream must be explicitly configured as ceramic, exa, or linkup");
|
|
142
|
+
}
|
|
143
|
+
const accountId = requiredSetting(this.accountId, "account ID", "auth");
|
|
144
|
+
const apiToken = requiredSetting(this.apiToken, "API token", "auth");
|
|
145
|
+
const gatewayId = requiredSetting(this.gatewayId, "gateway ID", "badRequest");
|
|
146
|
+
if (this.byokAlias !== undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(this.byokAlias)) {
|
|
147
|
+
return configurationError("Cloudflare byokAlias must contain 1-64 letters, digits, underscores, or hyphens");
|
|
148
|
+
}
|
|
149
|
+
const warnings: SearchWarning[] = normalized.mode === "keyword" || normalized.mode === "fresh"
|
|
150
|
+
? [{ code: "unsupported-option", option: "mode", message: `Cloudflare Web Search does not guarantee ${normalized.mode === "keyword" ? "keyword-only ranking" : "freshness"}; the configured upstream uses its standard retrieval mode` }]
|
|
151
|
+
: [];
|
|
152
|
+
const result = await postJson({
|
|
153
|
+
provider: this.id,
|
|
154
|
+
url: `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/ai/websearch/`,
|
|
155
|
+
headers: { authorization: `Bearer ${apiToken}` },
|
|
156
|
+
body: {
|
|
157
|
+
query: normalized.query,
|
|
158
|
+
provider: this.upstream,
|
|
159
|
+
limit: normalized.maxResults,
|
|
160
|
+
options: { gateway: { id: gatewayId } },
|
|
161
|
+
...(this.byokAlias === undefined ? {} : { byokAlias: this.byokAlias }),
|
|
162
|
+
},
|
|
163
|
+
signal,
|
|
164
|
+
fetchImpl: this.fetchImpl,
|
|
165
|
+
maxResponseBytes: this.maxResponseBytes,
|
|
166
|
+
});
|
|
167
|
+
const response = normalizeResponse(result, normalized, this.upstream);
|
|
168
|
+
return { ...response, warnings: [...warnings, ...response.warnings] };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Register only for explicitly opted-in calls; this factory performs no I/O or global credential lookup. */
|
|
173
|
+
export function createCloudflareProvider(options: CloudflareAdapterOptions): CloudflareProvider {
|
|
174
|
+
return new CloudflareProvider(options);
|
|
175
|
+
}
|
package/src/codex.ts
CHANGED
|
@@ -252,17 +252,17 @@ function normalizeCodexResponse(payload: unknown, request: SearchRequest): Searc
|
|
|
252
252
|
|
|
253
253
|
function codexHeaders(execution: ModelExecution): Readonly<Record<string, string>> {
|
|
254
254
|
const headers = modelAuthHeaders(execution);
|
|
255
|
-
if (
|
|
256
|
-
throw createProviderError({ provider: "openai-codex", kind: "auth", message: "Codex authentication returned no token", retryable: false });
|
|
255
|
+
if (!headers.get("authorization")?.trim()) {
|
|
256
|
+
throw createProviderError({ provider: "openai-codex", kind: "auth", fallbackSafe: true, message: "Codex authentication returned no token", retryable: false });
|
|
257
257
|
}
|
|
258
|
-
const parts =
|
|
258
|
+
const parts = headers.get("authorization")!.replace(/^Bearer\s+/i, "").split(".");
|
|
259
259
|
if (parts.length === 3 && parts[1] !== undefined) {
|
|
260
260
|
try {
|
|
261
261
|
const decoded = JSON.parse(Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(parts[1].length / 4) * 4, "="), "base64").toString("utf8")) as Record<string, unknown>;
|
|
262
262
|
const auth = decoded["https://api.openai.com/auth"];
|
|
263
263
|
if (auth !== null && typeof auth === "object" && !Array.isArray(auth)) {
|
|
264
264
|
const accountId = optionalString((auth as Record<string, unknown>).chatgpt_account_id, 500);
|
|
265
|
-
if (accountId !== undefined) headers.set("chatgpt-account-id", accountId);
|
|
265
|
+
if (accountId !== undefined && !headers.has("chatgpt-account-id")) headers.set("chatgpt-account-id", accountId);
|
|
266
266
|
}
|
|
267
267
|
} catch {
|
|
268
268
|
// The backend may authenticate non-JWT tokens through headers alone.
|
package/src/contracts.ts
CHANGED
|
@@ -55,6 +55,10 @@ export type ProviderAuthResult =
|
|
|
55
55
|
readonly ok: true;
|
|
56
56
|
readonly apiKey?: string;
|
|
57
57
|
readonly headers?: ProviderHeaders;
|
|
58
|
+
/** Provider-resolved endpoint override; wins over the catalog `baseUrl`. */
|
|
59
|
+
readonly baseUrl?: string;
|
|
60
|
+
/** Provider-scoped environment credentials resolved with the request. */
|
|
61
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
58
62
|
}
|
|
59
63
|
| {
|
|
60
64
|
readonly ok: false;
|
|
@@ -148,6 +152,8 @@ export interface ProviderCapabilities {
|
|
|
148
152
|
readonly excerpts?: boolean;
|
|
149
153
|
/** Supports domain include/exclude filters. */
|
|
150
154
|
readonly domainFilter?: boolean;
|
|
155
|
+
/** False when includes and excludes are individually supported but cannot be combined. */
|
|
156
|
+
readonly combinedDomainFilters?: boolean;
|
|
151
157
|
/** Supports provider-enforced date ranges. */
|
|
152
158
|
readonly dateFilter?: boolean;
|
|
153
159
|
/** Searches social sources such as X. */
|
|
@@ -339,8 +345,10 @@ export interface SearchResult {
|
|
|
339
345
|
* when the provider exposes any text; never synthesized.
|
|
340
346
|
*/
|
|
341
347
|
readonly excerpt?: string;
|
|
342
|
-
/**
|
|
348
|
+
/** Adapter that served this result. */
|
|
343
349
|
readonly provider: ProviderId;
|
|
350
|
+
/** Search backend behind a gateway adapter, when applicable. */
|
|
351
|
+
readonly upstreamProvider?: ProviderId;
|
|
344
352
|
/** The query that produced this result. */
|
|
345
353
|
readonly searchQuery: string;
|
|
346
354
|
/** Provider-internal id for the result, for citation/debugging. */
|
|
@@ -364,8 +372,10 @@ export interface SearchResponse {
|
|
|
364
372
|
readonly answer?: SearchAnswer;
|
|
365
373
|
/** Bounded source pages fetched after search when requested. */
|
|
366
374
|
readonly sourceContents?: readonly FetchedContent[];
|
|
367
|
-
/** Which
|
|
375
|
+
/** Which adapter actually served the request. */
|
|
368
376
|
readonly provider: ProviderId;
|
|
377
|
+
/** Search backend behind a gateway adapter, when applicable. */
|
|
378
|
+
readonly upstreamProvider?: ProviderId;
|
|
369
379
|
/** Model used by a model-mediated provider, when available. */
|
|
370
380
|
readonly executionModel?: string;
|
|
371
381
|
/** Providers attempted by bounded automatic routing, in order. */
|
|
@@ -415,8 +425,10 @@ export type ResearchStopReason = "completed" | "partial" | "budget" | "deadline"
|
|
|
415
425
|
|
|
416
426
|
export interface ResearchResponse {
|
|
417
427
|
readonly question: string;
|
|
418
|
-
/** The single
|
|
428
|
+
/** The single adapter selected for the whole research invocation. */
|
|
419
429
|
readonly provider: ProviderId;
|
|
430
|
+
/** Search backend behind a gateway adapter, when applicable. */
|
|
431
|
+
readonly upstreamProvider?: ProviderId;
|
|
420
432
|
/** Model used when the selected provider is model-mediated. */
|
|
421
433
|
readonly executionModel?: string;
|
|
422
434
|
readonly results: readonly SearchResult[];
|
|
@@ -554,6 +566,7 @@ export type ProviderId =
|
|
|
554
566
|
| "gemini"
|
|
555
567
|
| "brave"
|
|
556
568
|
| "exa"
|
|
569
|
+
| "cloudflare"
|
|
557
570
|
| "parallel"
|
|
558
571
|
| "parallel-responses"
|
|
559
572
|
| "brave-answers"
|
|
@@ -571,6 +584,7 @@ export const SEARCH_PROVIDER_HINT_IDS = [
|
|
|
571
584
|
"gemini",
|
|
572
585
|
"brave",
|
|
573
586
|
"exa",
|
|
587
|
+
"cloudflare",
|
|
574
588
|
"parallel",
|
|
575
589
|
"parallel-responses",
|
|
576
590
|
"brave-answers",
|
|
@@ -609,6 +623,10 @@ export interface ProviderError extends Error {
|
|
|
609
623
|
readonly retryAfterMs?: number;
|
|
610
624
|
/** Provider-reported quota metadata. */
|
|
611
625
|
readonly rateLimits?: ProviderRateLimitInfo;
|
|
626
|
+
/** Usage already reported even when a dispatched call fails. */
|
|
627
|
+
readonly usage?: ProviderUsage;
|
|
628
|
+
/** Explicit proof that another provider cannot compound uncertain billing. */
|
|
629
|
+
readonly fallbackSafe?: boolean;
|
|
612
630
|
/** Whether retrying the same provider could help. */
|
|
613
631
|
readonly retryable: boolean;
|
|
614
632
|
}
|
package/src/errors.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ProviderError, ProviderId, ProviderRateLimitInfo, RequestId } from "./contracts";
|
|
1
|
+
import type { ProviderError, ProviderId, ProviderRateLimitInfo, ProviderUsage, RequestId } from "./contracts";
|
|
2
2
|
|
|
3
3
|
export interface ProviderErrorOptions {
|
|
4
4
|
readonly provider: ProviderId;
|
|
@@ -9,6 +9,8 @@ export interface ProviderErrorOptions {
|
|
|
9
9
|
readonly requestId?: RequestId;
|
|
10
10
|
readonly retryAfterMs?: number;
|
|
11
11
|
readonly rateLimits?: ProviderRateLimitInfo;
|
|
12
|
+
readonly fallbackSafe?: boolean;
|
|
13
|
+
readonly usage?: ProviderUsage;
|
|
12
14
|
readonly cause?: unknown;
|
|
13
15
|
}
|
|
14
16
|
|
|
@@ -21,6 +23,8 @@ export class SearchProviderError extends Error implements ProviderError {
|
|
|
21
23
|
readonly requestId?: RequestId;
|
|
22
24
|
readonly retryAfterMs?: number;
|
|
23
25
|
readonly rateLimits?: ProviderRateLimitInfo;
|
|
26
|
+
readonly fallbackSafe: boolean;
|
|
27
|
+
readonly usage?: ProviderUsage;
|
|
24
28
|
|
|
25
29
|
constructor(options: ProviderErrorOptions) {
|
|
26
30
|
super(options.message, { cause: options.cause });
|
|
@@ -32,6 +36,8 @@ export class SearchProviderError extends Error implements ProviderError {
|
|
|
32
36
|
this.requestId = options.requestId;
|
|
33
37
|
this.retryAfterMs = options.retryAfterMs;
|
|
34
38
|
this.rateLimits = options.rateLimits;
|
|
39
|
+
this.fallbackSafe = options.fallbackSafe === true;
|
|
40
|
+
this.usage = options.usage;
|
|
35
41
|
}
|
|
36
42
|
}
|
|
37
43
|
|
|
@@ -81,6 +87,8 @@ export class SearchToolError extends Error {
|
|
|
81
87
|
readonly requestId?: RequestId;
|
|
82
88
|
readonly retryAfterMs?: number;
|
|
83
89
|
readonly rateLimits?: ProviderRateLimitInfo;
|
|
90
|
+
readonly fallbackSafe: boolean;
|
|
91
|
+
readonly usage?: ProviderUsage;
|
|
84
92
|
|
|
85
93
|
constructor(
|
|
86
94
|
code: SearchToolErrorCode,
|
|
@@ -93,6 +101,8 @@ export class SearchToolError extends Error {
|
|
|
93
101
|
readonly requestId?: RequestId;
|
|
94
102
|
readonly retryAfterMs?: number;
|
|
95
103
|
readonly rateLimits?: ProviderRateLimitInfo;
|
|
104
|
+
readonly fallbackSafe?: boolean;
|
|
105
|
+
readonly usage?: ProviderUsage;
|
|
96
106
|
} = {},
|
|
97
107
|
) {
|
|
98
108
|
super(`${code}: ${message}`);
|
|
@@ -105,6 +115,8 @@ export class SearchToolError extends Error {
|
|
|
105
115
|
this.requestId = options.requestId;
|
|
106
116
|
this.retryAfterMs = options.retryAfterMs;
|
|
107
117
|
this.rateLimits = options.rateLimits;
|
|
118
|
+
this.fallbackSafe = options.fallbackSafe === true;
|
|
119
|
+
this.usage = options.usage;
|
|
108
120
|
}
|
|
109
121
|
}
|
|
110
122
|
|
|
@@ -154,6 +166,8 @@ export function toSearchToolError(error: unknown, provider: ProviderId): SearchT
|
|
|
154
166
|
requestId: error.requestId,
|
|
155
167
|
retryAfterMs: error.retryAfterMs,
|
|
156
168
|
rateLimits: error.rateLimits,
|
|
169
|
+
fallbackSafe: error.fallbackSafe,
|
|
170
|
+
usage: error.usage,
|
|
157
171
|
});
|
|
158
172
|
}
|
|
159
173
|
const message = error instanceof Error ? error.message : "Unknown search failure";
|
package/src/gemini.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { createProviderError } from "./errors";
|
|
|
14
14
|
import { cancelResponseBody } from "./http";
|
|
15
15
|
import { executeGroundedSearch } from "./grounding";
|
|
16
16
|
import { httpSource, objectValue, optionalString, type SearchHttpFetch } from "./provider-http";
|
|
17
|
-
import { modelAuthHeaders, type ModelExecution } from "./model-selection";
|
|
17
|
+
import { hasExplicitHeader, modelAuthHeaders, type ModelExecution } from "./model-selection";
|
|
18
18
|
import { validateSearchRequest } from "./search";
|
|
19
19
|
|
|
20
20
|
export const GEMINI_GENERATE_CONTENT_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta";
|
|
@@ -88,9 +88,16 @@ function endpointFor(model: ProviderModel, override?: string): string {
|
|
|
88
88
|
|
|
89
89
|
function authHeaders(execution: ModelExecution): Readonly<Record<string, string>> {
|
|
90
90
|
const headers = modelAuthHeaders(execution, { bearerApiKey: false });
|
|
91
|
-
|
|
91
|
+
// An explicit x-goog-api-key (value or null deletion) wins over a derived key.
|
|
92
|
+
if (
|
|
93
|
+
execution.auth.apiKey !== undefined &&
|
|
94
|
+
execution.auth.apiKey.trim().length > 0 &&
|
|
95
|
+
!hasExplicitHeader([execution.model.headers, execution.auth.headers], "x-goog-api-key")
|
|
96
|
+
) {
|
|
97
|
+
headers.set("x-goog-api-key", execution.auth.apiKey);
|
|
98
|
+
}
|
|
92
99
|
if (!headers.has("x-goog-api-key")) {
|
|
93
|
-
throw createProviderError({ provider: "gemini", kind: "auth", message: "Gemini authentication returned no API key", retryable: false });
|
|
100
|
+
throw createProviderError({ provider: "gemini", kind: "auth", fallbackSafe: true, message: "Gemini authentication returned no API key", retryable: false });
|
|
94
101
|
}
|
|
95
102
|
return Object.fromEntries(headers.entries());
|
|
96
103
|
}
|
package/src/grounding.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Usage } from "@earendil-works/pi-ai";
|
|
1
2
|
import type {
|
|
2
3
|
ProviderContext,
|
|
3
4
|
ProviderId,
|
|
@@ -52,6 +53,19 @@ export function tokenUsage(
|
|
|
52
53
|
};
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/** Report known nested usage to Pi; unknown token/cost categories stay zero. */
|
|
57
|
+
export function toolUsage(usage: ProviderUsage | undefined): Usage | undefined {
|
|
58
|
+
if (usage === undefined || (usage.inputTokens === undefined && usage.outputTokens === undefined && usage.totalTokens === undefined && usage.costUsd === undefined)) return undefined;
|
|
59
|
+
return {
|
|
60
|
+
input: usage.inputTokens ?? 0,
|
|
61
|
+
output: usage.outputTokens ?? 0,
|
|
62
|
+
cacheRead: 0,
|
|
63
|
+
cacheWrite: 0,
|
|
64
|
+
totalTokens: usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
|
|
65
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: usage.costUsd ?? 0 },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
55
69
|
/** Merge provider-reported usage with observed transport rate limits. */
|
|
56
70
|
export function usageWithRateLimits(
|
|
57
71
|
usage: ProviderUsage | undefined,
|
|
@@ -85,7 +99,7 @@ export function assertHttpEndpoint(candidate: string, provider: ProviderId, labe
|
|
|
85
99
|
export function bearerAuthHeaders(execution: ModelExecution, provider: ProviderId): Readonly<Record<string, string>> {
|
|
86
100
|
const headers = modelAuthHeaders(execution);
|
|
87
101
|
if (!headers.has("authorization")) {
|
|
88
|
-
throw createProviderError({ provider, kind: "auth", message: "Model authentication returned no authorization header", retryable: false });
|
|
102
|
+
throw createProviderError({ provider, kind: "auth", fallbackSafe: true, message: "Model authentication returned no authorization header", retryable: false });
|
|
89
103
|
}
|
|
90
104
|
return Object.fromEntries(headers.entries());
|
|
91
105
|
}
|
|
@@ -105,7 +119,7 @@ export interface GroundedSearchOptions {
|
|
|
105
119
|
*/
|
|
106
120
|
readonly includeModel?: boolean;
|
|
107
121
|
readonly endpointFor: (model: ProviderModel) => string;
|
|
108
|
-
readonly headersFor: (execution: ModelExecution) => Readonly<Record<string, string
|
|
122
|
+
readonly headersFor: (execution: ModelExecution, signal: AbortSignal) => Readonly<Record<string, string>> | Promise<Readonly<Record<string, string>>>;
|
|
109
123
|
readonly plan: GroundingPlan;
|
|
110
124
|
readonly normalize: (
|
|
111
125
|
payload: unknown,
|
|
@@ -145,7 +159,7 @@ export async function executeGroundedSearch(options: GroundedSearchOptions): Pro
|
|
|
145
159
|
const result = await postJson({
|
|
146
160
|
provider: options.provider,
|
|
147
161
|
url: options.endpointFor(execution.model),
|
|
148
|
-
headers: options.headersFor(execution),
|
|
162
|
+
headers: await options.headersFor(execution, options.signal),
|
|
149
163
|
body: options.includeModel === false ? options.plan.body : { ...options.plan.body, model: execution.model.id },
|
|
150
164
|
signal: options.signal,
|
|
151
165
|
fetchImpl: options.fetchImpl,
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { createBraveProvider, BraveQuotaTracker } from "./brave";
|
|
|
4
4
|
import { createBraveAnswersProvider } from "./brave-answers";
|
|
5
5
|
import { createCodexProvider } from "./codex";
|
|
6
6
|
import { createExaProvider } from "./exa";
|
|
7
|
+
import { createCloudflareProvider, type CloudflareUpstream } from "./cloudflare";
|
|
7
8
|
import { createGeminiProvider } from "./gemini";
|
|
8
9
|
import { registerWebFetch } from "./fetch-tool";
|
|
9
10
|
import { createOpenAIProvider } from "./openai";
|
|
@@ -13,7 +14,6 @@ import { createMetaProvider } from "./meta";
|
|
|
13
14
|
import { createSearchRouter } from "./router";
|
|
14
15
|
import { createXProvider } from "./x";
|
|
15
16
|
import { createXAIProvider } from "./xai";
|
|
16
|
-
import { adaptLocalLlamaPayload, isLocalOpenAICompatibleModel } from "./local-llama-compat";
|
|
17
17
|
import { availableProviderHints } from "./provider-availability";
|
|
18
18
|
import { registerWebResearch } from "./research-tool";
|
|
19
19
|
import { registerWebSearch } from "./search-tool";
|
|
@@ -43,6 +43,19 @@ export default function (pi: ExtensionAPI): void {
|
|
|
43
43
|
const parallelKey = process.env.PARALLEL_API_KEY;
|
|
44
44
|
const xToken = process.env.X_API_BEARER_TOKEN;
|
|
45
45
|
const exa = createExaProvider({ apiKey: exaKey });
|
|
46
|
+
const cloudflareUpstream = process.env.PI_SEARCH_CLOUDFLARE_PROVIDER;
|
|
47
|
+
const cloudflareAccount = process.env.CLOUDFLARE_ACCOUNT_ID;
|
|
48
|
+
const cloudflareToken = process.env.CLOUDFLARE_API_TOKEN;
|
|
49
|
+
// Credentials alone do not enable gateway spending or select Ceramic.
|
|
50
|
+
const cloudflareConfigured = ["ceramic", "exa", "linkup"].includes(cloudflareUpstream ?? "")
|
|
51
|
+
&& Boolean(cloudflareAccount?.trim()) && Boolean(cloudflareToken?.trim());
|
|
52
|
+
const cloudflare = cloudflareConfigured ? createCloudflareProvider({
|
|
53
|
+
upstream: cloudflareUpstream as CloudflareUpstream,
|
|
54
|
+
accountId: cloudflareAccount,
|
|
55
|
+
apiToken: cloudflareToken,
|
|
56
|
+
gatewayId: process.env.PI_SEARCH_CLOUDFLARE_GATEWAY_ID,
|
|
57
|
+
byokAlias: process.env.PI_SEARCH_CLOUDFLARE_BYOK_ALIAS,
|
|
58
|
+
}) : undefined;
|
|
46
59
|
const parallel = createParallelProvider({ apiKey: parallelKey });
|
|
47
60
|
const x = createXProvider({ bearerToken: xToken });
|
|
48
61
|
// Opt-in answer-synthesis providers: the enable gate alone controls
|
|
@@ -66,6 +79,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
66
79
|
anthropic,
|
|
67
80
|
meta,
|
|
68
81
|
exa,
|
|
82
|
+
cloudflare,
|
|
83
|
+
cloudflareConfigured,
|
|
69
84
|
parallel,
|
|
70
85
|
parallelResponses,
|
|
71
86
|
braveAnswers,
|
|
@@ -87,14 +102,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
87
102
|
brave: braveKey !== undefined && braveKey.trim().length > 0,
|
|
88
103
|
braveAnswers: braveAnswersEnabled,
|
|
89
104
|
exa: exaKey !== undefined && exaKey.trim().length > 0,
|
|
105
|
+
cloudflare: cloudflareConfigured,
|
|
90
106
|
parallel: parallelKey !== undefined && parallelKey.trim().length > 0,
|
|
91
107
|
parallelResponses: parallelResponsesEnabled,
|
|
92
108
|
x: xToken !== undefined && xToken.trim().length > 0,
|
|
93
109
|
});
|
|
94
|
-
pi.on("before_provider_request", (event, context) => {
|
|
95
|
-
if (!isLocalOpenAICompatibleModel(context.model)) return;
|
|
96
|
-
return adaptLocalLlamaPayload(event.payload);
|
|
97
|
-
});
|
|
98
110
|
registerWebSearch(pi, route, { availableProviderHints: availableHints });
|
|
99
111
|
registerWebFetch(pi);
|
|
100
112
|
registerWebResearch(pi, route, { availableProviderHints: availableHints });
|
package/src/model-selection.ts
CHANGED
|
@@ -54,7 +54,7 @@ function candidates(options: ModelSelectionOptions): ProviderModel[] {
|
|
|
54
54
|
export async function selectModelExecution(options: ModelSelectionOptions): Promise<ModelExecution> {
|
|
55
55
|
const registry = options.context.modelRegistry;
|
|
56
56
|
if (registry === undefined) {
|
|
57
|
-
throw createProviderError({ provider: options.searchProvider, kind: "auth", message: "Pi model authentication is unavailable", retryable: false });
|
|
57
|
+
throw createProviderError({ provider: options.searchProvider, kind: "auth", fallbackSafe: true, message: "Pi model authentication is unavailable", retryable: false });
|
|
58
58
|
}
|
|
59
59
|
const available = candidates(options);
|
|
60
60
|
const requested = options.request.executionModel;
|
|
@@ -104,12 +104,14 @@ async function authenticate(model: ProviderModel, registry: NonNullable<Provider
|
|
|
104
104
|
try {
|
|
105
105
|
auth = await registry.getApiKeyAndHeaders(model);
|
|
106
106
|
} catch (error) {
|
|
107
|
-
throw createProviderError({ provider: options.searchProvider, kind: "auth", message: "Pi model authentication could not be resolved", retryable: false, cause: error });
|
|
107
|
+
throw createProviderError({ provider: options.searchProvider, kind: "auth", fallbackSafe: true, message: "Pi model authentication could not be resolved", retryable: false, cause: error });
|
|
108
108
|
}
|
|
109
109
|
if (!auth.ok) {
|
|
110
|
-
throw createProviderError({ provider: options.searchProvider, kind: "auth", message: `Pi model authentication is not configured for ${model.id}`, retryable: false });
|
|
110
|
+
throw createProviderError({ provider: options.searchProvider, kind: "auth", fallbackSafe: true, message: `Pi model authentication is not configured for ${model.id}`, retryable: false });
|
|
111
111
|
}
|
|
112
|
-
|
|
112
|
+
// A provider-resolved endpoint wins over the catalog base URL, matching
|
|
113
|
+
// `ModelRegistry.stream()`; adapters build their request URL from this model.
|
|
114
|
+
return { model: auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model, auth };
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
export interface ModelAuthHeaderOptions {
|
|
@@ -125,10 +127,26 @@ export function applyProviderHeaders(headers: Headers, source: ProviderHeaders |
|
|
|
125
127
|
}
|
|
126
128
|
}
|
|
127
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Whether any source declares a header, including a `null` deletion. An explicit
|
|
132
|
+
* `authorization` entry must win over a bearer synthesized from the API key.
|
|
133
|
+
*/
|
|
134
|
+
export function hasExplicitHeader(sources: readonly (ProviderHeaders | undefined)[], name: string): boolean {
|
|
135
|
+
const lower = name.toLowerCase();
|
|
136
|
+
return sources.some((source) => source !== undefined && Object.keys(source).some((key) => key.toLowerCase() === lower));
|
|
137
|
+
}
|
|
138
|
+
|
|
128
139
|
export function modelAuthHeaders(execution: ModelExecution, options: ModelAuthHeaderOptions = {}): Headers {
|
|
129
140
|
const headers = new Headers();
|
|
130
141
|
applyProviderHeaders(headers, execution.model.headers);
|
|
131
142
|
applyProviderHeaders(headers, execution.auth.headers);
|
|
132
|
-
if (
|
|
143
|
+
if (
|
|
144
|
+
options.bearerApiKey !== false &&
|
|
145
|
+
execution.auth.apiKey !== undefined &&
|
|
146
|
+
execution.auth.apiKey.trim().length > 0 &&
|
|
147
|
+
!hasExplicitHeader([execution.model.headers, execution.auth.headers], "authorization")
|
|
148
|
+
) {
|
|
149
|
+
headers.set("authorization", `Bearer ${execution.auth.apiKey}`);
|
|
150
|
+
}
|
|
133
151
|
return headers;
|
|
134
152
|
}
|