@nijaru/pi-search 0.0.2 → 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 +5 -4
- package/src/anthropic-auth.ts +108 -0
- package/src/anthropic.ts +64 -55
- 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 +17 -3
- package/src/errors.ts +15 -1
- package/src/gemini.ts +1 -1
- package/src/grounding.ts +17 -3
- package/src/index.ts +17 -5
- package/src/model-selection.ts +3 -3
- package/src/openai.ts +23 -32
- 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 +31 -7
- 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
|
@@ -152,6 +152,8 @@ export interface ProviderCapabilities {
|
|
|
152
152
|
readonly excerpts?: boolean;
|
|
153
153
|
/** Supports domain include/exclude filters. */
|
|
154
154
|
readonly domainFilter?: boolean;
|
|
155
|
+
/** False when includes and excludes are individually supported but cannot be combined. */
|
|
156
|
+
readonly combinedDomainFilters?: boolean;
|
|
155
157
|
/** Supports provider-enforced date ranges. */
|
|
156
158
|
readonly dateFilter?: boolean;
|
|
157
159
|
/** Searches social sources such as X. */
|
|
@@ -343,8 +345,10 @@ export interface SearchResult {
|
|
|
343
345
|
* when the provider exposes any text; never synthesized.
|
|
344
346
|
*/
|
|
345
347
|
readonly excerpt?: string;
|
|
346
|
-
/**
|
|
348
|
+
/** Adapter that served this result. */
|
|
347
349
|
readonly provider: ProviderId;
|
|
350
|
+
/** Search backend behind a gateway adapter, when applicable. */
|
|
351
|
+
readonly upstreamProvider?: ProviderId;
|
|
348
352
|
/** The query that produced this result. */
|
|
349
353
|
readonly searchQuery: string;
|
|
350
354
|
/** Provider-internal id for the result, for citation/debugging. */
|
|
@@ -368,8 +372,10 @@ export interface SearchResponse {
|
|
|
368
372
|
readonly answer?: SearchAnswer;
|
|
369
373
|
/** Bounded source pages fetched after search when requested. */
|
|
370
374
|
readonly sourceContents?: readonly FetchedContent[];
|
|
371
|
-
/** Which
|
|
375
|
+
/** Which adapter actually served the request. */
|
|
372
376
|
readonly provider: ProviderId;
|
|
377
|
+
/** Search backend behind a gateway adapter, when applicable. */
|
|
378
|
+
readonly upstreamProvider?: ProviderId;
|
|
373
379
|
/** Model used by a model-mediated provider, when available. */
|
|
374
380
|
readonly executionModel?: string;
|
|
375
381
|
/** Providers attempted by bounded automatic routing, in order. */
|
|
@@ -419,8 +425,10 @@ export type ResearchStopReason = "completed" | "partial" | "budget" | "deadline"
|
|
|
419
425
|
|
|
420
426
|
export interface ResearchResponse {
|
|
421
427
|
readonly question: string;
|
|
422
|
-
/** The single
|
|
428
|
+
/** The single adapter selected for the whole research invocation. */
|
|
423
429
|
readonly provider: ProviderId;
|
|
430
|
+
/** Search backend behind a gateway adapter, when applicable. */
|
|
431
|
+
readonly upstreamProvider?: ProviderId;
|
|
424
432
|
/** Model used when the selected provider is model-mediated. */
|
|
425
433
|
readonly executionModel?: string;
|
|
426
434
|
readonly results: readonly SearchResult[];
|
|
@@ -558,6 +566,7 @@ export type ProviderId =
|
|
|
558
566
|
| "gemini"
|
|
559
567
|
| "brave"
|
|
560
568
|
| "exa"
|
|
569
|
+
| "cloudflare"
|
|
561
570
|
| "parallel"
|
|
562
571
|
| "parallel-responses"
|
|
563
572
|
| "brave-answers"
|
|
@@ -575,6 +584,7 @@ export const SEARCH_PROVIDER_HINT_IDS = [
|
|
|
575
584
|
"gemini",
|
|
576
585
|
"brave",
|
|
577
586
|
"exa",
|
|
587
|
+
"cloudflare",
|
|
578
588
|
"parallel",
|
|
579
589
|
"parallel-responses",
|
|
580
590
|
"brave-answers",
|
|
@@ -613,6 +623,10 @@ export interface ProviderError extends Error {
|
|
|
613
623
|
readonly retryAfterMs?: number;
|
|
614
624
|
/** Provider-reported quota metadata. */
|
|
615
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;
|
|
616
630
|
/** Whether retrying the same provider could help. */
|
|
617
631
|
readonly retryable: boolean;
|
|
618
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
|
@@ -97,7 +97,7 @@ function authHeaders(execution: ModelExecution): Readonly<Record<string, string>
|
|
|
97
97
|
headers.set("x-goog-api-key", execution.auth.apiKey);
|
|
98
98
|
}
|
|
99
99
|
if (!headers.has("x-goog-api-key")) {
|
|
100
|
-
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 });
|
|
101
101
|
}
|
|
102
102
|
return Object.fromEntries(headers.entries());
|
|
103
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,10 +104,10 @@ 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
113
|
// `ModelRegistry.stream()`; adapters build their request URL from this model.
|
package/src/openai.ts
CHANGED
|
@@ -16,7 +16,7 @@ import type {
|
|
|
16
16
|
import { createProviderError, isProviderError } from "./errors";
|
|
17
17
|
import { applyProviderHeaders, hasExplicitHeader } from "./model-selection";
|
|
18
18
|
import { cancelResponseBody, readBoundedResponseText } from "./http";
|
|
19
|
-
import { parseProviderRateLimits } from "./provider-http";
|
|
19
|
+
import { parseProviderRateLimits, retryAfterMsFromHeaders as parseRetryAfter } from "./provider-http";
|
|
20
20
|
import { validateSearchRequest } from "./search";
|
|
21
21
|
import { normalizeSearchUrl } from "./search-cleanup";
|
|
22
22
|
|
|
@@ -420,7 +420,7 @@ function searchModelCandidates(
|
|
|
420
420
|
async function selectSearchExecution(active: ProviderModel | undefined, registry: ProviderContext["modelRegistry"], request: SearchRequest): Promise<SearchExecution> {
|
|
421
421
|
const provider = "openai" as const;
|
|
422
422
|
if (registry === undefined) {
|
|
423
|
-
throw createProviderError({ provider, kind: "auth", message: "Pi model authentication is unavailable", retryable: false });
|
|
423
|
+
throw createProviderError({ provider, kind: "auth", fallbackSafe: true, message: "Pi model authentication is unavailable", retryable: false });
|
|
424
424
|
}
|
|
425
425
|
const candidates = searchModelCandidates(active, registry, request.executionModel);
|
|
426
426
|
const selectedCandidates = request.executionModel === undefined
|
|
@@ -442,19 +442,19 @@ async function selectSearchExecution(active: ProviderModel | undefined, registry
|
|
|
442
442
|
try {
|
|
443
443
|
auth = await registry.getApiKeyAndHeaders(selected);
|
|
444
444
|
} catch (error) {
|
|
445
|
-
throw createProviderError({ provider, kind: "auth", message: "Pi model authentication could not be resolved", retryable: false, cause: error });
|
|
445
|
+
throw createProviderError({ provider, kind: "auth", fallbackSafe: true, message: "Pi model authentication could not be resolved", retryable: false, cause: error });
|
|
446
446
|
}
|
|
447
447
|
// A provider-resolved endpoint wins over the catalog base URL; the adapter
|
|
448
448
|
// builds its request URL from this model. Mirrors selectModelExecution().
|
|
449
449
|
if (auth.ok) return { model: auth.baseUrl ? { ...selected, baseUrl: auth.baseUrl } : selected, auth };
|
|
450
450
|
if (request.executionModel !== undefined) {
|
|
451
|
-
throw createProviderError({ provider, kind: "auth", message: `Pi model authentication is not configured for ${selected.id}`, retryable: false });
|
|
451
|
+
throw createProviderError({ provider, kind: "auth", fallbackSafe: true, message: `Pi model authentication is not configured for ${selected.id}`, retryable: false });
|
|
452
452
|
}
|
|
453
453
|
}
|
|
454
454
|
|
|
455
455
|
throw createProviderError({
|
|
456
456
|
provider,
|
|
457
|
-
kind: "auth",
|
|
457
|
+
kind: "auth", fallbackSafe: true,
|
|
458
458
|
message: `No authenticated ${provider} Responses model is available for native search`,
|
|
459
459
|
retryable: false,
|
|
460
460
|
});
|
|
@@ -616,22 +616,6 @@ async function readBody(response: Response, signal: AbortSignal, maxBytes: numbe
|
|
|
616
616
|
}
|
|
617
617
|
}
|
|
618
618
|
|
|
619
|
-
function parseRetryAfter(headers: Headers): number | undefined {
|
|
620
|
-
const milliseconds = headers.get("retry-after-ms");
|
|
621
|
-
if (milliseconds !== null && /^\d+(?:\.\d+)?$/.test(milliseconds.trim())) {
|
|
622
|
-
const value = Number(milliseconds);
|
|
623
|
-
if (Number.isFinite(value)) return Math.max(0, Math.round(value));
|
|
624
|
-
}
|
|
625
|
-
const retryAfter = headers.get("retry-after");
|
|
626
|
-
if (retryAfter === null) return undefined;
|
|
627
|
-
if (/^\d+(?:\.\d+)?$/.test(retryAfter.trim())) {
|
|
628
|
-
const value = Number(retryAfter);
|
|
629
|
-
return Number.isFinite(value) ? Math.max(0, Math.round(value * 1_000)) : undefined;
|
|
630
|
-
}
|
|
631
|
-
const date = Date.parse(retryAfter);
|
|
632
|
-
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
|
|
633
|
-
}
|
|
634
|
-
|
|
635
619
|
function parseSseEventData(body: string, provider: OpenAIProviderId): readonly Record<string, unknown>[] {
|
|
636
620
|
const events: Record<string, unknown>[] = [];
|
|
637
621
|
let dataLines: string[] = [];
|
|
@@ -661,7 +645,7 @@ function parseSseEventData(body: string, provider: OpenAIProviderId): readonly R
|
|
|
661
645
|
return events;
|
|
662
646
|
}
|
|
663
647
|
|
|
664
|
-
function parseResponseBody(body: string, provider: OpenAIProviderId): Record<string, unknown> {
|
|
648
|
+
function parseResponseBody(body: string, provider: OpenAIProviderId, secrets: readonly string[]): Record<string, unknown> {
|
|
665
649
|
const trimmed = body.trim();
|
|
666
650
|
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
667
651
|
try {
|
|
@@ -682,7 +666,8 @@ function parseResponseBody(body: string, provider: OpenAIProviderId): Record<str
|
|
|
682
666
|
for (const event of parseSseEventData(body, provider)) {
|
|
683
667
|
const type = String(event.type ?? "");
|
|
684
668
|
if (type === "error") {
|
|
685
|
-
|
|
669
|
+
const diagnostic = sanitizeErrorDiagnostic(JSON.stringify(event), secrets);
|
|
670
|
+
throw createProviderError({ provider, kind: "http", message: `OpenAI web search returned an error event${diagnostic === undefined ? "" : `: ${diagnostic}`}`, retryable: false });
|
|
686
671
|
}
|
|
687
672
|
if (type === "response.output_item.done" && event.item !== undefined) output.push(event.item);
|
|
688
673
|
if (["response.done", "response.completed", "response.incomplete", "response.failed"].includes(type)) {
|
|
@@ -770,11 +755,7 @@ export class OpenAIProvider implements Provider {
|
|
|
770
755
|
throw createProviderError({ provider: this.id, kind: "canceled", message: "Search canceled", retryable: false });
|
|
771
756
|
}
|
|
772
757
|
|
|
773
|
-
const
|
|
774
|
-
...plan.body,
|
|
775
|
-
model: execution.model.id,
|
|
776
|
-
max_output_tokens: 2_048,
|
|
777
|
-
};
|
|
758
|
+
const endpoint = endpointFor(execution.model, this.endpoint);
|
|
778
759
|
const headers = new Headers();
|
|
779
760
|
applyProviderHeaders(headers, execution.model.headers);
|
|
780
761
|
applyProviderHeaders(headers, execution.auth.headers);
|
|
@@ -786,8 +767,17 @@ export class OpenAIProvider implements Provider {
|
|
|
786
767
|
headers.set("Authorization", `Bearer ${execution.auth.apiKey}`);
|
|
787
768
|
}
|
|
788
769
|
if (!headers.has("authorization")) {
|
|
789
|
-
throw createProviderError({ provider: this.id, kind: "auth", message: "Pi model authentication returned no authorization header", retryable: false });
|
|
770
|
+
throw createProviderError({ provider: this.id, kind: "auth", fallbackSafe: true, message: "Pi model authentication returned no authorization header", retryable: false });
|
|
790
771
|
}
|
|
772
|
+
// Pi's direct ChatGPT sign-in uses a non-sk bearer on the OpenAI API.
|
|
773
|
+
// That auth path rejects max_output_tokens; API keys and proxies do not.
|
|
774
|
+
const bearer = headers.get("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
775
|
+
const chatGPT = endpoint === OPENAI_RESPONSES_ENDPOINT && bearer !== undefined && !bearer.startsWith("sk-");
|
|
776
|
+
const body = {
|
|
777
|
+
...plan.body,
|
|
778
|
+
model: execution.model.id,
|
|
779
|
+
...(chatGPT ? {} : { max_output_tokens: 2_048 }),
|
|
780
|
+
};
|
|
791
781
|
const diagnosticSecrets = [
|
|
792
782
|
execution.auth.apiKey,
|
|
793
783
|
...Object.entries(execution.model.headers ?? {}).filter(([key]) => /authorization|api[-_]?key|token|secret|cookie/i.test(key)).map(([, value]) => value),
|
|
@@ -798,8 +788,9 @@ export class OpenAIProvider implements Provider {
|
|
|
798
788
|
|
|
799
789
|
let response: Response;
|
|
800
790
|
try {
|
|
801
|
-
response = await this.fetchImpl(
|
|
791
|
+
response = await this.fetchImpl(endpoint, {
|
|
802
792
|
method: "POST",
|
|
793
|
+
redirect: "error",
|
|
803
794
|
headers,
|
|
804
795
|
body: JSON.stringify(body),
|
|
805
796
|
signal,
|
|
@@ -817,7 +808,7 @@ export class OpenAIProvider implements Provider {
|
|
|
817
808
|
const responseRateLimits = parseProviderRateLimits(response.headers);
|
|
818
809
|
if (response.status === 401 || response.status === 403) {
|
|
819
810
|
const diagnostic = await readErrorDiagnostic(response, diagnosticSecrets, signal);
|
|
820
|
-
throw createProviderError({ provider: this.id, kind: "auth", message: `OpenAI web search authentication failed (HTTP ${response.status})${diagnostic === undefined ? "" : `: ${diagnostic}`}`, status: response.status, requestId, retryAfterMs, rateLimits: responseRateLimits, retryable: false });
|
|
811
|
+
throw createProviderError({ provider: this.id, kind: "auth", fallbackSafe: true, message: `OpenAI web search authentication failed (HTTP ${response.status})${diagnostic === undefined ? "" : `: ${diagnostic}`}`, status: response.status, requestId, retryAfterMs, rateLimits: responseRateLimits, retryable: false });
|
|
821
812
|
}
|
|
822
813
|
if (response.status === 429) {
|
|
823
814
|
const diagnostic = await readErrorDiagnostic(response, diagnosticSecrets, signal);
|
|
@@ -830,7 +821,7 @@ export class OpenAIProvider implements Provider {
|
|
|
830
821
|
|
|
831
822
|
try {
|
|
832
823
|
const responseBody = await readBody(response, signal, this.maxResponseBytes, this.id);
|
|
833
|
-
const payload = parseResponseBody(responseBody, this.id);
|
|
824
|
+
const payload = parseResponseBody(responseBody, this.id, diagnosticSecrets);
|
|
834
825
|
responseStatusFailure(this.id, payload);
|
|
835
826
|
const normalizedResponse = normalizeOpenAIResponse(payload, normalized, this.id);
|
|
836
827
|
const usage = normalizedResponse.usage === undefined && responseRateLimits === undefined
|
|
@@ -165,6 +165,7 @@ function usageFromPayload(root: Record<string, unknown>): ProviderUsage | undefi
|
|
|
165
165
|
export function normalizeParallelResponsesPayload(payload: unknown, request: SearchRequest): SearchResponse {
|
|
166
166
|
const normalized = validateSearchRequest(request);
|
|
167
167
|
const root = objectValue(payload, "response", "parallel-responses");
|
|
168
|
+
if (root.status !== "completed" || root.error != null) malformed("response did not complete successfully");
|
|
168
169
|
const { text, citations: rawCitations } = answerFromPayload(root);
|
|
169
170
|
if (text.trim().length === 0) malformed("answer text is empty");
|
|
170
171
|
const limitedText = text.slice(0, MAX_ANSWER_LENGTH);
|
package/src/parallel.ts
CHANGED
|
@@ -52,6 +52,9 @@ export function buildParallelRequest(request: SearchRequest): ParallelRequestPla
|
|
|
52
52
|
if (normalized.dateRange?.to !== undefined) {
|
|
53
53
|
throw createProviderError({ provider: "parallel", kind: "unsupported", message: "Parallel Search supports a date-range start but not an upper date bound", retryable: false });
|
|
54
54
|
}
|
|
55
|
+
if (normalized.dateRange?.from?.includes("T")) {
|
|
56
|
+
throw createProviderError({ provider: "parallel", kind: "unsupported", message: "Parallel Search supports a date-only start, not an exact timestamp", retryable: false });
|
|
57
|
+
}
|
|
55
58
|
if (normalized.domains?.include?.length && normalized.domains?.exclude?.length) {
|
|
56
59
|
throw createProviderError({ provider: "parallel", kind: "unsupported", message: "Parallel Search accepts either included or excluded domains, not both", retryable: false });
|
|
57
60
|
}
|
|
@@ -63,7 +66,7 @@ export function buildParallelRequest(request: SearchRequest): ParallelRequestPla
|
|
|
63
66
|
const sourcePolicy = {
|
|
64
67
|
...(normalized.domains?.include === undefined ? {} : { include_domains: [...normalized.domains.include] }),
|
|
65
68
|
...(normalized.domains?.exclude === undefined ? {} : { exclude_domains: [...normalized.domains.exclude] }),
|
|
66
|
-
...(normalized.dateRange?.from === undefined ? {} : { after_date: normalized.dateRange.from
|
|
69
|
+
...(normalized.dateRange?.from === undefined ? {} : { after_date: normalized.dateRange.from }),
|
|
67
70
|
};
|
|
68
71
|
const appliedOptions: SearchOption[] = ["maxResults", "mode"];
|
|
69
72
|
if (normalized.domains !== undefined && (normalized.domains.include !== undefined || normalized.domains.exclude !== undefined)) appliedOptions.push("domains");
|