@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.
@@ -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 (execution.auth.apiKey === undefined || execution.auth.apiKey.trim().length === 0) {
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 = execution.auth.apiKey.split(".");
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
- /** The provider that produced this result. */
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 provider actually served the request. */
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 provider selected for the whole research invocation. */
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
- if (execution.auth.apiKey !== undefined && execution.auth.apiKey.trim().length > 0) headers.set("x-goog-api-key", execution.auth.apiKey);
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 });
@@ -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
- return { model, auth };
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 (options.bearerApiKey !== false && execution.auth.apiKey !== undefined && execution.auth.apiKey.trim().length > 0) headers.set("authorization", `Bearer ${execution.auth.apiKey}`);
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
  }