Merge remote-tracking branch 'origin/farm/8d096e89/web-search-freezes-on-windows-no-hard-ti'
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { getEnvApiKey } from "@oh-my-pi/pi-ai";
|
||||
import { findCredential } from "./search/providers/utils";
|
||||
import { findCredential, withHardTimeout } from "./search/providers/utils";
|
||||
|
||||
const KAGI_SEARCH_URL = "https://kagi.com/api/v0/search";
|
||||
|
||||
@@ -138,7 +138,7 @@ export async function searchWithKagi(query: string, options: KagiSearchOptions =
|
||||
|
||||
const response = await fetch(requestUrl, {
|
||||
headers: getAuthHeaders(apiKey),
|
||||
signal: options.signal,
|
||||
signal: withHardTimeout(options.signal),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw parseKagiErrorResponse(response.status, await response.text());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getEnvApiKey } from "@oh-my-pi/pi-ai";
|
||||
import { findCredential } from "./search/providers/utils";
|
||||
import { findCredential, withHardTimeout } from "./search/providers/utils";
|
||||
|
||||
const PARALLEL_API_URL = "https://api.parallel.ai";
|
||||
const PARALLEL_SEARCH_URL = `${PARALLEL_API_URL}/v1beta/search`;
|
||||
@@ -304,7 +304,7 @@ export async function searchWithParallel(
|
||||
max_chars_per_result: options.maxCharsPerResult ?? 10_000,
|
||||
},
|
||||
}),
|
||||
signal: options.signal,
|
||||
signal: withHardTimeout(options.signal),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw parseParallelErrorResponse(response.status, await response.text());
|
||||
@@ -335,7 +335,7 @@ export async function extractWithParallel(
|
||||
excerpts: options.excerpts ?? true,
|
||||
full_content: options.fullContent ?? false,
|
||||
}),
|
||||
signal: options.signal,
|
||||
signal: withHardTimeout(options.signal),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw parseParallelErrorResponse(response.status, await response.text());
|
||||
|
||||
@@ -14,6 +14,7 @@ import webSearchSystemPrompt from "../../prompts/system/web-search.md" with { ty
|
||||
import webSearchDescription from "../../prompts/tools/web-search.md" with { type: "text" };
|
||||
import type { ToolSession } from "../../tools";
|
||||
import { formatAge } from "../../tools/render-utils";
|
||||
import { throwIfAborted } from "../../tools/tool-errors";
|
||||
import { getSearchProvider, getSearchProviderLabel, resolveProviderChain, type SearchProvider } from "./provider";
|
||||
import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render";
|
||||
import type { SearchProviderId, SearchResponse } from "./types";
|
||||
@@ -161,6 +162,12 @@ async function executeSearch(
|
||||
details: { response },
|
||||
};
|
||||
} catch (error) {
|
||||
// Surface user-initiated cancellation immediately so the session sees
|
||||
// a clean abort instead of a generic "all providers failed" message.
|
||||
// Without this, an AbortError from `fetch()` is treated as a provider
|
||||
// failure and the loop falls through to the next provider (or to the
|
||||
// summary error), masking the cancellation.
|
||||
throwIfAborted(signal);
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,12 +24,12 @@ import type {
|
||||
import { SearchProviderError } from "../../../web/search/types";
|
||||
import type { SearchParams } from "./base";
|
||||
import { SearchProvider } from "./base";
|
||||
import { withHardTimeout } from "./utils";
|
||||
|
||||
const DEFAULT_MODEL = "claude-haiku-4-5";
|
||||
const DEFAULT_MAX_TOKENS = 4096;
|
||||
const WEB_SEARCH_TOOL_NAME = "web_search";
|
||||
const WEB_SEARCH_TOOL_TYPE = "web_search_20250305";
|
||||
|
||||
export interface AnthropicSearchParams {
|
||||
query: string;
|
||||
system_prompt?: string;
|
||||
@@ -118,7 +118,7 @@ async function callSearch(
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
signal: withHardTimeout(signal),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { SearchProviderError } from "../../../web/search/types";
|
||||
import { clampNumResults, dateToAgeSeconds } from "../utils";
|
||||
import type { SearchParams } from "./base";
|
||||
import { SearchProvider } from "./base";
|
||||
import { isApiKeyAvailable } from "./utils";
|
||||
import { isApiKeyAvailable, withHardTimeout } from "./utils";
|
||||
|
||||
const BRAVE_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search";
|
||||
const DEFAULT_NUM_RESULTS = 10;
|
||||
@@ -85,7 +85,7 @@ async function callBraveSearch(
|
||||
Accept: "application/json",
|
||||
"X-Subscription-Token": apiKey,
|
||||
},
|
||||
signal: params.signal,
|
||||
signal: withHardTimeout(params.signal),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
||||
import { SearchProviderError } from "../../../web/search/types";
|
||||
import type { SearchParams } from "./base";
|
||||
import { SearchProvider } from "./base";
|
||||
import { withHardTimeout } from "./utils";
|
||||
|
||||
const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
||||
const CODEX_RESPONSES_PATH = "/codex/responses";
|
||||
@@ -338,7 +339,7 @@ async function callCodexSearch(
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: options.signal,
|
||||
signal: withHardTimeout(options.signal),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { SearchProviderError } from "../../../web/search/types";
|
||||
import { dateToAgeSeconds } from "../utils";
|
||||
import type { SearchParams } from "./base";
|
||||
import { SearchProvider } from "./base";
|
||||
import { withHardTimeout } from "./utils";
|
||||
|
||||
const EXA_API_URL = "https://api.exa.ai/search";
|
||||
|
||||
@@ -180,7 +181,7 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
|
||||
"x-api-key": apiKey,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: params.signal,
|
||||
signal: withHardTimeout(params.signal),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { SearchCitation, SearchResponse, SearchSource } from "../../../web/
|
||||
import { SearchProviderError } from "../../../web/search/types";
|
||||
import type { SearchParams } from "./base";
|
||||
import { SearchProvider } from "./base";
|
||||
import { withHardTimeout } from "./utils";
|
||||
|
||||
const DEFAULT_ENDPOINT = "https://cloudcode-pa.googleapis.com";
|
||||
const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
|
||||
@@ -310,7 +311,7 @@ async function callGeminiSearch(
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal,
|
||||
signal: withHardTimeout(signal),
|
||||
});
|
||||
const urlFor = (attempt: number) =>
|
||||
`${endpoints[Math.min(attempt, endpoints.length - 1)]}/v1internal:streamGenerateContent?alt=sse`;
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
||||
import { SearchProviderError } from "../../../web/search/types";
|
||||
import type { SearchParams } from "./base";
|
||||
import { SearchProvider } from "./base";
|
||||
import { isApiKeyAvailable } from "./utils";
|
||||
import { isApiKeyAvailable, withHardTimeout } from "./utils";
|
||||
|
||||
const JINA_SEARCH_URL = "https://s.jina.ai";
|
||||
|
||||
@@ -41,7 +41,7 @@ async function callJinaSearch(apiKey: string, query: string, signal?: AbortSigna
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
signal,
|
||||
signal: withHardTimeout(signal),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { SearchProviderError } from "../../../web/search/types";
|
||||
import { clampNumResults, dateToAgeSeconds } from "../utils";
|
||||
import type { SearchParams } from "./base";
|
||||
import { SearchProvider } from "./base";
|
||||
import { findCredential, isApiKeyAvailable } from "./utils";
|
||||
import { findCredential, isApiKeyAvailable, withHardTimeout } from "./utils";
|
||||
|
||||
const KIMI_SEARCH_URL = "https://api.kimi.com/coding/v1/search";
|
||||
|
||||
@@ -78,7 +78,7 @@ async function callKimiSearch(
|
||||
enable_page_crawling: params.includeContent,
|
||||
timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
|
||||
}),
|
||||
signal: params.signal,
|
||||
signal: withHardTimeout(params.signal),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { SearchProviderError } from "../../../web/search/types";
|
||||
import { dateToAgeSeconds } from "../utils";
|
||||
import type { SearchParams } from "./base";
|
||||
import { SearchProvider } from "./base";
|
||||
import { withHardTimeout } from "./utils";
|
||||
|
||||
const PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions";
|
||||
const PERPLEXITY_OAUTH_ASK_URL = "https://www.perplexity.ai/rest/sse/perplexity_ask";
|
||||
@@ -247,7 +248,7 @@ async function callPerplexityApi(
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
signal,
|
||||
signal: withHardTimeout(signal),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -364,7 +365,7 @@ async function callPerplexityOAuth(
|
||||
skip_search_enabled: true,
|
||||
},
|
||||
}),
|
||||
signal: params.signal,
|
||||
signal: withHardTimeout(params.signal),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -31,6 +31,7 @@ import { SearchProviderError } from "../../../web/search/types";
|
||||
import { clampNumResults, dateToAgeSeconds } from "../utils";
|
||||
import type { SearchParams } from "./base";
|
||||
import { SearchProvider } from "./base";
|
||||
import { withHardTimeout } from "./utils";
|
||||
|
||||
const DEFAULT_NUM_RESULTS = 10;
|
||||
const MAX_NUM_RESULTS = 20;
|
||||
@@ -211,7 +212,7 @@ async function callSearXNGSearch(
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers,
|
||||
signal: params.signal,
|
||||
signal: withHardTimeout(params.signal),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
||||
import { SearchProviderError } from "../../../web/search/types";
|
||||
import type { SearchParams } from "./base";
|
||||
import { SearchProvider } from "./base";
|
||||
import { findCredential, isApiKeyAvailable } from "./utils";
|
||||
import { findCredential, isApiKeyAvailable, withHardTimeout } from "./utils";
|
||||
|
||||
const SYNTHETIC_SEARCH_URL = "https://api.synthetic.new/v2/search";
|
||||
|
||||
@@ -43,7 +43,7 @@ async function callSyntheticSearch(
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
signal,
|
||||
signal: withHardTimeout(signal),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { SearchProviderError } from "../../../web/search/types";
|
||||
import { clampNumResults, dateToAgeSeconds } from "../utils";
|
||||
import type { SearchParams } from "./base";
|
||||
import { SearchProvider } from "./base";
|
||||
import { findCredential, isApiKeyAvailable } from "./utils";
|
||||
import { findCredential, isApiKeyAvailable, withHardTimeout } from "./utils";
|
||||
|
||||
const TAVILY_SEARCH_URL = "https://api.tavily.com/search";
|
||||
const DEFAULT_NUM_RESULTS = 5;
|
||||
@@ -92,7 +92,7 @@ async function callTavilySearch(apiKey: string, params: TavilySearchParams): Pro
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(buildRequestBody(params)),
|
||||
signal: params.signal,
|
||||
signal: withHardTimeout(params.signal),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -49,6 +49,36 @@ export async function isApiKeyAvailable(findApiKey: () => string | null | Promis
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default hard ceiling for a single web-search round-trip. 60s tolerates
|
||||
* legitimate slow LLM-mediated responses (anthropic web_search_20250305,
|
||||
* perplexity, gemini, codex) while still guaranteeing the session unfreezes
|
||||
* within a minute if Bun's `AbortSignal` fails to propagate on Windows.
|
||||
*
|
||||
* Pure search APIs (brave, exa, jina, tavily, searxng, synthetic, zai)
|
||||
* settle far faster in practice; reusing the same ceiling keeps the wiring
|
||||
* uniform without compromising correctness.
|
||||
*/
|
||||
export const SEARCH_HARD_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Compose a caller-supplied {@link AbortSignal} with a hard timeout so an
|
||||
* outbound `fetch()` is guaranteed to settle within `ms` even when the
|
||||
* runtime fails to propagate cancellation to the underlying transport.
|
||||
*
|
||||
* Bun's WinHTTP backend on Windows is known to ignore `AbortSignal` once a
|
||||
* TCP/TLS connection stalls (oven-sh/bun#15275, oven-sh/bun#18536); without
|
||||
* this safety net a stalled web-search request freezes the entire session
|
||||
* because the user's Esc is never delivered to the native layer.
|
||||
*
|
||||
* @param signal - Caller cancellation signal, if any.
|
||||
* @param ms - Hard timeout in milliseconds. Defaults to {@link SEARCH_HARD_TIMEOUT_MS}.
|
||||
*/
|
||||
export function withHardTimeout(signal: AbortSignal | undefined, ms: number = SEARCH_HARD_TIMEOUT_MS): AbortSignal {
|
||||
const timeout = AbortSignal.timeout(ms);
|
||||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a provider's raw source list to the unified SearchSource shape,
|
||||
* clamped to the requested result count and annotated with ageSeconds.
|
||||
|
||||
@@ -11,7 +11,7 @@ import { SearchProviderError } from "../../../web/search/types";
|
||||
import { dateToAgeSeconds } from "../utils";
|
||||
import type { SearchParams } from "./base";
|
||||
import { SearchProvider } from "./base";
|
||||
import { findCredential, isApiKeyAvailable } from "./utils";
|
||||
import { findCredential, isApiKeyAvailable, withHardTimeout } from "./utils";
|
||||
|
||||
const ZAI_MCP_URL = "https://api.z.ai/api/mcp/web_search_prime/mcp";
|
||||
const ZAI_TOOL_NAME = "web_search_prime";
|
||||
@@ -73,7 +73,7 @@ async function callZaiTool(apiKey: string, args: Record<string, unknown>, signal
|
||||
arguments: args,
|
||||
},
|
||||
}),
|
||||
signal,
|
||||
signal: withHardTimeout(signal),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
Reference in New Issue
Block a user