@nijaru/pi-search 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -9
- package/package.json +5 -4
- package/src/anthropic-auth.ts +108 -0
- package/src/anthropic.ts +64 -55
- package/src/brave-answers.ts +9 -2
- package/src/brave.ts +9 -6
- package/src/cloudflare.ts +175 -0
- package/src/codex.ts +4 -4
- package/src/contracts.ts +17 -3
- package/src/errors.ts +15 -1
- package/src/gemini.ts +1 -1
- package/src/grounding.ts +17 -3
- package/src/index.ts +17 -5
- package/src/model-selection.ts +3 -3
- package/src/openai.ts +23 -32
- package/src/parallel-responses.ts +1 -0
- package/src/parallel.ts +4 -1
- package/src/provider-availability.ts +2 -0
- package/src/provider-http.ts +3 -2
- package/src/research-tool.ts +47 -30
- package/src/router.ts +10 -2
- package/src/search-cleanup.ts +7 -6
- package/src/search-tool.ts +31 -7
- package/src/search.ts +2 -1
- package/src/x.ts +2 -2
- package/src/xai.ts +1 -1
- package/src/local-llama-compat.ts +0 -134
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
|
-
|
|
92
|
-
context.
|
|
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
|
-
###
|
|
111
|
+
### Cloudflare Web Search
|
|
109
112
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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.
|
|
3
|
+
"version": "0.0.3",
|
|
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": "
|
|
45
|
-
"@earendil-works/pi-coding-agent": "
|
|
46
|
-
"@earendil-works/pi-tui": "
|
|
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 {
|
|
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
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
//
|
|
159
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
package/src/brave-answers.ts
CHANGED
|
@@ -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
|
|
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
|
@@ -363,6 +363,7 @@ export class BraveProvider implements Provider {
|
|
|
363
363
|
throw createProviderError({
|
|
364
364
|
provider: this.id,
|
|
365
365
|
kind: "rateLimit",
|
|
366
|
+
fallbackSafe: true,
|
|
366
367
|
message: "Brave quota window is exhausted",
|
|
367
368
|
retryable: true,
|
|
368
369
|
rateLimits: observed,
|
|
@@ -379,6 +380,7 @@ export class BraveProvider implements Provider {
|
|
|
379
380
|
throw createProviderError({
|
|
380
381
|
provider: this.id,
|
|
381
382
|
kind: "rateLimit",
|
|
383
|
+
fallbackSafe: true,
|
|
382
384
|
message: "Brave quota window is exhausted",
|
|
383
385
|
retryable: true,
|
|
384
386
|
rateLimits: latest,
|
|
@@ -388,16 +390,17 @@ export class BraveProvider implements Provider {
|
|
|
388
390
|
}
|
|
389
391
|
const apiKey = this.apiKey;
|
|
390
392
|
if (apiKey === undefined || apiKey.trim().length === 0) {
|
|
391
|
-
throw createProviderError({ provider: this.id, kind: "auth", message: "Brave API key is not configured", retryable: false });
|
|
393
|
+
throw createProviderError({ provider: this.id, kind: "auth", fallbackSafe: true, message: "Brave API key is not configured", retryable: false });
|
|
392
394
|
}
|
|
393
395
|
|
|
394
396
|
let response: Response;
|
|
395
397
|
try {
|
|
396
398
|
response = await this.fetchImpl(plan.url, {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
399
|
+
method: "GET",
|
|
400
|
+
redirect: "error",
|
|
401
|
+
headers: { accept: "application/json", "x-subscription-token": apiKey },
|
|
402
|
+
signal,
|
|
403
|
+
});
|
|
401
404
|
} catch (error) {
|
|
402
405
|
if (signal.aborted) {
|
|
403
406
|
throw createProviderError({ provider: this.id, kind: "canceled", message: "Search canceled", retryable: false, cause: error });
|
|
@@ -411,7 +414,7 @@ export class BraveProvider implements Provider {
|
|
|
411
414
|
const requestId = response.headers.get("x-request-id") ?? response.headers.get("x-brave-request-id") ?? undefined;
|
|
412
415
|
if (response.status === 401 || response.status === 403) {
|
|
413
416
|
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 });
|
|
417
|
+
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
418
|
}
|
|
416
419
|
if (response.status === 429) {
|
|
417
420
|
await cancelResponseBody(response);
|