@nijaru/pi-search 0.0.2 → 0.0.4

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 CHANGED
@@ -34,7 +34,7 @@ Pi tools:
34
34
  is allowed; Exa returns semantic results with excerpts and reported usage.
35
35
  - In free-only mode, configured Brave is the conservative paced keyword/fresh
36
36
  path. Set `PI_SEARCH_PREFER_FREE=1` to prefer it before metered Exa.
37
- Parallel and the official X API remain explicit providers.
37
+ Parallel, Cloudflare Web Search, and the official X API remain explicit providers.
38
38
  - Automatic routing permits at most one visible fallback after a safe
39
39
  authentication, rate-limit, or unavailable failure. Network, timeout, and
40
40
  post-dispatch HTTP failures remain final because their effects are uncertain.
@@ -88,8 +88,11 @@ export X_API_BEARER_TOKEN=... # explicit official X API search
88
88
  # from Brave search's free-mode admission.
89
89
  ```
90
90
 
91
- Gemini and xAI credentials come from Pi's model-registry authentication
92
- context. Active Gemini/xAI models use native search automatically. For
91
+ Native-provider credentials come from Pi's model-registry authentication
92
+ context. Anthropic supports API keys, registry-owned bearer headers, and
93
+ workload identity federation; federation exchanges use the official SDK
94
+ protocol with bounded, cancellable requests. Anthropic Pro/Max subscription
95
+ search is not supported; its tokens are rejected before dispatch. Active Gemini/xAI models use native search automatically. For
93
96
  search-only Gemini grounding, prefer Pi's current `gemini-flash-lite-latest`
94
97
  model alias rather than full Flash/Pro or legacy model IDs. Explicit
95
98
  `provider: "gemini"`, `"xai"`, `"xai-x"`, `"anthropic"`, or `"meta"` can use a
@@ -105,13 +108,45 @@ selected source pages are needed; this reuses the safe local fetch path. For
105
108
  when a multi-query run should use a specific Gemini, xAI, Anthropic, Meta,
106
109
  OpenAI, or Codex model.
107
110
 
108
- ### Local OpenAI-compatible endpoints
111
+ ### Cloudflare Web Search
109
112
 
110
- The extension keeps the public and runtime 2,000-character query limit. For
111
- local/private OpenAI-compatible endpoints, it applies a compatibility-only
112
- outgoing schema adjustment for llama.cpp's nested-string grammar boundary; the
113
- schema sent to hosted GPT and other public providers is unchanged. No setting
114
- or tool toggle is required.
113
+ Cloudflare routes search through Ceramic, Exa, or Linkup. It is an explicit
114
+ secondary provider, never an automatic choice or fallback:
115
+
116
+ ```bash
117
+ export CLOUDFLARE_ACCOUNT_ID=...
118
+ export CLOUDFLARE_API_TOKEN=...
119
+ export PI_SEARCH_CLOUDFLARE_PROVIDER=exa # required: exa, linkup, or ceramic
120
+ # export PI_SEARCH_CLOUDFLARE_GATEWAY_ID=default
121
+ # export PI_SEARCH_CLOUDFLARE_BYOK_ALIAS=default
122
+ ```
123
+
124
+ Use `provider: "cloudflare"` with `web_search` or `web_research`. The API token
125
+ needs **Workers AI Read** and **AI Gateway Read** permissions. Queries are
126
+ limited to 1,024 characters and results to 10. Domain/date/social filters and
127
+ native-model controls are rejected, not silently dropped. Results identify
128
+ both Cloudflare and the configured upstream.
129
+
130
+ An explicit BYOK alias fails if its stored key is missing. Without an alias,
131
+ Cloudflare uses the upstream's stored `default` key when present, otherwise
132
+ prepaid gateway credits. See [Cloudflare's API and billing instructions](https://developers.cloudflare.com/web-search/how-to-use/).
133
+
134
+ **Keep direct Exa as the default direct search choice for now.** It provides
135
+ richer controls than the gateway. Ceramic has an independent web index and
136
+ long result descriptions; it is not merely a keyword-only engine. Its much
137
+ lower advertised price does not establish comparable quality. More
138
+ importantly, [Ceramic's terms §7(n)–(p)](https://www.ceramic.ai/terms-of-service)
139
+ restrict result retention and extractability in ways that appear incompatible
140
+ with saved/exportable Pi transcripts unless contractually permitted. Clarify
141
+ those rights before using Ceramic. Zero Data Retention describes provider
142
+ handling, not permission to retain results yourself.
143
+
144
+ ### Local and virtual models
145
+
146
+ Research queries retain the 2,000-character runtime limit. The tool schema
147
+ omits that nested string bound because llama.cpp cannot compile it; validation
148
+ still rejects overlong queries before search. This works with direct local
149
+ models and virtual routes without endpoint detection or request rewriting.
115
150
 
116
151
  ## Fetch coverage
117
152
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nijaru/pi-search",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Provider-neutral web search and fetching tools for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,9 +41,9 @@
41
41
  "live:smoke": "bun scripts/live-smoke.ts"
42
42
  },
43
43
  "devDependencies": {
44
- "@earendil-works/pi-ai": "0.87.0",
45
- "@earendil-works/pi-coding-agent": "0.87.0",
46
- "@earendil-works/pi-tui": "0.87.0",
44
+ "@earendil-works/pi-ai": "1.2.0",
45
+ "@earendil-works/pi-coding-agent": "1.2.0",
46
+ "@earendil-works/pi-tui": "1.2.0",
47
47
  "@types/bun": "1.3.14",
48
48
  "@types/turndown": "^5.0.6",
49
49
  "typebox": "1.3.10",
@@ -60,6 +60,7 @@
60
60
  "node-domexception": "npm:@profoundlogic/node-domexception@1.0.2"
61
61
  },
62
62
  "dependencies": {
63
+ "@anthropic-ai/sdk": "0.129.0",
63
64
  "@firecrawl/anydoc": "0.2.4",
64
65
  "@mozilla/readability": "^0.6.0",
65
66
  "linkedom": "^0.18.13",
@@ -0,0 +1,108 @@
1
+ import { open } from "node:fs/promises";
2
+ import type { ModelExecution } from "./model-selection";
3
+ import { hasExplicitHeader, modelAuthHeaders } from "./model-selection";
4
+ import { createProviderError } from "./errors";
5
+ import { readBoundedResponseText } from "./http";
6
+ import type { SearchHttpFetch } from "./provider-http";
7
+
8
+ export const ANTHROPIC_VERSION = "2023-06-01";
9
+ const OAUTH_BETA = "oauth-2025-04-20";
10
+ const MAX_IDENTITY_BYTES = 16 * 1024;
11
+
12
+ async function identityToken(path: string, signal: AbortSignal): Promise<string> {
13
+ signal.throwIfAborted();
14
+ const file = await open(path, "r");
15
+ try {
16
+ const buffer = Buffer.alloc(MAX_IDENTITY_BYTES + 1);
17
+ let size = 0;
18
+ while (size < buffer.length) {
19
+ signal.throwIfAborted();
20
+ const { bytesRead } = await file.read(buffer, size, buffer.length - size, null);
21
+ if (bytesRead === 0) break;
22
+ size += bytesRead;
23
+ }
24
+ const token = buffer.subarray(0, size).toString("utf8").trim();
25
+ if (size > MAX_IDENTITY_BYTES || token.length === 0) throw new Error("Invalid identity token file");
26
+ return token;
27
+ } finally {
28
+ await file.close();
29
+ }
30
+ }
31
+
32
+ /** Adapter-owned cache; auth configuration and credentials come only from Pi. */
33
+ export class AnthropicSearchAuth {
34
+ private cached?: { key: string; token: string; expiresAt: number | null };
35
+
36
+ constructor(private readonly fetchImpl: SearchHttpFetch) {}
37
+
38
+ invalidate(): void {
39
+ this.cached = undefined;
40
+ }
41
+
42
+ async headers(execution: ModelExecution, endpoint: string, signal: AbortSignal): Promise<Readonly<Record<string, string>>> {
43
+ const headers = modelAuthHeaders(execution, { bearerApiKey: false });
44
+ const sources = [execution.model.headers, execution.auth.headers];
45
+ const apiKey = execution.auth.apiKey;
46
+ // Pro/Max OAuth requires Pi's Claude Code request identity, not just
47
+ // a bearer header. Do not dispatch an incomplete subscription request.
48
+ if ((apiKey?.startsWith("sk-ant-oat") && !headers.has("authorization") && !headers.has("x-api-key"))
49
+ || /^Bearer\s+sk-ant-oat/i.test(headers.get("authorization") ?? "")) {
50
+ throw createProviderError({ provider: "anthropic", kind: "unsupported", message: "Anthropic subscription search is not supported; use an API key, API bearer credential, or workload identity", retryable: false });
51
+ }
52
+ if (apiKey?.trim() && !hasExplicitHeader(sources, "x-api-key") && !headers.has("authorization")) {
53
+ headers.set("x-api-key", apiKey);
54
+ }
55
+
56
+ if (!headers.has("x-api-key") && !headers.has("authorization")) {
57
+ // A null authorization is an explicit deletion, not permission to
58
+ // replace it with a separately exchanged credential.
59
+ if (hasExplicitHeader(sources, "authorization")) throw this.missingAuth();
60
+ const env = execution.auth.env;
61
+ const federationRuleId = env?.ANTHROPIC_FEDERATION_RULE_ID;
62
+ const organizationId = env?.ANTHROPIC_ORGANIZATION_ID;
63
+ const path = env?.ANTHROPIC_IDENTITY_TOKEN_FILE;
64
+ if (!federationRuleId || !organizationId || !path) throw this.missingAuth();
65
+ // The SDK token endpoint is /v1/oauth/token, relative to the API root.
66
+ const tokenBase = new URL(endpoint);
67
+ tokenBase.pathname = tokenBase.pathname.replace(/\/(?:v1\/)?messages\/?$/, "");
68
+ tokenBase.search = "";
69
+ tokenBase.hash = "";
70
+ const baseURL = tokenBase.toString().replace(/\/$/, "");
71
+ const key = JSON.stringify([baseURL, federationRuleId, organizationId, path, env?.ANTHROPIC_SERVICE_ACCOUNT_ID, env?.ANTHROPIC_WORKSPACE_ID]);
72
+ let cached = this.cached;
73
+ if (cached?.key !== key || (cached.expiresAt !== null && cached.expiresAt <= Date.now() / 1_000 + 60)) {
74
+ try {
75
+ // Reuse the official exchange protocol without its ambient
76
+ // credential chain, background refresh, or hidden request retries.
77
+ const { oidcFederationProvider } = await import("@anthropic-ai/sdk/lib/credentials/oidc-federation");
78
+ const exchange = oidcFederationProvider({
79
+ baseURL,
80
+ federationRuleId,
81
+ organizationId,
82
+ serviceAccountId: env?.ANTHROPIC_SERVICE_ACCOUNT_ID ?? undefined,
83
+ workspaceId: env?.ANTHROPIC_WORKSPACE_ID ?? undefined,
84
+ identityTokenProvider: () => identityToken(path, signal),
85
+ fetch: async (input, init) => {
86
+ const response = await this.fetchImpl(input, { ...init, signal, redirect: "error" });
87
+ const body = await readBoundedResponseText(response, 64 * 1024, signal);
88
+ return new Response(body, { status: response.status, headers: response.headers });
89
+ },
90
+ });
91
+ const token = await exchange();
92
+ signal.throwIfAborted();
93
+ cached = this.cached = { key, ...token };
94
+ } catch (error) {
95
+ throw createProviderError({ provider: "anthropic", kind: signal.aborted ? "canceled" : "auth", message: signal.aborted ? "Search canceled" : "Anthropic workload identity token exchange failed", retryable: false, cause: error });
96
+ }
97
+ }
98
+ headers.set("authorization", `Bearer ${cached.token}`);
99
+ headers.set("anthropic-beta", [headers.get("anthropic-beta"), OAUTH_BETA].filter(Boolean).join(","));
100
+ }
101
+ headers.set("anthropic-version", ANTHROPIC_VERSION);
102
+ return Object.fromEntries(headers.entries());
103
+ }
104
+
105
+ private missingAuth(): Error {
106
+ return createProviderError({ provider: "anthropic", kind: "auth", fallbackSafe: true, message: "Anthropic authentication returned no API key, bearer header, or workload identity configuration", retryable: false });
107
+ }
108
+ }
package/src/anthropic.ts CHANGED
@@ -10,20 +10,19 @@ import type {
10
10
  SearchResult,
11
11
  SearchWarning,
12
12
  } from "./contracts";
13
- import { createProviderError } from "./errors";
13
+ import { createProviderError, isProviderError } from "./errors";
14
14
  import {
15
- appendEndpointSuffix,
16
15
  assertHttpEndpoint,
17
16
  executeGroundedSearch,
18
17
  tokenUsage,
19
18
  type GroundingPlan,
20
19
  } from "./grounding";
21
- import { hasExplicitHeader, modelAuthHeaders, type ModelExecution } from "./model-selection";
20
+ import { AnthropicSearchAuth } from "./anthropic-auth";
22
21
  import { httpSource, objectValue, optionalString, type SearchHttpFetch } from "./provider-http";
23
22
  import { validateSearchRequest } from "./search";
24
23
 
25
24
  export const ANTHROPIC_MESSAGES_ENDPOINT = "https://api.anthropic.com/v1/messages";
26
- export const ANTHROPIC_VERSION = "2023-06-01";
25
+ export { ANTHROPIC_VERSION } from "./anthropic-auth";
27
26
  export const ANTHROPIC_WEB_SEARCH_TOOL = "web_search_20250305";
28
27
  export const DEFAULT_ANTHROPIC_RESPONSE_BYTES = 4 * 1024 * 1024;
29
28
  const MAX_ANTHROPIC_ANSWER_LENGTH = 8_000;
@@ -41,6 +40,7 @@ const capabilities: ProviderCapabilities = {
41
40
  semantic: true,
42
41
  excerpts: true,
43
42
  domainFilter: true,
43
+ combinedDomainFilters: false,
44
44
  nativeGrounding: true,
45
45
  userLocation: true,
46
46
  };
@@ -118,26 +118,12 @@ export function buildAnthropicRequest(request: SearchRequest, toolType = ANTHROP
118
118
  }
119
119
 
120
120
  function endpointFor(model: ProviderModel, override?: string): string {
121
- const base = override ?? (model.baseUrl.trim().length > 0 ? model.baseUrl : ANTHROPIC_MESSAGES_ENDPOINT);
122
- return assertHttpEndpoint(appendEndpointSuffix(base, "/messages"), "anthropic", "Anthropic Messages endpoint");
123
- }
124
-
125
- function anthropicHeaders(execution: ModelExecution): Readonly<Record<string, string>> {
126
- const headers = modelAuthHeaders(execution, { bearerApiKey: false });
127
- const apiKey = execution.auth.apiKey;
128
- // An explicit x-api-key (value or null deletion) wins over a derived key.
129
- if (
130
- apiKey !== undefined &&
131
- apiKey.trim().length > 0 &&
132
- !hasExplicitHeader([execution.model.headers, execution.auth.headers], "x-api-key")
133
- ) {
134
- headers.set("x-api-key", apiKey);
135
- }
136
- if (!headers.has("x-api-key")) {
137
- throw createProviderError({ provider: "anthropic", kind: "auth", message: "Anthropic authentication returned no API key", retryable: false });
138
- }
139
- headers.set("anthropic-version", ANTHROPIC_VERSION);
140
- return Object.fromEntries(headers.entries());
121
+ if (override !== undefined) return assertHttpEndpoint(override, "anthropic", "Anthropic Messages endpoint");
122
+ const base = model.baseUrl.trim().length > 0 ? model.baseUrl : ANTHROPIC_MESSAGES_ENDPOINT;
123
+ const url = new URL(assertHttpEndpoint(base, "anthropic", "Anthropic Messages endpoint"));
124
+ const path = url.pathname.replace(/\/+$/, "");
125
+ if (!path.endsWith("/messages")) url.pathname = `${path}${path.endsWith("/v1") ? "/messages" : "/v1/messages"}`;
126
+ return url.toString();
141
127
  }
142
128
 
143
129
  interface AnthropicCandidate {
@@ -155,9 +141,8 @@ function candidateFromUrl(url: unknown, title: unknown): AnthropicCandidate | un
155
141
 
156
142
  function throwResultError(code: unknown): never {
157
143
  const errorCode = optionalString(code, 100) ?? "unknown";
158
- // Errored searches are not billed, so retry/fallback is safe. The router
159
- // treats `unavailable` like auth/rate-limit failures and may consume its
160
- // single bounded automatic fallback.
144
+ // Search errors can still follow billed model tokens or successful searches.
145
+ // Retryability is not evidence that a paid fallback is safe.
161
146
  const retryable = errorCode === "too_many_requests" || errorCode === "unavailable";
162
147
  throw createProviderError({
163
148
  provider: "anthropic",
@@ -167,8 +152,35 @@ function throwResultError(code: unknown): never {
167
152
  });
168
153
  }
169
154
 
170
- /** Normalize a Messages API payload into evidence-first results. */
155
+ function reportedUsage(root: Record<string, unknown>, searchQueries = 0) {
156
+ const usageRecord = root.usage !== null && typeof root.usage === "object" && !Array.isArray(root.usage)
157
+ ? root.usage as Record<string, unknown>
158
+ : undefined;
159
+ const serverToolUse = usageRecord?.server_tool_use !== null && typeof usageRecord?.server_tool_use === "object" && !Array.isArray(usageRecord?.server_tool_use)
160
+ ? usageRecord.server_tool_use as Record<string, unknown>
161
+ : undefined;
162
+ const reportedQueries = typeof serverToolUse?.web_search_requests === "number" && Number.isFinite(serverToolUse.web_search_requests) && serverToolUse.web_search_requests >= 0
163
+ ? Math.floor(serverToolUse.web_search_requests)
164
+ : undefined;
165
+ const usage = tokenUsage(usageRecord?.input_tokens, usageRecord?.output_tokens, undefined);
166
+ const usageDetails = usage === undefined && (reportedQueries ?? searchQueries) === 0
167
+ ? undefined
168
+ : { ...usage, ...((reportedQueries ?? searchQueries) === 0 ? {} : { searchQueries: reportedQueries ?? searchQueries }) };
169
+ return usageDetails;
170
+ }
171
+
172
+ /** Preserve known usage even when normalization rejects a billed response. */
171
173
  export function normalizeAnthropicResponse(payload: unknown, request: SearchRequest): SearchResponse {
174
+ try {
175
+ return normalizeEvidence(payload, request);
176
+ } catch (error) {
177
+ if (!isProviderError(error)) throw error;
178
+ const root = objectValue(payload, "response", "anthropic");
179
+ throw createProviderError({ ...error, message: error.message, usage: reportedUsage(root), fallbackSafe: false, cause: error });
180
+ }
181
+ }
182
+
183
+ function normalizeEvidence(payload: unknown, request: SearchRequest): SearchResponse {
172
184
  const normalized = validateSearchRequest(request);
173
185
  const root = objectValue(payload, "response", "anthropic");
174
186
  if (!Array.isArray(root.content)) {
@@ -285,19 +297,7 @@ export function normalizeAnthropicResponse(payload: unknown, request: SearchRequ
285
297
  const answer = normalized.answerMode !== "evidence" && answerText.length > 0 && citations.length > 0
286
298
  ? { text: answerText, contentTrust: "untrusted" as const, provider: "anthropic" as const, citations }
287
299
  : undefined;
288
- const usageRecord = root.usage !== null && typeof root.usage === "object" && !Array.isArray(root.usage)
289
- ? root.usage as Record<string, unknown>
290
- : undefined;
291
- const serverToolUse = usageRecord?.server_tool_use !== null && typeof usageRecord?.server_tool_use === "object" && !Array.isArray(usageRecord?.server_tool_use)
292
- ? usageRecord.server_tool_use as Record<string, unknown>
293
- : undefined;
294
- const reportedQueries = typeof serverToolUse?.web_search_requests === "number" && Number.isFinite(serverToolUse.web_search_requests) && serverToolUse.web_search_requests >= 0
295
- ? Math.floor(serverToolUse.web_search_requests)
296
- : undefined;
297
- const usage = tokenUsage(usageRecord?.input_tokens, usageRecord?.output_tokens, undefined);
298
- const usageDetails = usage === undefined && (reportedQueries ?? searchQueries) === 0
299
- ? undefined
300
- : { ...usage, ...((reportedQueries ?? searchQueries) === 0 ? {} : { searchQueries: reportedQueries ?? searchQueries }) };
300
+ const usageDetails = reportedUsage(root, searchQueries);
301
301
  const warnings: SearchWarning[] = discarded > 0
302
302
  ? [{ code: "partial-results", message: `Anthropic discarded ${discarded} malformed result entr${discarded === 1 ? "y" : "ies"}` }]
303
303
  : [];
@@ -321,6 +321,7 @@ export class AnthropicProvider implements Provider {
321
321
  private readonly fetchImpl: SearchHttpFetch;
322
322
  private readonly maxResponseBytes: number;
323
323
  private readonly toolType: string;
324
+ private readonly auth: AnthropicSearchAuth;
324
325
 
325
326
  constructor(options: AnthropicAdapterOptions = {}) {
326
327
  this.endpoint = options.endpoint;
@@ -330,25 +331,33 @@ export class AnthropicProvider implements Provider {
330
331
  throw new Error("Anthropic maxResponseBytes must be a positive integer");
331
332
  }
332
333
  this.toolType = options.toolType ?? ANTHROPIC_WEB_SEARCH_TOOL;
334
+ this.auth = new AnthropicSearchAuth(this.fetchImpl);
333
335
  }
334
336
 
335
337
  async search(request: SearchRequest, signal: AbortSignal, context: ProviderContext): Promise<SearchResponse> {
336
338
  const normalized = validateSearchRequest(request);
337
339
  const plan = buildAnthropicRequest(normalized, this.toolType);
338
- return executeGroundedSearch({
339
- provider: this.id,
340
- modelProvider: "anthropic",
341
- api: "anthropic-messages",
342
- request: normalized,
343
- signal,
344
- context,
345
- fetchImpl: this.fetchImpl,
346
- maxResponseBytes: this.maxResponseBytes,
347
- endpointFor: (model) => endpointFor(model, this.endpoint),
348
- headersFor: anthropicHeaders,
349
- plan,
350
- normalize: normalizeAnthropicResponse,
351
- });
340
+ try {
341
+ return await executeGroundedSearch({
342
+ provider: this.id,
343
+ modelProvider: "anthropic",
344
+ api: "anthropic-messages",
345
+ request: normalized,
346
+ signal,
347
+ context,
348
+ fetchImpl: this.fetchImpl,
349
+ maxResponseBytes: this.maxResponseBytes,
350
+ endpointFor: (model) => endpointFor(model, this.endpoint),
351
+ headersFor: (execution, signal) => this.auth.headers(execution, endpointFor(execution.model, this.endpoint), signal),
352
+ plan,
353
+ normalize: normalizeAnthropicResponse,
354
+ });
355
+ } catch (error) {
356
+ // Do not retry the search. A subsequent explicit call may exchange
357
+ // fresh identity after the server rejects a cached access token.
358
+ if (isProviderError(error) && error.kind === "auth") this.auth.invalidate();
359
+ throw error;
360
+ }
352
361
  }
353
362
  }
354
363
 
@@ -248,11 +248,13 @@ async function readSseChunks(response: Response, provider: "brave-answers", sign
248
248
  const text = await readBoundedResponseText(response, maxBytes, signal);
249
249
  const events: Record<string, unknown>[] = [];
250
250
  let dataLines: string[] = [];
251
+ let completed = false;
251
252
  const flush = (): void => {
252
253
  if (dataLines.length === 0) return;
253
254
  const data = dataLines.join("\n").trim();
254
255
  dataLines = [];
255
- if (data.length === 0 || data === "[DONE]") return;
256
+ if (data === "[DONE]") { completed = true; return; }
257
+ if (data.length === 0) return;
256
258
  try {
257
259
  const parsed = JSON.parse(data) as unknown;
258
260
  if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) events.push(parsed as Record<string, unknown>);
@@ -271,6 +273,10 @@ async function readSseChunks(response: Response, provider: "brave-answers", sign
271
273
  if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
272
274
  }
273
275
  flush();
276
+ if (!completed) malformed("stream ended before completion");
277
+ if (events.some(event => event.error != null)) {
278
+ throw createProviderError({ provider, kind: "http", message: "Brave answer stream reported an error", retryable: false });
279
+ }
274
280
  return events;
275
281
  }
276
282
 
@@ -303,6 +309,7 @@ export class BraveAnswersProvider implements Provider {
303
309
  try {
304
310
  response = await this.fetchImpl(this.endpoint, {
305
311
  method: "POST",
312
+ redirect: "error",
306
313
  headers: {
307
314
  "x-subscription-token": requireApiKey(this.id, this.apiKey),
308
315
  accept: "text/event-stream",
@@ -324,7 +331,7 @@ export class BraveAnswersProvider implements Provider {
324
331
  };
325
332
  if (response.status === 401 || response.status === 403) {
326
333
  const message = await statusMessage();
327
- throw createProviderError({ provider: this.id, kind: "auth", message, status: response.status, retryable: false, ...(requestId === undefined ? {} : { requestId }) });
334
+ throw createProviderError({ provider: this.id, kind: "auth", fallbackSafe: true, message, status: response.status, retryable: false, ...(requestId === undefined ? {} : { requestId }) });
328
335
  }
329
336
  if (response.status === 429) {
330
337
  const message = await statusMessage();
package/src/brave.ts CHANGED
@@ -25,7 +25,7 @@ export type BraveFetch = (input: string | URL | Request, init?: RequestInit) =>
25
25
  export interface BraveCapacityTracker {
26
26
  /** True when no known finite quota window currently blocks a call. */
27
27
  readonly canAttempt: () => boolean;
28
- /** Reserve a local request slot, returning false when the caller canceled. */
28
+ /** Wait for local admission, returning false when the caller canceled. */
29
29
  readonly waitForTurn?: (signal: AbortSignal) => Promise<boolean>;
30
30
  /** Record provider observations without inventing or persisting counters. */
31
31
  readonly observe: (info: ProviderRateLimitInfo | undefined) => void;
@@ -48,6 +48,8 @@ export class BraveQuotaTracker implements BraveCapacityTracker {
48
48
  private observedAtMs = 0;
49
49
  private readonly minimumIntervalMs: number;
50
50
  private nextRequestAtMs = 0;
51
+ private readonly waiting = new Set<{ signal: AbortSignal; finish: (admitted: boolean) => void }>();
52
+ private admissionTimer: ReturnType<typeof setTimeout> | undefined;
51
53
 
52
54
  constructor(options: BraveQuotaTrackerOptions = {}) {
53
55
  const minimumIntervalMs = options.minimumIntervalMs ?? 0;
@@ -57,30 +59,48 @@ export class BraveQuotaTracker implements BraveCapacityTracker {
57
59
  this.minimumIntervalMs = minimumIntervalMs;
58
60
  }
59
61
 
60
- async waitForTurn(signal: AbortSignal): Promise<boolean> {
61
- if (signal.aborted) return false;
62
- const now = Date.now();
63
- const startAt = Math.max(now, this.nextRequestAtMs);
64
- this.nextRequestAtMs = startAt + this.minimumIntervalMs;
65
- const delayMs = startAt - now;
66
- if (delayMs <= 0) return true;
62
+ waitForTurn(signal: AbortSignal): Promise<boolean> {
63
+ if (signal.aborted) return Promise.resolve(false);
64
+ if (this.minimumIntervalMs === 0) return Promise.resolve(true);
67
65
  return new Promise<boolean>((resolve) => {
68
- let settled = false;
69
- let timer: ReturnType<typeof setTimeout> | undefined;
70
- const finish = (admitted: boolean): void => {
71
- if (settled) return;
72
- settled = true;
73
- if (timer !== undefined) clearTimeout(timer);
74
- signal.removeEventListener("abort", onAbort);
75
- resolve(admitted);
66
+ const waiter = {
67
+ signal,
68
+ finish: (admitted: boolean): void => {
69
+ if (!this.waiting.delete(waiter)) return;
70
+ signal.removeEventListener("abort", onAbort);
71
+ if (this.waiting.size === 0 && this.admissionTimer !== undefined) {
72
+ clearTimeout(this.admissionTimer);
73
+ this.admissionTimer = undefined;
74
+ }
75
+ resolve(admitted);
76
+ },
76
77
  };
77
- const onAbort = (): void => finish(false);
78
- timer = setTimeout(() => finish(true), delayMs);
78
+ const onAbort = (): void => { waiter.finish(false); };
79
+ this.waiting.add(waiter);
79
80
  signal.addEventListener("abort", onAbort, { once: true });
80
81
  if (signal.aborted) onAbort();
82
+ this.admitNext();
81
83
  });
82
84
  }
83
85
 
86
+ private admitNext(): void {
87
+ if (this.admissionTimer !== undefined) return;
88
+ for (const waiter of this.waiting) {
89
+ if (waiter.signal.aborted) { waiter.finish(false); continue; }
90
+ const now = Date.now(), delayMs = this.nextRequestAtMs - now;
91
+ if (delayMs > 0) {
92
+ this.admissionTimer = setTimeout(() => {
93
+ this.admissionTimer = undefined;
94
+ this.admitNext();
95
+ }, delayMs);
96
+ return;
97
+ }
98
+ // Only actual admissions consume spacing. Late timers cannot release a burst.
99
+ this.nextRequestAtMs = now + this.minimumIntervalMs;
100
+ waiter.finish(true);
101
+ }
102
+ }
103
+
84
104
  canAttempt(): boolean {
85
105
  const info = this.snapshot();
86
106
  if (info === undefined) return true;
@@ -363,6 +383,7 @@ export class BraveProvider implements Provider {
363
383
  throw createProviderError({
364
384
  provider: this.id,
365
385
  kind: "rateLimit",
386
+ fallbackSafe: true,
366
387
  message: "Brave quota window is exhausted",
367
388
  retryable: true,
368
389
  rateLimits: observed,
@@ -379,6 +400,7 @@ export class BraveProvider implements Provider {
379
400
  throw createProviderError({
380
401
  provider: this.id,
381
402
  kind: "rateLimit",
403
+ fallbackSafe: true,
382
404
  message: "Brave quota window is exhausted",
383
405
  retryable: true,
384
406
  rateLimits: latest,
@@ -388,16 +410,17 @@ export class BraveProvider implements Provider {
388
410
  }
389
411
  const apiKey = this.apiKey;
390
412
  if (apiKey === undefined || apiKey.trim().length === 0) {
391
- throw createProviderError({ provider: this.id, kind: "auth", message: "Brave API key is not configured", retryable: false });
413
+ throw createProviderError({ provider: this.id, kind: "auth", fallbackSafe: true, message: "Brave API key is not configured", retryable: false });
392
414
  }
393
415
 
394
416
  let response: Response;
395
417
  try {
396
418
  response = await this.fetchImpl(plan.url, {
397
- method: "GET",
398
- headers: { accept: "application/json", "x-subscription-token": apiKey },
399
- signal,
400
- });
419
+ method: "GET",
420
+ redirect: "error",
421
+ headers: { accept: "application/json", "x-subscription-token": apiKey },
422
+ signal,
423
+ });
401
424
  } catch (error) {
402
425
  if (signal.aborted) {
403
426
  throw createProviderError({ provider: this.id, kind: "canceled", message: "Search canceled", retryable: false, cause: error });
@@ -411,7 +434,7 @@ export class BraveProvider implements Provider {
411
434
  const requestId = response.headers.get("x-request-id") ?? response.headers.get("x-brave-request-id") ?? undefined;
412
435
  if (response.status === 401 || response.status === 403) {
413
436
  await cancelResponseBody(response);
414
- throw createProviderError({ provider: this.id, kind: "auth", message: `Brave rejected the API key (HTTP ${response.status})`, status: response.status, retryable: false, requestId, rateLimits });
437
+ throw createProviderError({ provider: this.id, kind: "auth", fallbackSafe: true, message: `Brave rejected the API key (HTTP ${response.status})`, status: response.status, retryable: false, requestId, rateLimits });
415
438
  }
416
439
  if (response.status === 429) {
417
440
  await cancelResponseBody(response);