feat(tools): resilient web search and fetch across all providers (#836)
- Add exponential backoff retry to DuckDuckGo adapter (3 attempts with
jitter) to handle transient rate-limiting and connection errors.
- Add native fetch() fallback in WebFetch when axios hangs with custom
DNS lookup in bundled contexts.
- Prevent broken native-path fallback for web search on OpenAI shim
providers (minimax, moonshot, nvidia-nim, etc.) that do not support
Anthropic's web_search_20250305 tool.
- Cherry-pick existing fixes:
- a48bd56: cover codex/minimax/nvidia-nim in getSmallFastModel()
- 31f0b68: 45s budget + raw-markdown fallback for secondary model
- 446c1e8: sparse Codex /responses payload parsing
- ae3f0b2: echo reasoning_content on assistant tool-call messages
- Fix domainCheck.test.ts mock modules to include isFirstPartyAnthropicBaseUrl
and isGithubNativeAnthropicMode exports.
Co-authored-by: OpenClaude <openclaude@gitlawb.com>
This commit is contained in:
@@ -203,6 +203,61 @@ function buildCodexWebSearchInstructions(): string {
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
function pushCodexTextResult(
|
||||
results: (SearchResult | string)[],
|
||||
value: unknown,
|
||||
): void {
|
||||
if (typeof value !== 'string') return
|
||||
const trimmed = value.trim()
|
||||
if (trimmed) {
|
||||
results.push(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
function addCodexSource(
|
||||
sourceMap: Map<string, { title: string; url: string }>,
|
||||
source: unknown,
|
||||
): void {
|
||||
if (typeof source?.url !== 'string' || !source.url) return
|
||||
sourceMap.set(source.url, {
|
||||
title:
|
||||
typeof source.title === 'string' && source.title
|
||||
? source.title
|
||||
: source.url,
|
||||
url: source.url,
|
||||
})
|
||||
}
|
||||
|
||||
function getCodexSources(item: Record<string, any>): unknown[] {
|
||||
if (Array.isArray(item.action?.sources)) {
|
||||
return item.action.sources
|
||||
}
|
||||
if (Array.isArray(item.sources)) {
|
||||
return item.sources
|
||||
}
|
||||
if (Array.isArray(item.result?.sources)) {
|
||||
return item.result.sources
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function extractCodexWebSearchFailure(item: Record<string, any>): string | undefined {
|
||||
// Codex web_search_call items can carry a status field. When the tool
|
||||
// call fails (rate limit, upstream error, model-side guardrail), the
|
||||
// parser should surface a meaningful error rather than the generic
|
||||
// "No results found." fallback. Shape observed across recent payloads:
|
||||
// { type: 'web_search_call', status: 'failed', error: { message?: string } }
|
||||
// { type: 'web_search_call', status: 'failed', action: { error?: { message?: string } } }
|
||||
if (item?.status !== 'failed') return undefined
|
||||
const reason =
|
||||
(typeof item.error?.message === 'string' && item.error.message) ||
|
||||
(typeof item.action?.error?.message === 'string' &&
|
||||
item.action.error.message) ||
|
||||
(typeof item.error === 'string' && item.error) ||
|
||||
undefined
|
||||
return reason ? `Web search failed: ${reason}` : 'Web search failed.'
|
||||
}
|
||||
|
||||
function makeOutputFromCodexWebSearchResponse(
|
||||
response: Record<string, unknown>,
|
||||
query: string,
|
||||
@@ -214,18 +269,12 @@ function makeOutputFromCodexWebSearchResponse(
|
||||
|
||||
for (const item of output) {
|
||||
if (item?.type === 'web_search_call') {
|
||||
const sources = Array.isArray(item.action?.sources)
|
||||
? item.action.sources
|
||||
: []
|
||||
for (const source of sources) {
|
||||
if (typeof source?.url !== 'string' || !source.url) continue
|
||||
sourceMap.set(source.url, {
|
||||
title:
|
||||
typeof source.title === 'string' && source.title
|
||||
? source.title
|
||||
: source.url,
|
||||
url: source.url,
|
||||
})
|
||||
const failure = extractCodexWebSearchFailure(item)
|
||||
if (failure) {
|
||||
results.push(failure)
|
||||
}
|
||||
for (const source of getCodexSources(item)) {
|
||||
addCodexSource(sourceMap, source)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -235,11 +284,12 @@ function makeOutputFromCodexWebSearchResponse(
|
||||
}
|
||||
|
||||
for (const part of item.content) {
|
||||
if (part?.type === 'output_text' && typeof part.text === 'string') {
|
||||
const trimmed = part.text.trim()
|
||||
if (trimmed) {
|
||||
results.push(trimmed)
|
||||
}
|
||||
if (part?.type === 'output_text' || part?.type === 'text') {
|
||||
pushCodexTextResult(results, part.text)
|
||||
}
|
||||
|
||||
for (const source of getCodexSources(part)) {
|
||||
addCodexSource(sourceMap, source)
|
||||
}
|
||||
|
||||
const annotations = Array.isArray(part?.annotations)
|
||||
@@ -247,23 +297,13 @@ function makeOutputFromCodexWebSearchResponse(
|
||||
: []
|
||||
for (const annotation of annotations) {
|
||||
if (annotation?.type !== 'url_citation') continue
|
||||
if (typeof annotation.url !== 'string' || !annotation.url) continue
|
||||
sourceMap.set(annotation.url, {
|
||||
title:
|
||||
typeof annotation.title === 'string' && annotation.title
|
||||
? annotation.title
|
||||
: annotation.url,
|
||||
url: annotation.url,
|
||||
})
|
||||
addCodexSource(sourceMap, annotation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length === 0 && typeof response.output_text === 'string') {
|
||||
const trimmed = response.output_text.trim()
|
||||
if (trimmed) {
|
||||
results.push(trimmed)
|
||||
}
|
||||
if (results.length === 0) {
|
||||
pushCodexTextResult(results, response.output_text)
|
||||
}
|
||||
|
||||
if (sourceMap.size > 0) {
|
||||
@@ -273,6 +313,10 @@ function makeOutputFromCodexWebSearchResponse(
|
||||
})
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
results.push('No results found.')
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
results,
|
||||
@@ -280,6 +324,10 @@ function makeOutputFromCodexWebSearchResponse(
|
||||
}
|
||||
}
|
||||
|
||||
export const __test = {
|
||||
makeOutputFromCodexWebSearchResponse,
|
||||
}
|
||||
|
||||
async function runCodexWebSearch(
|
||||
input: Input,
|
||||
signal: AbortSignal,
|
||||
@@ -457,6 +505,19 @@ function shouldUseAdapterProvider(): boolean {
|
||||
return getAvailableProviders().length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the current provider has a working native or Codex
|
||||
* web-search fallback after an adapter failure. OpenAI shim providers
|
||||
* (moonshot, minimax, nvidia-nim, openai, github, etc.) do NOT support
|
||||
* Anthropic's web_search_20250305 tool, so falling through to the native
|
||||
* path silently produces "Did 0 searches".
|
||||
*/
|
||||
function hasNativeSearchFallback(): boolean {
|
||||
if (isCodexResponsesWebSearchEnabled()) return true
|
||||
const provider = getAPIProvider()
|
||||
return provider === 'firstParty' || provider === 'vertex' || provider === 'foundry'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool export
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -609,6 +670,17 @@ export const WebSearchTool = buildTool({
|
||||
// Auto mode: only fall through on transient errors (network, timeout, 5xx).
|
||||
// Config / guardrail errors (SSRF, HTTPS, bad URL, etc.) must surface.
|
||||
if (!isTransientError(err)) throw err
|
||||
// No viable fallback for this provider — surface the adapter error
|
||||
// instead of falling through to a broken native path.
|
||||
if (!hasNativeSearchFallback()) {
|
||||
const provider = getAPIProvider()
|
||||
const errMsg = err instanceof Error ? err.message : String(err)
|
||||
throw new Error(
|
||||
`Web search is unavailable for provider "${provider}". ` +
|
||||
`The search adapter failed (${errMsg}). ` +
|
||||
`Try switching to a provider with built-in web search (e.g. Anthropic, Codex) or try again later.`,
|
||||
)
|
||||
}
|
||||
console.error(
|
||||
`[web-search] Adapter failed, falling through to native: ${err}`,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user