@demigodmode/pi-web-agent 1.11.0 → 1.13.0
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/CHANGELOG.md +29 -0
- package/README.md +2 -0
- package/dist/backends/config.d.ts +13 -0
- package/dist/backends/config.js +44 -1
- package/dist/backends/doctor.js +27 -29
- package/dist/backends/factory.d.ts +18 -7
- package/dist/backends/factory.js +126 -99
- package/dist/backends/failure.d.ts +11 -0
- package/dist/backends/failure.js +34 -0
- package/dist/backends/fallback-policy.d.ts +31 -0
- package/dist/backends/fallback-policy.js +239 -0
- package/dist/backends/provider-failure.d.ts +21 -0
- package/dist/backends/provider-failure.js +110 -0
- package/dist/backends/provider-health.d.ts +29 -0
- package/dist/backends/provider-health.js +49 -0
- package/dist/commands/web-agent-config.d.ts +14 -1
- package/dist/commands/web-agent-config.js +75 -3
- package/dist/extension.js +47 -3
- package/dist/extract/bot-check.d.ts +1 -0
- package/dist/extract/bot-check.js +10 -0
- package/dist/extract/readability.d.ts +4 -0
- package/dist/extract/readability.js +67 -3
- package/dist/extract/section-selector.d.ts +14 -0
- package/dist/extract/section-selector.js +233 -0
- package/dist/fetch/destination-policy.d.ts +32 -0
- package/dist/fetch/destination-policy.js +24 -0
- package/dist/fetch/firecrawl-fetch.d.ts +1 -1
- package/dist/fetch/firecrawl-fetch.js +74 -46
- package/dist/fetch/guard-proxy-fetch.d.ts +17 -0
- package/dist/fetch/guard-proxy-fetch.js +82 -0
- package/dist/fetch/guard-proxy.d.ts +58 -0
- package/dist/fetch/guard-proxy.js +420 -0
- package/dist/fetch/guarded-fetch.d.ts +7 -0
- package/dist/fetch/guarded-fetch.js +75 -0
- package/dist/fetch/headless-fetch.d.ts +11 -2
- package/dist/fetch/headless-fetch.js +190 -13
- package/dist/fetch/http-fetch.d.ts +1 -1
- package/dist/fetch/http-fetch.js +29 -8
- package/dist/fetch/network-guard.d.ts +82 -0
- package/dist/fetch/network-guard.js +275 -0
- package/dist/orchestration/answer-synthesizer.js +2 -0
- package/dist/orchestration/evidence-quality.d.ts +3 -2
- package/dist/orchestration/evidence-quality.js +2 -1
- package/dist/orchestration/index.d.ts +26 -7
- package/dist/orchestration/index.js +9 -2
- package/dist/orchestration/research-orchestrator.d.ts +23 -7
- package/dist/orchestration/research-orchestrator.js +60 -26
- package/dist/orchestration/research-types.d.ts +13 -1
- package/dist/orchestration/research-worker.d.ts +2 -4
- package/dist/orchestration/research-worker.js +51 -15
- package/dist/orchestration/stop-decider.js +3 -1
- package/dist/presentation/config-store.js +6 -0
- package/dist/presentation/explore-presentation.js +3 -1
- package/dist/presentation/fetch-presentation.js +16 -9
- package/dist/presentation/search-presentation.d.ts +2 -1
- package/dist/presentation/search-presentation.js +13 -1
- package/dist/readers/resolver.d.ts +3 -7
- package/dist/search/brave.d.ts +1 -2
- package/dist/search/brave.js +23 -80
- package/dist/search/duckduckgo.d.ts +7 -3
- package/dist/search/duckduckgo.js +17 -18
- package/dist/search/exa.d.ts +1 -2
- package/dist/search/exa.js +15 -76
- package/dist/search/fanout.d.ts +12 -0
- package/dist/search/fanout.js +86 -47
- package/dist/search/json-provider.d.ts +32 -0
- package/dist/search/json-provider.js +76 -0
- package/dist/search/searxng.d.ts +1 -2
- package/dist/search/searxng.js +15 -57
- package/dist/search/tavily.d.ts +1 -2
- package/dist/search/tavily.js +17 -74
- package/dist/search/youcom.d.ts +4 -2
- package/dist/search/youcom.js +49 -75
- package/dist/tools/web-explore.d.ts +9 -0
- package/dist/tools/web-explore.js +16 -2
- package/dist/tools/web-fetch-headless.d.ts +3 -5
- package/dist/tools/web-fetch-headless.js +3 -3
- package/dist/tools/web-fetch.d.ts +3 -5
- package/dist/tools/web-fetch.js +3 -3
- package/dist/tools/web-search.js +41 -103
- package/dist/types.d.ts +48 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -18,6 +18,35 @@ The format is intentionally simple and release-oriented.
|
|
|
18
18
|
### Breaking
|
|
19
19
|
- None.
|
|
20
20
|
|
|
21
|
+
## [1.13.0] - 2026-09-24
|
|
22
|
+
### Added
|
|
23
|
+
- `web_explore` now uses the research question to pick relevant sections from long HTML pages instead of stopping at the first 4,000 characters. It can reach an answer buried later in a page, whether the page came through HTTP, headless browsing, or Firecrawl. Direct `web_fetch` output is unchanged. (#36)
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- You.com search and `/web-agent doctor` now use the documented `/v1/search` endpoint. Web and news results appear in the usual search list, with web results first. Result descriptions provide the snippets when present. (#60)
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- Research still excludes recognized bot-check pages when their verification message sits outside the section chosen for the question. That page cannot be used as evidence just because another section looks relevant. (#36)
|
|
30
|
+
- `/web-agent doctor` no longer repeats the default DuckDuckGo and HTTP backends after listing them in the config summary. It still shows checks and warnings for backends that need them. (#64)
|
|
31
|
+
- On pages with several `<article>` sections and no `<main>`, query-based reads now search across the articles. A match in the first article no longer hides an answer in a later one. (#71)
|
|
32
|
+
|
|
33
|
+
### Breaking
|
|
34
|
+
- None.
|
|
35
|
+
|
|
36
|
+
## [1.12.0] - 2026-09-17
|
|
37
|
+
### Added
|
|
38
|
+
- A network allow list, under Settings → Backends → Network allow list. web_explore now refuses private and local addresses when the link came from the model or a page it read, so if you genuinely want it to read something on your own network (an internal docs site, a service on localhost), add that range here as a CIDR like `10.0.0.0/24`. Entries are checked when you save, and ones that would allow everything (`0.0.0.0/0`, `::/0`) are rejected, since that would quietly turn the protection off. It is also the fix if every fetch suddenly fails: some proxy apps run in fake-IP mode and make every site look like it lives in `198.18.0.0/15`, and adding that range gets you going again. (#53)
|
|
39
|
+
- A "Trust the upstream proxy to enforce private-address restrictions" setting (`backends.network.trustProxyDns`). With a proxy configured, pi-web-agent normally looks each address up itself and asks the proxy to connect to that exact IP, so the proxy can't send a request somewhere else. Some proxies only accept hostnames, and some networks only resolve names inside the proxy. Turning this on hands the hostname to the proxy instead. It is off by default because it moves the address decision to your proxy, and even when it is on, localhost, private IPs typed straight into a link, and anything your own machine resolves to a private address are still refused. `/web-agent doctor` shows the allow list and this setting. (#53)
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
- Search and fetch fallback now act on why a backend failed. Rate limits pause that backend for the time the provider asks (capped at 15 minutes), exhausted quotas and bad API keys skip it until settings change, and timeouts or server errors get one retry. An empty result is treated as a real answer instead of a failure, a private-address refusal or an invalid shared proxy setting never falls back to another backend, and answers say when some search backends were unavailable. DuckDuckGo no longer retries on its own. (#55)
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
- web_explore no longer fetches private, loopback, or link-local addresses when the link came from the model or a page it read. The check happens where connections are opened, through a local guard proxy, so it also covers redirects, DNS changes, and everything a headless page loads. A web page could previously steer it at cloud metadata endpoints or services on your network. Addresses you configured yourself (search backends, SearXNG, Firecrawl, the proxy) are unaffected. If you do need to reach a private range, add it under Settings → Backends → Network allow list. With an upstream proxy that only accepts hostnames, turn on "Trust the upstream proxy to enforce private-address restrictions". (#53)
|
|
46
|
+
|
|
47
|
+
### Breaking
|
|
48
|
+
- None.
|
|
49
|
+
|
|
21
50
|
## [1.11.0] - 2026-09-16
|
|
22
51
|
### Added
|
|
23
52
|
- Optional proxy support. Point web_explore at an HTTP/HTTPS proxy and everything outbound goes through it: search, fetch, Firecrawl, the GitHub/PDF/YouTube readers, doctor health checks, and the headless browser. Off unless you set it, and nothing changes if you don't. Set it in Settings → Backends, and keep credentials in `PI_WEB_AGENT_PROXY_USERNAME` / `PI_WEB_AGENT_PROXY_PASSWORD` rather than in the URL. Thanks to @lo-tp for building this. (#50)
|
package/README.md
CHANGED
|
@@ -34,6 +34,8 @@ One public tool, `web_explore`, that does bounded web research for Pi: search, f
|
|
|
34
34
|
- **Six search backends.** DuckDuckGo (keyless default), SearXNG, Brave, You.com, Exa, Tavily.
|
|
35
35
|
- **Optional search fanout.** Query several backends at once, dedupe, and rank pages that more than one provider agreed on to the top. Off by default; flip it to `on` or `auto`.
|
|
36
36
|
- **Honest by default.** Weak, narrow, blocked, or cautionary evidence gets flagged instead of dressed up as confidence.
|
|
37
|
+
- **Safe with untrusted pages.** Links the model picks, or finds on a page it read, can't reach localhost, your LAN, or cloud metadata endpoints. That includes redirects and everything a headless page loads. Addresses you configure yourself aren't affected, and an allow list covers the private ranges you do want.
|
|
38
|
+
- **Fallback that knows why.** Rate-limited or misconfigured backends get skipped for a while, flaky ones get one retry, and answers say when some search backends were unavailable.
|
|
37
39
|
- **Bounded output.** `compact` / `preview` / `verbose` transcript modes.
|
|
38
40
|
- **Zero-config to start.** Runs keyless out of the box (DuckDuckGo search, local browser, the built-in readers). Opt into hosted backends, fallback, search fanout, and per-tool output modes through config when you want more control.
|
|
39
41
|
|
|
@@ -34,17 +34,25 @@ export type FetchBackendConfig = {
|
|
|
34
34
|
export type HeadlessBackendConfig = {
|
|
35
35
|
provider: 'local-browser';
|
|
36
36
|
};
|
|
37
|
+
export type NetworkConfig = {
|
|
38
|
+
/** CIDR ranges exempted from the private-address guard (#53). */
|
|
39
|
+
allowRanges?: string[];
|
|
40
|
+
/** Trust the upstream proxy to enforce private-address restrictions. */
|
|
41
|
+
trustProxyDns?: boolean;
|
|
42
|
+
};
|
|
37
43
|
export type BackendConfig = {
|
|
38
44
|
search: SearchBackendConfig;
|
|
39
45
|
fetch: FetchBackendConfig;
|
|
40
46
|
headless: HeadlessBackendConfig;
|
|
41
47
|
proxy?: ProxyConfig;
|
|
48
|
+
network?: NetworkConfig;
|
|
42
49
|
};
|
|
43
50
|
export type BackendConfigOverride = {
|
|
44
51
|
search?: Partial<SearchBackendConfig>;
|
|
45
52
|
fetch?: Partial<FetchBackendConfig>;
|
|
46
53
|
headless?: Partial<HeadlessBackendConfig>;
|
|
47
54
|
proxy?: ProxyConfig;
|
|
55
|
+
network?: NetworkConfig;
|
|
48
56
|
};
|
|
49
57
|
export type BackendConfigFile = {
|
|
50
58
|
backends?: {
|
|
@@ -70,6 +78,10 @@ export type BackendConfigFile = {
|
|
|
70
78
|
username?: unknown;
|
|
71
79
|
password?: unknown;
|
|
72
80
|
};
|
|
81
|
+
network?: {
|
|
82
|
+
allowRanges?: unknown;
|
|
83
|
+
trustProxyDns?: unknown;
|
|
84
|
+
};
|
|
73
85
|
};
|
|
74
86
|
};
|
|
75
87
|
export declare const DEFAULT_BACKEND_CONFIG: BackendConfig;
|
|
@@ -88,6 +100,7 @@ export declare function stripProxyCredentials(url: string): string;
|
|
|
88
100
|
*/
|
|
89
101
|
export declare function isValidProxyUrl(url: string): boolean;
|
|
90
102
|
export declare function extractProxyConfig(value: unknown): ProxyConfig | undefined;
|
|
103
|
+
export declare function extractNetworkConfig(value: unknown): NetworkConfig | undefined;
|
|
91
104
|
export declare function usableSearchProviders(search: SearchBackendConfig, env?: NodeJS.ProcessEnv): SearchProviderName[];
|
|
92
105
|
export declare function extractBackendConfigOverride(file: BackendConfigFile | null | undefined): BackendConfigOverride;
|
|
93
106
|
export declare function validateBackendConfig(config: BackendConfig): string[];
|
package/dist/backends/config.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseCidr } from '../fetch/network-guard.js';
|
|
1
2
|
export const DEFAULT_BACKEND_CONFIG = {
|
|
2
3
|
search: { provider: 'duckduckgo' },
|
|
3
4
|
fetch: { provider: 'http' },
|
|
@@ -79,6 +80,19 @@ export function extractProxyConfig(value) {
|
|
|
79
80
|
config.password = raw.password;
|
|
80
81
|
return config;
|
|
81
82
|
}
|
|
83
|
+
export function extractNetworkConfig(value) {
|
|
84
|
+
if (!value || typeof value !== 'object')
|
|
85
|
+
return undefined;
|
|
86
|
+
const raw = value;
|
|
87
|
+
const config = {};
|
|
88
|
+
if (Array.isArray(raw.allowRanges)) {
|
|
89
|
+
// Coerce non-string entries so validation flags them instead of dropping the list.
|
|
90
|
+
config.allowRanges = raw.allowRanges.map((entry) => (typeof entry === 'string' ? entry : String(entry)));
|
|
91
|
+
}
|
|
92
|
+
if (typeof raw.trustProxyDns === 'boolean')
|
|
93
|
+
config.trustProxyDns = raw.trustProxyDns;
|
|
94
|
+
return Object.keys(config).length > 0 ? config : undefined;
|
|
95
|
+
}
|
|
82
96
|
function extractSearxngOptions(value) {
|
|
83
97
|
if (!value || typeof value !== 'object')
|
|
84
98
|
return undefined;
|
|
@@ -187,6 +201,10 @@ export function extractBackendConfigOverride(file) {
|
|
|
187
201
|
if (proxy) {
|
|
188
202
|
override.proxy = proxy;
|
|
189
203
|
}
|
|
204
|
+
const network = extractNetworkConfig(backends?.network);
|
|
205
|
+
if (network) {
|
|
206
|
+
override.network = network;
|
|
207
|
+
}
|
|
190
208
|
return override;
|
|
191
209
|
}
|
|
192
210
|
export function validateBackendConfig(config) {
|
|
@@ -214,6 +232,18 @@ export function validateBackendConfig(config) {
|
|
|
214
232
|
if (config.fetch.provider === 'firecrawl' && !config.fetch.baseUrl) {
|
|
215
233
|
issues.push('fetch provider firecrawl requires backends.fetch.baseUrl');
|
|
216
234
|
}
|
|
235
|
+
for (const range of config.network?.allowRanges ?? []) {
|
|
236
|
+
const cidr = parseCidr(range);
|
|
237
|
+
if (!cidr) {
|
|
238
|
+
issues.push(`backends.network.allowRanges entry "${range}" is not a valid CIDR range`);
|
|
239
|
+
}
|
|
240
|
+
else if (cidr.prefix === 0) {
|
|
241
|
+
issues.push(`backends.network.allowRanges entry "${range}" allows every address, which turns the guard off; list specific ranges instead`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (config.network?.trustProxyDns && !config.proxy?.url?.trim()) {
|
|
245
|
+
issues.push('backends.network.trustProxyDns has no effect without backends.proxy');
|
|
246
|
+
}
|
|
217
247
|
if (config.search.fallback === 'duckduckgo' && config.search.provider !== 'searxng' && config.search.provider !== 'brave' && config.search.provider !== 'youcom' && config.search.provider !== 'exa' && config.search.provider !== 'tavily') {
|
|
218
248
|
issues.push('search fallback duckduckgo is only supported when search provider is searxng, brave, youcom, exa, or tavily');
|
|
219
249
|
}
|
|
@@ -260,6 +290,17 @@ function mergeFetchConfig(current, override) {
|
|
|
260
290
|
}
|
|
261
291
|
return { ...current, ...override };
|
|
262
292
|
}
|
|
293
|
+
function mergeNetworkConfig(base, layer) {
|
|
294
|
+
if (!layer)
|
|
295
|
+
return base;
|
|
296
|
+
const next = { ...base };
|
|
297
|
+
// A layer's allow list replaces the lower one outright; trust is set independently.
|
|
298
|
+
if (layer.allowRanges)
|
|
299
|
+
next.allowRanges = [...layer.allowRanges];
|
|
300
|
+
if (layer.trustProxyDns !== undefined)
|
|
301
|
+
next.trustProxyDns = layer.trustProxyDns;
|
|
302
|
+
return next;
|
|
303
|
+
}
|
|
263
304
|
export function mergeBackendConfigLayers(...layers) {
|
|
264
305
|
return layers.reduce((merged, layer) => ({
|
|
265
306
|
search: mergeSearchConfig(merged.search, layer?.search),
|
|
@@ -269,6 +310,8 @@ export function mergeBackendConfigLayers(...layers) {
|
|
|
269
310
|
? layer.proxy.url === ''
|
|
270
311
|
? undefined // explicit disable overrides any proxy from lower layers
|
|
271
312
|
: { ...merged.proxy, ...layer.proxy }
|
|
272
|
-
: merged.proxy
|
|
313
|
+
: merged.proxy,
|
|
314
|
+
// Replace, don't union: a project list is the whole list for that project.
|
|
315
|
+
network: mergeNetworkConfig(merged.network, layer?.network)
|
|
273
316
|
}), DEFAULT_BACKEND_CONFIG);
|
|
274
317
|
}
|
package/dist/backends/doctor.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeYouComResults, YOUCOM_SEARCH_URL } from '../search/youcom.js';
|
|
1
2
|
function withTimeout(timeoutMs) {
|
|
2
3
|
const controller = new AbortController();
|
|
3
4
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -13,7 +14,7 @@ function braveDoctorUrl() {
|
|
|
13
14
|
return url.toString();
|
|
14
15
|
}
|
|
15
16
|
function youcomDoctorBody() {
|
|
16
|
-
return JSON.stringify({ query: 'pi-web-agent-doctor',
|
|
17
|
+
return JSON.stringify({ query: 'pi-web-agent-doctor', count: 1 });
|
|
17
18
|
}
|
|
18
19
|
function exaDoctorBody() {
|
|
19
20
|
return JSON.stringify({ query: 'pi-web-agent-doctor', numResults: 1 });
|
|
@@ -42,10 +43,7 @@ function firecrawlDoctorBody(options = {}) {
|
|
|
42
43
|
}
|
|
43
44
|
export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs = 3_000 } = {}) {
|
|
44
45
|
const lines = [];
|
|
45
|
-
if (config.search.provider === '
|
|
46
|
-
lines.push('search backend: duckduckgo');
|
|
47
|
-
}
|
|
48
|
-
else if (config.search.provider === 'brave') {
|
|
46
|
+
if (config.search.provider === 'brave') {
|
|
49
47
|
const apiKey = process.env.PI_WEB_AGENT_BRAVE_API_KEY;
|
|
50
48
|
if (!apiKey?.trim()) {
|
|
51
49
|
lines.push('search backend: brave warning (missing PI_WEB_AGENT_BRAVE_API_KEY)');
|
|
@@ -86,7 +84,7 @@ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs
|
|
|
86
84
|
else {
|
|
87
85
|
const timeout = withTimeout(timeoutMs);
|
|
88
86
|
try {
|
|
89
|
-
const response = await fetchImpl(
|
|
87
|
+
const response = await fetchImpl(YOUCOM_SEARCH_URL, {
|
|
90
88
|
method: 'POST',
|
|
91
89
|
headers: {
|
|
92
90
|
Accept: 'application/json',
|
|
@@ -100,8 +98,8 @@ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs
|
|
|
100
98
|
lines.push(`search backend: youcom warning (HTTP ${response.status})`);
|
|
101
99
|
}
|
|
102
100
|
else {
|
|
103
|
-
const
|
|
104
|
-
lines.push(
|
|
101
|
+
const normalized = normalizeYouComResults(await response.json());
|
|
102
|
+
lines.push(normalized && (normalized.rawCount === 0 || normalized.results.length > 0)
|
|
105
103
|
? 'search backend: youcom ok'
|
|
106
104
|
: 'search backend: youcom warning (unexpected response)');
|
|
107
105
|
}
|
|
@@ -186,23 +184,26 @@ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs
|
|
|
186
184
|
}
|
|
187
185
|
}
|
|
188
186
|
}
|
|
189
|
-
else if (
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const timeout = withTimeout(timeoutMs);
|
|
194
|
-
try {
|
|
195
|
-
const response = await fetchImpl(searxngDoctorUrl(config.search.baseUrl, config.search.options), { signal: timeout.signal });
|
|
196
|
-
const json = (await response.json());
|
|
197
|
-
lines.push(response.ok && Array.isArray(json.results)
|
|
198
|
-
? 'search backend: searxng ok'
|
|
199
|
-
: 'search backend: searxng warning (unexpected response)');
|
|
200
|
-
}
|
|
201
|
-
catch (error) {
|
|
202
|
-
lines.push(`search backend: searxng warning (${message(error)})`);
|
|
187
|
+
else if (config.search.provider === 'searxng') {
|
|
188
|
+
const baseUrl = config.search.baseUrl;
|
|
189
|
+
if (!baseUrl) {
|
|
190
|
+
lines.push('search backend: searxng warning (missing baseUrl)');
|
|
203
191
|
}
|
|
204
|
-
|
|
205
|
-
timeout
|
|
192
|
+
else {
|
|
193
|
+
const timeout = withTimeout(timeoutMs);
|
|
194
|
+
try {
|
|
195
|
+
const response = await fetchImpl(searxngDoctorUrl(baseUrl, config.search.options), { signal: timeout.signal });
|
|
196
|
+
const json = (await response.json());
|
|
197
|
+
lines.push(response.ok && Array.isArray(json.results)
|
|
198
|
+
? 'search backend: searxng ok'
|
|
199
|
+
: 'search backend: searxng warning (unexpected response)');
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
lines.push(`search backend: searxng warning (${message(error)})`);
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
timeout.done();
|
|
206
|
+
}
|
|
206
207
|
}
|
|
207
208
|
}
|
|
208
209
|
if (config.search.fallback) {
|
|
@@ -241,13 +242,10 @@ export async function checkBackendHealth(config, { fetchImpl = fetch, timeoutMs
|
|
|
241
242
|
}
|
|
242
243
|
}
|
|
243
244
|
}
|
|
244
|
-
if (config.fetch.provider
|
|
245
|
-
lines.push('fetch backend: http');
|
|
246
|
-
}
|
|
247
|
-
else if (!config.fetch.baseUrl) {
|
|
245
|
+
if (config.fetch.provider !== 'http' && !config.fetch.baseUrl) {
|
|
248
246
|
lines.push('fetch backend: firecrawl warning (missing baseUrl)');
|
|
249
247
|
}
|
|
250
|
-
else {
|
|
248
|
+
else if (config.fetch.provider !== 'http') {
|
|
251
249
|
const timeout = withTimeout(timeoutMs);
|
|
252
250
|
try {
|
|
253
251
|
const headers = { 'content-type': 'application/json' };
|
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
import { createFirecrawlFetcher } from '../fetch/firecrawl-fetch.js';
|
|
2
|
+
import { type GuardProxy, type GuardProxyOptions } from '../fetch/guard-proxy.js';
|
|
3
|
+
import { type NetworkGuard } from '../fetch/network-guard.js';
|
|
2
4
|
import { createBraveSearchTool } from '../search/brave.js';
|
|
3
5
|
import { createYouComSearchTool } from '../search/youcom.js';
|
|
4
6
|
import { createExaSearchTool } from '../search/exa.js';
|
|
5
7
|
import { createTavilySearchTool } from '../search/tavily.js';
|
|
6
8
|
import { createSearxngSearchTool } from '../search/searxng.js';
|
|
9
|
+
import { type PolicyDeps } from './fallback-policy.js';
|
|
10
|
+
import { type ProviderHealth } from './provider-health.js';
|
|
7
11
|
import { createWebFetchHeadlessTool } from '../tools/web-fetch-headless.js';
|
|
8
12
|
import { createWebFetchTool } from '../tools/web-fetch.js';
|
|
9
13
|
import { createWebSearchTool } from '../tools/web-search.js';
|
|
10
|
-
import type { WebFetchHeadlessResponse, WebFetchResponse, WebSearchResponse } from '../types.js';
|
|
14
|
+
import type { ResearchFetchInput, WebFetchHeadlessResponse, WebFetchResponse, WebSearchResponse } from '../types.js';
|
|
11
15
|
import { type BackendConfig, type ProxyConfig } from './config.js';
|
|
12
16
|
export type BackendSet = {
|
|
13
17
|
search: (input: {
|
|
14
18
|
query: string;
|
|
15
19
|
}) => Promise<WebSearchResponse>;
|
|
16
|
-
fetchPage: (input:
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
url: string;
|
|
21
|
-
}) => Promise<WebFetchHeadlessResponse>;
|
|
20
|
+
fetchPage: (input: ResearchFetchInput) => Promise<WebFetchResponse>;
|
|
21
|
+
headlessFetch: (input: ResearchFetchInput) => Promise<WebFetchHeadlessResponse>;
|
|
22
|
+
/** Releases the guard proxy and its agents. Idempotent; never starts the proxy. */
|
|
23
|
+
close: () => Promise<void>;
|
|
22
24
|
};
|
|
23
25
|
export type BackendFactoryDeps = {
|
|
24
26
|
createDuckDuckGoSearch?: typeof createWebSearchTool;
|
|
@@ -31,5 +33,14 @@ export type BackendFactoryDeps = {
|
|
|
31
33
|
createFirecrawlFetch?: typeof createFirecrawlFetcher;
|
|
32
34
|
createHeadlessFetch?: typeof createWebFetchHeadlessTool;
|
|
33
35
|
createProxyFetch?: (proxy: ProxyConfig) => typeof fetch;
|
|
36
|
+
networkGuard?: NetworkGuard;
|
|
37
|
+
/** Test seam: the fetch used for model-chosen URLs, before redirect handling. */
|
|
38
|
+
createModelFetch?: (guard: NetworkGuard) => typeof fetch;
|
|
39
|
+
createGuardProxy?: (options: GuardProxyOptions) => Promise<GuardProxy>;
|
|
40
|
+
providerHealth?: ProviderHealth;
|
|
41
|
+
/** Test seam for the retry sleep, jitter, and clock. */
|
|
42
|
+
policy?: Omit<PolicyDeps, 'health'>;
|
|
43
|
+
/** Test seam for the per-call fanout provider timeout. */
|
|
44
|
+
fanoutTimeoutMs?: number;
|
|
34
45
|
};
|
|
35
46
|
export declare function createBackendSet(config?: BackendConfig, deps?: BackendFactoryDeps): BackendSet;
|
package/dist/backends/factory.js
CHANGED
|
@@ -2,13 +2,19 @@ import { createFirecrawlFetcher } from '../fetch/firecrawl-fetch.js';
|
|
|
2
2
|
import { createHttpFetcher } from '../fetch/http-fetch.js';
|
|
3
3
|
import { createProxyFetch, resolveProxyCredentials } from '../fetch/proxy-fetch.js';
|
|
4
4
|
import { headlessFetch } from '../fetch/headless-fetch.js';
|
|
5
|
+
import { createGuardedFetch } from '../fetch/guarded-fetch.js';
|
|
6
|
+
import { startGuardProxy } from '../fetch/guard-proxy.js';
|
|
7
|
+
import { createGuardProxyFetch } from '../fetch/guard-proxy-fetch.js';
|
|
8
|
+
import { createNetworkGuard, findGuardError } from '../fetch/network-guard.js';
|
|
5
9
|
import { createBraveSearchTool } from '../search/brave.js';
|
|
6
10
|
import { createYouComSearchTool } from '../search/youcom.js';
|
|
7
11
|
import { fetchDuckDuckGoHtml } from '../search/duckduckgo.js';
|
|
8
12
|
import { createExaSearchTool } from '../search/exa.js';
|
|
9
13
|
import { createTavilySearchTool } from '../search/tavily.js';
|
|
10
14
|
import { createSearxngSearchTool } from '../search/searxng.js';
|
|
11
|
-
import { createFanoutSearch } from '../search/fanout.js';
|
|
15
|
+
import { createFanoutSearch, FANOUT_PROVIDER_TIMEOUT_MS, withCallTimeout } from '../search/fanout.js';
|
|
16
|
+
import { chainSearch, withFetchPolicy, withSearchPolicy } from './fallback-policy.js';
|
|
17
|
+
import { createProviderHealth } from './provider-health.js';
|
|
12
18
|
import { buildFetchPresentation } from '../presentation/fetch-presentation.js';
|
|
13
19
|
import { buildSearchPresentation } from '../presentation/search-presentation.js';
|
|
14
20
|
import { createWebFetchHeadlessTool } from '../tools/web-fetch-headless.js';
|
|
@@ -27,7 +33,8 @@ function invalidSearxngSearch() {
|
|
|
27
33
|
metadata: { backend: 'searxng', cacheHit: false },
|
|
28
34
|
error: {
|
|
29
35
|
code: 'BACKEND_CONFIG_INVALID',
|
|
30
|
-
message: 'SearXNG search requires backends.search.baseUrl.'
|
|
36
|
+
message: 'SearXNG search requires backends.search.baseUrl.',
|
|
37
|
+
failure: { kind: 'not_configured' }
|
|
31
38
|
}
|
|
32
39
|
};
|
|
33
40
|
return { ...result, presentation: buildSearchPresentation(result) };
|
|
@@ -41,47 +48,36 @@ function invalidFirecrawlFetch() {
|
|
|
41
48
|
metadata: { method: 'firecrawl', cacheHit: false },
|
|
42
49
|
error: {
|
|
43
50
|
code: 'BACKEND_CONFIG_INVALID',
|
|
44
|
-
message: 'Firecrawl fetch requires backends.fetch.baseUrl.'
|
|
51
|
+
message: 'Firecrawl fetch requires backends.fetch.baseUrl.',
|
|
52
|
+
failure: { kind: 'not_configured' }
|
|
45
53
|
}
|
|
46
54
|
};
|
|
47
55
|
return { ...result, presentation: buildFetchPresentation(result) };
|
|
48
56
|
};
|
|
49
57
|
}
|
|
50
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Checks the target URL before dispatching to the http fetcher, a content
|
|
60
|
+
* reader, or Firecrawl. Doing it here means a private URL is refused once, in
|
|
61
|
+
* one place, and never reaches Firecrawl or its http fallback (#53).
|
|
62
|
+
*/
|
|
63
|
+
function withTargetGuard(fetchPage, guard, method) {
|
|
51
64
|
return async (input) => {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
function withFetchFallback(primary, fallback) {
|
|
71
|
-
return async (input) => {
|
|
72
|
-
const first = await primary(input);
|
|
73
|
-
if (first.status !== 'error' && first.status !== 'needs_headless')
|
|
74
|
-
return first;
|
|
75
|
-
const second = await fallback(input);
|
|
76
|
-
const result = {
|
|
77
|
-
...second,
|
|
78
|
-
metadata: {
|
|
79
|
-
...second.metadata,
|
|
80
|
-
fallbackFrom: 'firecrawl',
|
|
81
|
-
fallbackReason: first.error?.message ?? 'Firecrawl fetch failed.'
|
|
82
|
-
}
|
|
83
|
-
};
|
|
84
|
-
return { ...result, presentation: buildFetchPresentation(result) };
|
|
65
|
+
try {
|
|
66
|
+
await guard.assertUrlAllowed(input.url);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
const blocked = findGuardError(error);
|
|
70
|
+
if (!blocked)
|
|
71
|
+
throw error;
|
|
72
|
+
const result = {
|
|
73
|
+
status: 'error',
|
|
74
|
+
url: input.url,
|
|
75
|
+
metadata: { method, cacheHit: false },
|
|
76
|
+
error: { code: blocked.code, message: blocked.message, failure: { kind: 'guard_refused' } }
|
|
77
|
+
};
|
|
78
|
+
return { ...result, presentation: buildFetchPresentation(result) };
|
|
79
|
+
}
|
|
80
|
+
return fetchPage(input);
|
|
85
81
|
};
|
|
86
82
|
}
|
|
87
83
|
export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
@@ -109,7 +105,7 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
109
105
|
status: 'error',
|
|
110
106
|
results: [],
|
|
111
107
|
metadata: { backend: config.search.provider, cacheHit: false },
|
|
112
|
-
error: { code: 'BACKEND_CONFIG_INVALID', message }
|
|
108
|
+
error: { code: 'BACKEND_CONFIG_INVALID', message, failure: { kind: 'config_global' } }
|
|
113
109
|
};
|
|
114
110
|
return { ...result, presentation: buildSearchPresentation(result) };
|
|
115
111
|
},
|
|
@@ -118,7 +114,7 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
118
114
|
status: 'error',
|
|
119
115
|
url,
|
|
120
116
|
metadata: { method: 'http', cacheHit: false },
|
|
121
|
-
error: { code: 'BACKEND_CONFIG_INVALID', message }
|
|
117
|
+
error: { code: 'BACKEND_CONFIG_INVALID', message, failure: { kind: 'config_global' } }
|
|
122
118
|
};
|
|
123
119
|
return { ...result, presentation: buildFetchPresentation(result) };
|
|
124
120
|
},
|
|
@@ -127,24 +123,71 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
127
123
|
status: 'error',
|
|
128
124
|
url,
|
|
129
125
|
metadata: { method: 'headless', cacheHit: false },
|
|
130
|
-
error: { code: 'BACKEND_CONFIG_INVALID', message }
|
|
126
|
+
error: { code: 'BACKEND_CONFIG_INVALID', message, failure: { kind: 'config_global' } }
|
|
131
127
|
};
|
|
132
128
|
return { ...result, presentation: buildFetchPresentation(result) };
|
|
133
|
-
}
|
|
129
|
+
},
|
|
130
|
+
close: async () => undefined
|
|
134
131
|
};
|
|
135
132
|
}
|
|
136
|
-
// When a proxy is configured, every outbound HTTP request
|
|
137
|
-
//
|
|
138
|
-
//
|
|
133
|
+
// When a proxy is configured, every outbound HTTP request goes through it.
|
|
134
|
+
// User-configured endpoints use fetchImpl directly; model-chosen fetches and
|
|
135
|
+
// the headless browser reach it through the guard proxy below.
|
|
139
136
|
const fetchImpl = proxy ? makeProxyFetch(proxy) : fetch;
|
|
140
137
|
const proxyCredentials = proxy ? resolveProxyCredentials(proxy) : undefined;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
138
|
+
// Model-chosen URLs only. Search APIs and the configured SearXNG/Firecrawl
|
|
139
|
+
// endpoints keep using fetchImpl: the user typed those (#53).
|
|
140
|
+
const networkGuard = deps.networkGuard ?? createNetworkGuard({ allowRanges: config.network?.allowRanges ?? [] });
|
|
141
|
+
// One guard proxy per backend set, started on first use. It is the single
|
|
142
|
+
// place the address policy is enforced, for Node fetches and the browser,
|
|
143
|
+
// and it chains to the user's upstream proxy itself.
|
|
144
|
+
let guardProxy;
|
|
145
|
+
let isClosed = false;
|
|
146
|
+
const getGuardProxy = () => {
|
|
147
|
+
if (isClosed)
|
|
148
|
+
return Promise.reject(new Error('Backend set is closed.'));
|
|
149
|
+
if (guardProxy)
|
|
150
|
+
return guardProxy;
|
|
151
|
+
const started = (deps.createGuardProxy ?? startGuardProxy)({
|
|
152
|
+
guard: networkGuard,
|
|
153
|
+
...(proxy
|
|
154
|
+
? {
|
|
155
|
+
upstream: {
|
|
156
|
+
url: stripProxyCredentials(proxy.url),
|
|
157
|
+
...(proxyCredentials?.username !== undefined ? { username: proxyCredentials.username } : {}),
|
|
158
|
+
...(proxyCredentials?.password !== undefined ? { password: proxyCredentials.password } : {})
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
: {}),
|
|
162
|
+
trustProxyDns: config.network?.trustProxyDns === true
|
|
163
|
+
});
|
|
164
|
+
guardProxy = started;
|
|
165
|
+
// A failed start should not stick around for the rest of the backend
|
|
166
|
+
// set's life: clear it so the next call tries again, unless a newer
|
|
167
|
+
// attempt has already replaced it.
|
|
168
|
+
started.catch(() => {
|
|
169
|
+
if (guardProxy === started)
|
|
170
|
+
guardProxy = undefined;
|
|
171
|
+
});
|
|
172
|
+
return started;
|
|
173
|
+
};
|
|
174
|
+
const modelFetch = deps.createModelFetch
|
|
175
|
+
? deps.createModelFetch(networkGuard)
|
|
176
|
+
: createGuardProxyFetch(getGuardProxy);
|
|
177
|
+
const targetFetch = createGuardedFetch(modelFetch, networkGuard);
|
|
178
|
+
let closing;
|
|
179
|
+
const close = () => (closing ??= (async () => {
|
|
180
|
+
isClosed = true;
|
|
181
|
+
if ('close' in modelFetch)
|
|
182
|
+
await modelFetch.close();
|
|
183
|
+
if (guardProxy) {
|
|
184
|
+
const started = await guardProxy.catch(() => undefined);
|
|
185
|
+
await started?.close().catch(() => undefined);
|
|
146
186
|
}
|
|
147
|
-
|
|
187
|
+
})());
|
|
188
|
+
// One health state per backend set: a rebuilt set (config change) starts fresh.
|
|
189
|
+
const policyDeps = { health: deps.providerHealth ?? createProviderHealth(deps.policy?.now ? { now: deps.policy.now } : {}), ...deps.policy };
|
|
190
|
+
const guarded = (name, search, healthKey) => withSearchPolicy(name, search, policyDeps, healthKey);
|
|
148
191
|
const createDuckDuckGo = () => createDuckDuckGoSearch({ searchHtml: (query) => fetchDuckDuckGoHtml(query, { fetchImpl }) });
|
|
149
192
|
function buildProviderSearch(name) {
|
|
150
193
|
switch (name) {
|
|
@@ -165,34 +208,12 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
165
208
|
return createDuckDuckGo();
|
|
166
209
|
}
|
|
167
210
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
: config.search.provider === 'brave'
|
|
173
|
-
? createBraveSearch({ apiKey: process.env.PI_WEB_AGENT_BRAVE_API_KEY, fetchImpl })
|
|
174
|
-
: config.search.provider === 'youcom'
|
|
175
|
-
? createYouComSearch({ apiKey: process.env.YDC_API_KEY, fetchImpl })
|
|
176
|
-
: config.search.provider === 'exa'
|
|
177
|
-
? createExaSearch({ apiKey: process.env.EXA_API_KEY, fetchImpl })
|
|
178
|
-
: config.search.provider === 'tavily'
|
|
179
|
-
? createTavilySearch({ apiKey: process.env.TAVILY_API_KEY, fetchImpl })
|
|
180
|
-
: createDuckDuckGo();
|
|
181
|
-
if (config.search.provider === 'searxng' && config.search.fallback === 'duckduckgo') {
|
|
182
|
-
search = withSearchFallback(search, createDuckDuckGo(), 'searxng');
|
|
183
|
-
}
|
|
184
|
-
if (config.search.provider === 'brave' && config.search.fallback === 'duckduckgo') {
|
|
185
|
-
search = withSearchFallback(search, createDuckDuckGo(), 'brave');
|
|
186
|
-
}
|
|
187
|
-
if (config.search.provider === 'youcom' && config.search.fallback === 'duckduckgo') {
|
|
188
|
-
search = withSearchFallback(search, createDuckDuckGo(), 'youcom');
|
|
189
|
-
}
|
|
190
|
-
if (config.search.provider === 'exa' && config.search.fallback === 'duckduckgo') {
|
|
191
|
-
search = withSearchFallback(search, createDuckDuckGo(), 'exa');
|
|
192
|
-
}
|
|
193
|
-
if (config.search.provider === 'tavily' && config.search.fallback === 'duckduckgo') {
|
|
194
|
-
search = withSearchFallback(search, createDuckDuckGo(), 'tavily');
|
|
211
|
+
const primarySearch = guarded(config.search.provider, buildProviderSearch(config.search.provider));
|
|
212
|
+
const chain = [primarySearch];
|
|
213
|
+
if (config.search.provider !== 'duckduckgo' && config.search.fallback === 'duckduckgo') {
|
|
214
|
+
chain.push(guarded('duckduckgo', createDuckDuckGo()));
|
|
195
215
|
}
|
|
216
|
+
let search = chainSearch(chain, policyDeps);
|
|
196
217
|
const fanoutConfig = config.search.fanout;
|
|
197
218
|
if (fanoutConfig && fanoutConfig.mode !== 'off') {
|
|
198
219
|
const baseNames = fanoutConfig.providers && fanoutConfig.providers.length > 0
|
|
@@ -203,9 +224,12 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
203
224
|
? [...baseNames, 'duckduckgo']
|
|
204
225
|
: baseNames;
|
|
205
226
|
const ordered = [config.search.provider, ...providerNames.filter((n) => n !== config.search.provider)].filter((n, i, arr) => arr.indexOf(n) === i);
|
|
227
|
+
const timeoutMs = deps.fanoutTimeoutMs ?? FANOUT_PROVIDER_TIMEOUT_MS;
|
|
206
228
|
search = createFanoutSearch({
|
|
207
|
-
|
|
208
|
-
|
|
229
|
+
// The timeout sits inside the policy so a stalled provider gets its one transient retry.
|
|
230
|
+
providers: ordered.map((name) => ({ name, search: guarded(name, withCallTimeout(buildProviderSearch(name), timeoutMs, name)) })),
|
|
231
|
+
mode: fanoutConfig.mode,
|
|
232
|
+
timeoutMs
|
|
209
233
|
});
|
|
210
234
|
}
|
|
211
235
|
// Keep the keyless Tavily safety net for the no-key DuckDuckGo default, even under fanout —
|
|
@@ -214,32 +238,35 @@ export function createBackendSet(config = DEFAULT_BACKEND_CONFIG, deps = {}) {
|
|
|
214
238
|
const keylessFallbackDisabled = process.env.PI_WEB_AGENT_DISABLE_KEYLESS_FALLBACK === '1';
|
|
215
239
|
const usingDuckDuckGoDefault = config.search.provider === 'duckduckgo' || !config.search.provider;
|
|
216
240
|
if (usingDuckDuckGoDefault && !keylessFallbackDisabled) {
|
|
217
|
-
|
|
241
|
+
// chainSearch only falls back on non-terminal failures, so a terminal result or bad_request never reaches keyless Tavily.
|
|
242
|
+
search = chainSearch([search, guarded('tavily', createTavilySearch({ keyless: true, fetchImpl }), 'tavily-keyless')], policyDeps);
|
|
218
243
|
}
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
244
|
+
const httpFetcher = createHttpFetcher({ fetchImpl: targetFetch });
|
|
245
|
+
const httpFetch = createHttpFetch({ fetchPage: ({ url, query }) => httpFetcher(url, query) });
|
|
246
|
+
const firecrawlFetcher = config.fetch.baseUrl
|
|
247
|
+
? createFirecrawlFetch({
|
|
248
|
+
baseUrl: config.fetch.baseUrl,
|
|
249
|
+
apiKey: config.fetch.apiKey ?? process.env.PI_WEB_AGENT_FIRECRAWL_API_KEY,
|
|
250
|
+
options: config.fetch.options,
|
|
251
|
+
fetchImpl
|
|
252
|
+
})
|
|
253
|
+
: invalidFirecrawlFetch();
|
|
254
|
+
const fetchPage = config.fetch.provider === 'firecrawl'
|
|
255
|
+
? withFetchPolicy(createHttpFetch({ fetchPage: ({ url, query }) => firecrawlFetcher(url, query) }), config.fetch.fallback === 'http' ? httpFetch : undefined, policyDeps)
|
|
231
256
|
: httpFetch;
|
|
232
|
-
if (config.fetch.provider === 'firecrawl' && config.fetch.fallback === 'http') {
|
|
233
|
-
fetchPage = withFetchFallback(fetchPage, httpFetch);
|
|
234
|
-
}
|
|
235
257
|
const fetchPageWithReaders = createSpecialContentResolver({
|
|
236
|
-
readers: [
|
|
258
|
+
readers: [
|
|
259
|
+
createGithubReader({ fetchImpl: targetFetch }),
|
|
260
|
+
createPdfReader({ fetchImpl: targetFetch }),
|
|
261
|
+
createYoutubeReader({ fetchImpl: targetFetch })
|
|
262
|
+
],
|
|
237
263
|
fallback: fetchPage
|
|
238
264
|
});
|
|
239
|
-
const headlessPage = (url) =>
|
|
265
|
+
const headlessPage = ({ url, query }) => headlessFetch(url, { query, guard: networkGuard, guardProxy: getGuardProxy });
|
|
240
266
|
return {
|
|
241
267
|
search,
|
|
242
|
-
fetchPage: fetchPageWithReaders,
|
|
243
|
-
headlessFetch: createHeadlessFetch({ fetchPage: headlessPage })
|
|
268
|
+
fetchPage: withTargetGuard(fetchPageWithReaders, networkGuard, config.fetch.provider === 'firecrawl' ? 'firecrawl' : 'http'),
|
|
269
|
+
headlessFetch: createHeadlessFetch({ fetchPage: headlessPage }),
|
|
270
|
+
close
|
|
244
271
|
};
|
|
245
272
|
}
|