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:
Kevin Codex
2026-04-23 01:14:00 +08:00
committed by GitHub
parent 3c4d8435c4
commit 531e3f1059
10 changed files with 703 additions and 81 deletions

View File

@@ -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}`,
)