@nijaru/pi-search 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -9
- package/package.json +6 -6
- package/src/anthropic-auth.ts +108 -0
- package/src/anthropic.ts +64 -48
- 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 +21 -3
- package/src/errors.ts +15 -1
- package/src/gemini.ts +10 -3
- package/src/grounding.ts +17 -3
- package/src/index.ts +17 -5
- package/src/model-selection.ts +23 -5
- package/src/openai.ts +32 -35
- 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 +38 -8
- 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,11 +1,10 @@
|
|
|
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",
|
|
7
7
|
"author": "Nick Naru <nick@nijaru.com>",
|
|
8
|
-
|
|
9
8
|
"publishConfig": {
|
|
10
9
|
"access": "public"
|
|
11
10
|
},
|
|
@@ -42,16 +41,16 @@
|
|
|
42
41
|
"live:smoke": "bun scripts/live-smoke.ts"
|
|
43
42
|
},
|
|
44
43
|
"devDependencies": {
|
|
45
|
-
"@earendil-works/pi-ai": "
|
|
46
|
-
"@earendil-works/pi-coding-agent": "
|
|
47
|
-
"@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",
|
|
48
47
|
"@types/bun": "1.3.14",
|
|
49
48
|
"@types/turndown": "^5.0.6",
|
|
50
49
|
"typebox": "1.3.10",
|
|
51
50
|
"typescript": "7.0.2"
|
|
52
51
|
},
|
|
53
52
|
"peerDependencies": {
|
|
54
|
-
"@earendil-works/pi-ai": "
|
|
53
|
+
"@earendil-works/pi-ai": "*",
|
|
55
54
|
"@earendil-works/pi-coding-agent": "*",
|
|
56
55
|
"@earendil-works/pi-tui": "*",
|
|
57
56
|
"typebox": "*"
|
|
@@ -61,6 +60,7 @@
|
|
|
61
60
|
"node-domexception": "npm:@profoundlogic/node-domexception@1.0.2"
|
|
62
61
|
},
|
|
63
62
|
"dependencies": {
|
|
63
|
+
"@anthropic-ai/sdk": "0.129.0",
|
|
64
64
|
"@firecrawl/anydoc": "0.2.4",
|
|
65
65
|
"@mozilla/readability": "^0.6.0",
|
|
66
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,19 +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
|
-
if (apiKey === undefined || apiKey.trim().length === 0) {
|
|
129
|
-
throw createProviderError({ provider: "anthropic", kind: "auth", message: "Anthropic authentication returned no API key", retryable: false });
|
|
130
|
-
}
|
|
131
|
-
headers.set("x-api-key", apiKey);
|
|
132
|
-
headers.set("anthropic-version", ANTHROPIC_VERSION);
|
|
133
|
-
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();
|
|
134
127
|
}
|
|
135
128
|
|
|
136
129
|
interface AnthropicCandidate {
|
|
@@ -148,9 +141,8 @@ function candidateFromUrl(url: unknown, title: unknown): AnthropicCandidate | un
|
|
|
148
141
|
|
|
149
142
|
function throwResultError(code: unknown): never {
|
|
150
143
|
const errorCode = optionalString(code, 100) ?? "unknown";
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
// 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.
|
|
154
146
|
const retryable = errorCode === "too_many_requests" || errorCode === "unavailable";
|
|
155
147
|
throw createProviderError({
|
|
156
148
|
provider: "anthropic",
|
|
@@ -160,8 +152,35 @@ function throwResultError(code: unknown): never {
|
|
|
160
152
|
});
|
|
161
153
|
}
|
|
162
154
|
|
|
163
|
-
|
|
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. */
|
|
164
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 {
|
|
165
184
|
const normalized = validateSearchRequest(request);
|
|
166
185
|
const root = objectValue(payload, "response", "anthropic");
|
|
167
186
|
if (!Array.isArray(root.content)) {
|
|
@@ -278,19 +297,7 @@ export function normalizeAnthropicResponse(payload: unknown, request: SearchRequ
|
|
|
278
297
|
const answer = normalized.answerMode !== "evidence" && answerText.length > 0 && citations.length > 0
|
|
279
298
|
? { text: answerText, contentTrust: "untrusted" as const, provider: "anthropic" as const, citations }
|
|
280
299
|
: undefined;
|
|
281
|
-
const
|
|
282
|
-
? root.usage as Record<string, unknown>
|
|
283
|
-
: undefined;
|
|
284
|
-
const serverToolUse = usageRecord?.server_tool_use !== null && typeof usageRecord?.server_tool_use === "object" && !Array.isArray(usageRecord?.server_tool_use)
|
|
285
|
-
? usageRecord.server_tool_use as Record<string, unknown>
|
|
286
|
-
: undefined;
|
|
287
|
-
const reportedQueries = typeof serverToolUse?.web_search_requests === "number" && Number.isFinite(serverToolUse.web_search_requests) && serverToolUse.web_search_requests >= 0
|
|
288
|
-
? Math.floor(serverToolUse.web_search_requests)
|
|
289
|
-
: undefined;
|
|
290
|
-
const usage = tokenUsage(usageRecord?.input_tokens, usageRecord?.output_tokens, undefined);
|
|
291
|
-
const usageDetails = usage === undefined && (reportedQueries ?? searchQueries) === 0
|
|
292
|
-
? undefined
|
|
293
|
-
: { ...usage, ...((reportedQueries ?? searchQueries) === 0 ? {} : { searchQueries: reportedQueries ?? searchQueries }) };
|
|
300
|
+
const usageDetails = reportedUsage(root, searchQueries);
|
|
294
301
|
const warnings: SearchWarning[] = discarded > 0
|
|
295
302
|
? [{ code: "partial-results", message: `Anthropic discarded ${discarded} malformed result entr${discarded === 1 ? "y" : "ies"}` }]
|
|
296
303
|
: [];
|
|
@@ -314,6 +321,7 @@ export class AnthropicProvider implements Provider {
|
|
|
314
321
|
private readonly fetchImpl: SearchHttpFetch;
|
|
315
322
|
private readonly maxResponseBytes: number;
|
|
316
323
|
private readonly toolType: string;
|
|
324
|
+
private readonly auth: AnthropicSearchAuth;
|
|
317
325
|
|
|
318
326
|
constructor(options: AnthropicAdapterOptions = {}) {
|
|
319
327
|
this.endpoint = options.endpoint;
|
|
@@ -323,25 +331,33 @@ export class AnthropicProvider implements Provider {
|
|
|
323
331
|
throw new Error("Anthropic maxResponseBytes must be a positive integer");
|
|
324
332
|
}
|
|
325
333
|
this.toolType = options.toolType ?? ANTHROPIC_WEB_SEARCH_TOOL;
|
|
334
|
+
this.auth = new AnthropicSearchAuth(this.fetchImpl);
|
|
326
335
|
}
|
|
327
336
|
|
|
328
337
|
async search(request: SearchRequest, signal: AbortSignal, context: ProviderContext): Promise<SearchResponse> {
|
|
329
338
|
const normalized = validateSearchRequest(request);
|
|
330
339
|
const plan = buildAnthropicRequest(normalized, this.toolType);
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
+
}
|
|
345
361
|
}
|
|
346
362
|
}
|
|
347
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);
|