@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.
@@ -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
@@ -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
- /** The provider that produced this result. */
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 provider actually served the request. */
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 provider selected for the whole research invocation. */
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 });
@@ -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
- throw createProviderError({ provider, kind: "http", message: "OpenAI web search returned an error event", retryable: false });
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 body = {
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(endpointFor(execution.model, this.endpoint), {
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.slice(0, 10) }),
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");