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:
@@ -169,6 +169,14 @@ describe('Web search result count improvements', () => {
|
|||||||
|
|
||||||
expect(content).toMatch(/max_uses:\s*15/)
|
expect(content).toMatch(/max_uses:\s*15/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('codex web search path guarantees a non-empty result body', async () => {
|
||||||
|
const content = await file(
|
||||||
|
'tools/WebSearchTool/WebSearchTool.ts',
|
||||||
|
).text()
|
||||||
|
|
||||||
|
expect(content).toContain("results.push('No results found.')")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
convertCodexResponseToAnthropicMessage,
|
convertCodexResponseToAnthropicMessage,
|
||||||
convertToolsToResponsesTools,
|
convertToolsToResponsesTools,
|
||||||
} from './codexShim.js'
|
} from './codexShim.js'
|
||||||
|
import { __test as webSearchToolTest } from '../../tools/WebSearchTool/WebSearchTool.js'
|
||||||
|
|
||||||
const tempDirs: string[] = []
|
const tempDirs: string[] = []
|
||||||
const originalEnv = {
|
const originalEnv = {
|
||||||
@@ -609,6 +610,164 @@ describe('Codex request translation', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('recovers Codex web search text and sources from sparse completed response', () => {
|
||||||
|
const output = webSearchToolTest.makeOutputFromCodexWebSearchResponse(
|
||||||
|
{
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: 'web_search_call',
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
title: 'OpenClaude repo',
|
||||||
|
url: 'https://github.com/example/openclaude',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'OpenClaude is available on GitHub.',
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
title: 'Docs',
|
||||||
|
url: 'https://docs.example.com/openclaude',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'OpenClaude GitHub 2026',
|
||||||
|
0.42,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(output.results).toEqual([
|
||||||
|
'OpenClaude is available on GitHub.',
|
||||||
|
{
|
||||||
|
tool_use_id: 'codex-web-search',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
title: 'OpenClaude repo',
|
||||||
|
url: 'https://github.com/example/openclaude',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Docs',
|
||||||
|
url: 'https://docs.example.com/openclaude',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('falls back to a non-empty Codex web search result message', () => {
|
||||||
|
const output = webSearchToolTest.makeOutputFromCodexWebSearchResponse(
|
||||||
|
{ output: [] },
|
||||||
|
'OpenClaude GitHub 2026',
|
||||||
|
0.11,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(output.results).toEqual(['No results found.'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('surfaces Codex web search failure reason with a message', () => {
|
||||||
|
const output = webSearchToolTest.makeOutputFromCodexWebSearchResponse(
|
||||||
|
{
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: 'web_search_call',
|
||||||
|
status: 'failed',
|
||||||
|
error: { message: 'upstream search provider rate-limited' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'OpenClaude GitHub 2026',
|
||||||
|
0.05,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(output.results).toEqual([
|
||||||
|
'Web search failed: upstream search provider rate-limited',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('surfaces Codex web search failure reason nested under action.error', () => {
|
||||||
|
const output = webSearchToolTest.makeOutputFromCodexWebSearchResponse(
|
||||||
|
{
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: 'web_search_call',
|
||||||
|
status: 'failed',
|
||||||
|
action: { error: { message: 'query blocked' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'OpenClaude GitHub 2026',
|
||||||
|
0.05,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(output.results).toEqual(['Web search failed: query blocked'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles Codex web search failure with no reason attached', () => {
|
||||||
|
const output = webSearchToolTest.makeOutputFromCodexWebSearchResponse(
|
||||||
|
{
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: 'web_search_call',
|
||||||
|
status: 'failed',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'OpenClaude GitHub 2026',
|
||||||
|
0.05,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(output.results).toEqual(['Web search failed.'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a failure item does not suppress sources from a later message item', () => {
|
||||||
|
const output = webSearchToolTest.makeOutputFromCodexWebSearchResponse(
|
||||||
|
{
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: 'web_search_call',
|
||||||
|
status: 'failed',
|
||||||
|
error: { message: 'partial outage' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'output_text',
|
||||||
|
text: 'Partial results below.',
|
||||||
|
sources: [
|
||||||
|
{ title: 'Docs', url: 'https://docs.example.com/openclaude' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'OpenClaude GitHub 2026',
|
||||||
|
0.05,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(output.results).toEqual([
|
||||||
|
'Web search failed: partial outage',
|
||||||
|
'Partial results below.',
|
||||||
|
{
|
||||||
|
tool_use_id: 'codex-web-search',
|
||||||
|
content: [
|
||||||
|
{ title: 'Docs', url: 'https://docs.example.com/openclaude' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test('translates Codex SSE text stream into Anthropic events', async () => {
|
test('translates Codex SSE text stream into Anthropic events', async () => {
|
||||||
const responseText = [
|
const responseText = [
|
||||||
'event: response.output_item.added',
|
'event: response.output_item.added',
|
||||||
|
|||||||
87
src/tools/WebFetchTool/applyPromptFallback.test.ts
Normal file
87
src/tools/WebFetchTool/applyPromptFallback.test.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { afterEach, beforeEach, expect, mock, test } from 'bun:test'
|
||||||
|
|
||||||
|
// Mock the Anthropic-API-side before importing the module under test, so
|
||||||
|
// queryHaiku resolves into whatever the individual test wants (slow, failing,
|
||||||
|
// or successful). We preserve every other export from claude.js so unrelated
|
||||||
|
// transitive imports still work.
|
||||||
|
const haikuMock = mock()
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
haikuMock.mockReset()
|
||||||
|
const actual = await import('../../services/api/claude.js')
|
||||||
|
mock.module('../../services/api/claude.js', () => ({
|
||||||
|
...actual,
|
||||||
|
queryHaiku: haikuMock,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function runApply(markdown = 'Hello world.', signal?: AbortSignal): Promise<string> {
|
||||||
|
const nonce = `${Date.now()}-${Math.random()}`
|
||||||
|
const { applyPromptToMarkdown } =
|
||||||
|
await import(`./utils.js?ts=${nonce}`)
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
return applyPromptToMarkdown(
|
||||||
|
'summarize',
|
||||||
|
markdown,
|
||||||
|
signal ?? ctrl.signal,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('returns raw truncated markdown when queryHaiku throws', async () => {
|
||||||
|
haikuMock.mockImplementation(async () => {
|
||||||
|
throw new Error('MiniMax rejected the model name')
|
||||||
|
})
|
||||||
|
|
||||||
|
const output = await runApply('Gitlawb homepage content.')
|
||||||
|
expect(output).toContain('[Secondary-model summarization unavailable')
|
||||||
|
expect(output).toContain('Gitlawb homepage content.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns raw truncated markdown when queryHaiku simulates a timeout', async () => {
|
||||||
|
// Simulating raceWithTimeout's rejection path directly — we can't actually
|
||||||
|
// wait 45s in a test. The error shape matches what raceWithTimeout produces.
|
||||||
|
haikuMock.mockImplementation(async () => {
|
||||||
|
const err = new Error('Secondary-model summarization timed out after 45000ms')
|
||||||
|
;(err as NodeJS.ErrnoException).code = 'SECONDARY_MODEL_TIMEOUT'
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
|
||||||
|
const output = await runApply('Slow provider content.')
|
||||||
|
expect(output).toContain('[Secondary-model summarization unavailable')
|
||||||
|
expect(output).toContain('Slow provider content.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns the model response when queryHaiku succeeds', async () => {
|
||||||
|
haikuMock.mockImplementation(async () => ({
|
||||||
|
message: {
|
||||||
|
content: [{ type: 'text', text: 'This page is about GitLawb, an AI legal platform.' }],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const output = await runApply('some page content')
|
||||||
|
expect(output).toBe('This page is about GitLawb, an AI legal platform.')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns fallback when queryHaiku resolves with empty content', async () => {
|
||||||
|
haikuMock.mockImplementation(async () => ({ message: { content: [] } }))
|
||||||
|
|
||||||
|
const output = await runApply('some page content')
|
||||||
|
expect(output).toContain('[Secondary-model summarization unavailable')
|
||||||
|
expect(output).toContain('some page content')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('propagates AbortError from the caller signal', async () => {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
haikuMock.mockImplementation(async () => {
|
||||||
|
ctrl.abort()
|
||||||
|
return new Promise(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(runApply('content', ctrl.signal)).rejects.toThrow()
|
||||||
|
})
|
||||||
@@ -20,8 +20,11 @@ afterEach(() => {
|
|||||||
describe('checkDomainBlocklist', () => {
|
describe('checkDomainBlocklist', () => {
|
||||||
test('returns allowed without API call in OpenAI mode', async () => {
|
test('returns allowed without API call in OpenAI mode', async () => {
|
||||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
|
const actual = await import('../../utils/model/providers.js')
|
||||||
mock.module('../../utils/model/providers.js', () => ({
|
mock.module('../../utils/model/providers.js', () => ({
|
||||||
|
...actual,
|
||||||
getAPIProvider: () => 'openai',
|
getAPIProvider: () => 'openai',
|
||||||
|
isFirstPartyAnthropicBaseUrl: () => false,
|
||||||
}))
|
}))
|
||||||
const getSpy = mock(() =>
|
const getSpy = mock(() =>
|
||||||
Promise.resolve({ status: 200, data: { can_fetch: true } }),
|
Promise.resolve({ status: 200, data: { can_fetch: true } }),
|
||||||
@@ -37,8 +40,11 @@ describe('checkDomainBlocklist', () => {
|
|||||||
|
|
||||||
test('returns allowed without API call in Gemini mode', async () => {
|
test('returns allowed without API call in Gemini mode', async () => {
|
||||||
process.env.CLAUDE_CODE_USE_GEMINI = '1'
|
process.env.CLAUDE_CODE_USE_GEMINI = '1'
|
||||||
|
const actual = await import('../../utils/model/providers.js')
|
||||||
mock.module('../../utils/model/providers.js', () => ({
|
mock.module('../../utils/model/providers.js', () => ({
|
||||||
|
...actual,
|
||||||
getAPIProvider: () => 'gemini',
|
getAPIProvider: () => 'gemini',
|
||||||
|
isFirstPartyAnthropicBaseUrl: () => false,
|
||||||
}))
|
}))
|
||||||
const getSpy = mock(() =>
|
const getSpy = mock(() =>
|
||||||
Promise.resolve({ status: 200, data: { can_fetch: true } }),
|
Promise.resolve({ status: 200, data: { can_fetch: true } }),
|
||||||
@@ -57,8 +63,11 @@ describe('checkDomainBlocklist', () => {
|
|||||||
delete process.env.CLAUDE_CODE_USE_GEMINI
|
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||||
|
|
||||||
|
const actual = await import('../../utils/model/providers.js')
|
||||||
mock.module('../../utils/model/providers.js', () => ({
|
mock.module('../../utils/model/providers.js', () => ({
|
||||||
|
...actual,
|
||||||
getAPIProvider: () => 'firstParty',
|
getAPIProvider: () => 'firstParty',
|
||||||
|
isFirstPartyAnthropicBaseUrl: () => true,
|
||||||
}))
|
}))
|
||||||
const getSpy = mock(() =>
|
const getSpy = mock(() =>
|
||||||
Promise.resolve({ status: 200, data: { can_fetch: true } }),
|
Promise.resolve({ status: 200, data: { can_fetch: true } }),
|
||||||
|
|||||||
@@ -275,20 +275,76 @@ export async function getWithPermittedRedirects(
|
|||||||
if (depth > MAX_REDIRECTS) {
|
if (depth > MAX_REDIRECTS) {
|
||||||
throw new Error(`Too many redirects (exceeded ${MAX_REDIRECTS})`)
|
throw new Error(`Too many redirects (exceeded ${MAX_REDIRECTS})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const axiosConfig = {
|
||||||
|
signal,
|
||||||
|
timeout: FETCH_TIMEOUT_MS,
|
||||||
|
maxRedirects: 0,
|
||||||
|
responseType: 'arraybuffer' as const,
|
||||||
|
maxContentLength: MAX_HTTP_CONTENT_LENGTH,
|
||||||
|
lookup: ssrfGuardedLookup,
|
||||||
|
headers: {
|
||||||
|
Accept: 'text/markdown, text/html, */*',
|
||||||
|
'User-Agent': getWebFetchUserAgent(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await axios.get(url, {
|
return await axios.get(url, axiosConfig)
|
||||||
signal,
|
|
||||||
timeout: FETCH_TIMEOUT_MS,
|
|
||||||
maxRedirects: 0,
|
|
||||||
responseType: 'arraybuffer',
|
|
||||||
maxContentLength: MAX_HTTP_CONTENT_LENGTH,
|
|
||||||
lookup: ssrfGuardedLookup,
|
|
||||||
headers: {
|
|
||||||
Accept: 'text/markdown, text/html, */*',
|
|
||||||
'User-Agent': getWebFetchUserAgent(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Try native fetch as a fallback for timeout / network errors
|
||||||
|
// (Bun/Node bundled contexts occasionally hang with axios + custom lookup.)
|
||||||
|
const isTimeoutLike =
|
||||||
|
axios.isAxiosError(error) &&
|
||||||
|
(!error.response &&
|
||||||
|
(error.code === 'ECONNABORTED' ||
|
||||||
|
error.code === 'ETIMEDOUT' ||
|
||||||
|
error.message?.toLowerCase().includes('timeout')))
|
||||||
|
if (isTimeoutLike && !signal.aborted) {
|
||||||
|
try {
|
||||||
|
const fetchResponse = await fetch(url, {
|
||||||
|
signal,
|
||||||
|
redirect: 'manual',
|
||||||
|
headers: axiosConfig.headers,
|
||||||
|
})
|
||||||
|
// Handle redirects manually
|
||||||
|
if ([301, 302, 307, 308].includes(fetchResponse.status)) {
|
||||||
|
const redirectLocation = fetchResponse.headers.get('location')
|
||||||
|
if (!redirectLocation) {
|
||||||
|
throw new Error('Redirect missing Location header')
|
||||||
|
}
|
||||||
|
const redirectUrl = new URL(redirectLocation, url).toString()
|
||||||
|
if (redirectChecker(url, redirectUrl)) {
|
||||||
|
return getWithPermittedRedirects(
|
||||||
|
redirectUrl,
|
||||||
|
signal,
|
||||||
|
redirectChecker,
|
||||||
|
depth + 1,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
type: 'redirect' as const,
|
||||||
|
originalUrl: url,
|
||||||
|
redirectUrl,
|
||||||
|
statusCode: fetchResponse.status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const arrayBuffer = await fetchResponse.arrayBuffer()
|
||||||
|
// Build an AxiosResponse-like shape so downstream code stays happy
|
||||||
|
return {
|
||||||
|
data: new Uint8Array(arrayBuffer),
|
||||||
|
status: fetchResponse.status,
|
||||||
|
statusText: fetchResponse.statusText,
|
||||||
|
headers: Object.fromEntries(fetchResponse.headers.entries()),
|
||||||
|
config: axiosConfig,
|
||||||
|
request: undefined,
|
||||||
|
} as unknown as AxiosResponse<ArrayBuffer>
|
||||||
|
} catch {
|
||||||
|
// Fall through to original error handling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
axios.isAxiosError(error) &&
|
axios.isAxiosError(error) &&
|
||||||
error.response &&
|
error.response &&
|
||||||
@@ -489,6 +545,58 @@ export async function getURLMarkdownContent(
|
|||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Budget for the secondary-model summarization after fetch. If the small-
|
||||||
|
// fast model is slow (e.g. a 200k-context third-party running a reasoning
|
||||||
|
// pass over ~100KB of markdown), we'd rather fall back to raw truncated
|
||||||
|
// markdown than hang the tool. Also keeps the worst-case WebFetch bounded
|
||||||
|
// to FETCH_TIMEOUT_MS + SECONDARY_MODEL_TIMEOUT_MS regardless of provider.
|
||||||
|
const SECONDARY_MODEL_TIMEOUT_MS = 45_000
|
||||||
|
|
||||||
|
function raceWithTimeout<T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
timeoutMs: number,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const err = new Error(`Secondary-model summarization timed out after ${timeoutMs}ms`)
|
||||||
|
;(err as NodeJS.ErrnoException).code = 'SECONDARY_MODEL_TIMEOUT'
|
||||||
|
reject(err)
|
||||||
|
}, timeoutMs)
|
||||||
|
const onAbort = () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
reject(new AbortError())
|
||||||
|
}
|
||||||
|
if (signal.aborted) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
reject(new AbortError())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signal.addEventListener('abort', onAbort, { once: true })
|
||||||
|
promise.then(
|
||||||
|
value => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
signal.removeEventListener('abort', onAbort)
|
||||||
|
resolve(value)
|
||||||
|
},
|
||||||
|
err => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
signal.removeEventListener('abort', onAbort)
|
||||||
|
reject(err)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFallbackMarkdownSummary(truncatedContent: string): string {
|
||||||
|
return [
|
||||||
|
'[Secondary-model summarization unavailable — returning raw fetched content.',
|
||||||
|
'This typically means the configured small-fast model took too long or errored.]',
|
||||||
|
'',
|
||||||
|
truncatedContent,
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
export async function applyPromptToMarkdown(
|
export async function applyPromptToMarkdown(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
markdownContent: string,
|
markdownContent: string,
|
||||||
@@ -508,18 +616,35 @@ export async function applyPromptToMarkdown(
|
|||||||
prompt,
|
prompt,
|
||||||
isPreapprovedDomain,
|
isPreapprovedDomain,
|
||||||
)
|
)
|
||||||
const assistantMessage = await queryHaiku({
|
let assistantMessage
|
||||||
systemPrompt: asSystemPrompt([]),
|
try {
|
||||||
userPrompt: modelPrompt,
|
assistantMessage = await raceWithTimeout(
|
||||||
signal,
|
queryHaiku({
|
||||||
options: {
|
systemPrompt: asSystemPrompt([]),
|
||||||
querySource: 'web_fetch_apply',
|
userPrompt: modelPrompt,
|
||||||
agents: [],
|
signal,
|
||||||
isNonInteractiveSession,
|
options: {
|
||||||
hasAppendSystemPrompt: false,
|
querySource: 'web_fetch_apply',
|
||||||
mcpTools: [],
|
agents: [],
|
||||||
},
|
isNonInteractiveSession,
|
||||||
})
|
hasAppendSystemPrompt: false,
|
||||||
|
mcpTools: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
SECONDARY_MODEL_TIMEOUT_MS,
|
||||||
|
signal,
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
// User interrupts and SIGINTs still propagate. Everything else (timeout,
|
||||||
|
// provider-side error, unsupported model on third-party endpoint) falls
|
||||||
|
// back to raw markdown so the user still gets usable content rather than
|
||||||
|
// a hang. Log so it's visible in debug traces.
|
||||||
|
if (err instanceof AbortError || (err as Error)?.name === 'AbortError') {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
logError(err)
|
||||||
|
return buildFallbackMarkdownSummary(truncatedContent)
|
||||||
|
}
|
||||||
|
|
||||||
// We need to bubble this up, so that the tool call throws, causing us to return
|
// We need to bubble this up, so that the tool call throws, causing us to return
|
||||||
// an is_error tool_use block to the server, and render a red dot in the UI.
|
// an is_error tool_use block to the server, and render a red dot in the UI.
|
||||||
@@ -534,5 +659,5 @@ export async function applyPromptToMarkdown(
|
|||||||
return contentBlock.text
|
return contentBlock.text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 'No response from model'
|
return buildFallbackMarkdownSummary(truncatedContent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,6 +203,61 @@ function buildCodexWebSearchInstructions(): string {
|
|||||||
].join(' ')
|
].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(
|
function makeOutputFromCodexWebSearchResponse(
|
||||||
response: Record<string, unknown>,
|
response: Record<string, unknown>,
|
||||||
query: string,
|
query: string,
|
||||||
@@ -214,18 +269,12 @@ function makeOutputFromCodexWebSearchResponse(
|
|||||||
|
|
||||||
for (const item of output) {
|
for (const item of output) {
|
||||||
if (item?.type === 'web_search_call') {
|
if (item?.type === 'web_search_call') {
|
||||||
const sources = Array.isArray(item.action?.sources)
|
const failure = extractCodexWebSearchFailure(item)
|
||||||
? item.action.sources
|
if (failure) {
|
||||||
: []
|
results.push(failure)
|
||||||
for (const source of sources) {
|
}
|
||||||
if (typeof source?.url !== 'string' || !source.url) continue
|
for (const source of getCodexSources(item)) {
|
||||||
sourceMap.set(source.url, {
|
addCodexSource(sourceMap, source)
|
||||||
title:
|
|
||||||
typeof source.title === 'string' && source.title
|
|
||||||
? source.title
|
|
||||||
: source.url,
|
|
||||||
url: source.url,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -235,11 +284,12 @@ function makeOutputFromCodexWebSearchResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const part of item.content) {
|
for (const part of item.content) {
|
||||||
if (part?.type === 'output_text' && typeof part.text === 'string') {
|
if (part?.type === 'output_text' || part?.type === 'text') {
|
||||||
const trimmed = part.text.trim()
|
pushCodexTextResult(results, part.text)
|
||||||
if (trimmed) {
|
}
|
||||||
results.push(trimmed)
|
|
||||||
}
|
for (const source of getCodexSources(part)) {
|
||||||
|
addCodexSource(sourceMap, source)
|
||||||
}
|
}
|
||||||
|
|
||||||
const annotations = Array.isArray(part?.annotations)
|
const annotations = Array.isArray(part?.annotations)
|
||||||
@@ -247,23 +297,13 @@ function makeOutputFromCodexWebSearchResponse(
|
|||||||
: []
|
: []
|
||||||
for (const annotation of annotations) {
|
for (const annotation of annotations) {
|
||||||
if (annotation?.type !== 'url_citation') continue
|
if (annotation?.type !== 'url_citation') continue
|
||||||
if (typeof annotation.url !== 'string' || !annotation.url) continue
|
addCodexSource(sourceMap, annotation)
|
||||||
sourceMap.set(annotation.url, {
|
|
||||||
title:
|
|
||||||
typeof annotation.title === 'string' && annotation.title
|
|
||||||
? annotation.title
|
|
||||||
: annotation.url,
|
|
||||||
url: annotation.url,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (results.length === 0 && typeof response.output_text === 'string') {
|
if (results.length === 0) {
|
||||||
const trimmed = response.output_text.trim()
|
pushCodexTextResult(results, response.output_text)
|
||||||
if (trimmed) {
|
|
||||||
results.push(trimmed)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sourceMap.size > 0) {
|
if (sourceMap.size > 0) {
|
||||||
@@ -273,6 +313,10 @@ function makeOutputFromCodexWebSearchResponse(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
results.push('No results found.')
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
query,
|
query,
|
||||||
results,
|
results,
|
||||||
@@ -280,6 +324,10 @@ function makeOutputFromCodexWebSearchResponse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const __test = {
|
||||||
|
makeOutputFromCodexWebSearchResponse,
|
||||||
|
}
|
||||||
|
|
||||||
async function runCodexWebSearch(
|
async function runCodexWebSearch(
|
||||||
input: Input,
|
input: Input,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
@@ -457,6 +505,19 @@ function shouldUseAdapterProvider(): boolean {
|
|||||||
return getAvailableProviders().length > 0
|
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
|
// Tool export
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -609,6 +670,17 @@ export const WebSearchTool = buildTool({
|
|||||||
// Auto mode: only fall through on transient errors (network, timeout, 5xx).
|
// Auto mode: only fall through on transient errors (network, timeout, 5xx).
|
||||||
// Config / guardrail errors (SSRF, HTTPS, bad URL, etc.) must surface.
|
// Config / guardrail errors (SSRF, HTTPS, bad URL, etc.) must surface.
|
||||||
if (!isTransientError(err)) throw err
|
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(
|
console.error(
|
||||||
`[web-search] Adapter failed, falling through to native: ${err}`,
|
`[web-search] Adapter failed, falling through to native: ${err}`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,12 +12,33 @@ const DDG_ANOMALY_HINT =
|
|||||||
'JINA_API_KEY, BING_API_KEY, MOJEEK_API_KEY, LINKUP_API_KEY — ' +
|
'JINA_API_KEY, BING_API_KEY, MOJEEK_API_KEY, LINKUP_API_KEY — ' +
|
||||||
'or use an Anthropic / Vertex / Foundry provider for native web search.'
|
'or use an Anthropic / Vertex / Foundry provider for native web search.'
|
||||||
|
|
||||||
|
const MAX_RETRIES = 3
|
||||||
|
const INITIAL_BACKOFF_MS = 1000
|
||||||
|
|
||||||
function isAnomalyError(message: string): boolean {
|
function isAnomalyError(message: string): boolean {
|
||||||
return /anomaly in the request|likely making requests too quickly/i.test(
|
return /anomaly in the request|likely making requests too quickly/i.test(
|
||||||
message,
|
message,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRetryableDDGError(err: unknown): boolean {
|
||||||
|
if (!(err instanceof Error)) return false
|
||||||
|
const msg = err.message.toLowerCase()
|
||||||
|
return (
|
||||||
|
msg.includes('anomaly') ||
|
||||||
|
msg.includes('too quickly') ||
|
||||||
|
msg.includes('rate limit') ||
|
||||||
|
msg.includes('timeout') ||
|
||||||
|
msg.includes('econnreset') ||
|
||||||
|
msg.includes('etimedout') ||
|
||||||
|
msg.includes('econnaborted')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise(r => setTimeout(r, ms))
|
||||||
|
}
|
||||||
|
|
||||||
export const duckduckgoProvider: SearchProvider = {
|
export const duckduckgoProvider: SearchProvider = {
|
||||||
name: 'duckduckgo',
|
name: 'duckduckgo',
|
||||||
|
|
||||||
@@ -36,31 +57,44 @@ export const duckduckgoProvider: SearchProvider = {
|
|||||||
throw new Error('duck-duck-scrape package not installed. Run: npm install duck-duck-scrape')
|
throw new Error('duck-duck-scrape package not installed. Run: npm install duck-duck-scrape')
|
||||||
}
|
}
|
||||||
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
|
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
|
||||||
// TODO: duck-duck-scrape doesn't accept AbortSignal — can't cancel in-flight searches
|
|
||||||
let response: Awaited<ReturnType<typeof search>>
|
let lastErr: unknown
|
||||||
try {
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||||
response = await search(input.query, { safeSearch: SafeSearchType.STRICT })
|
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
|
||||||
} catch (err) {
|
try {
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
// TODO: duck-duck-scrape doesn't accept AbortSignal — can't cancel in-flight searches
|
||||||
if (isAnomalyError(msg)) {
|
const response = await search(input.query, { safeSearch: SafeSearchType.STRICT })
|
||||||
throw new Error(DDG_ANOMALY_HINT)
|
|
||||||
|
const hits = applyDomainFilters(
|
||||||
|
response.results.map(r => ({
|
||||||
|
title: r.title || r.url,
|
||||||
|
url: r.url,
|
||||||
|
description: r.description ?? undefined,
|
||||||
|
})),
|
||||||
|
input,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
hits,
|
||||||
|
providerName: 'duckduckgo',
|
||||||
|
durationSeconds: (performance.now() - start) / 1000,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err
|
||||||
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
|
if (isAnomalyError(msg)) {
|
||||||
|
throw new Error(DDG_ANOMALY_HINT)
|
||||||
|
}
|
||||||
|
if (!isRetryableDDGError(err) || attempt === MAX_RETRIES - 1) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
// Exponential backoff with jitter: 1s, 2s, 4s +/- 20%
|
||||||
|
const baseDelay = INITIAL_BACKOFF_MS * Math.pow(2, attempt)
|
||||||
|
const jitter = baseDelay * 0.2 * (Math.random() * 2 - 1)
|
||||||
|
await sleep(baseDelay + jitter)
|
||||||
}
|
}
|
||||||
throw err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hits = applyDomainFilters(
|
throw lastErr
|
||||||
response.results.map(r => ({
|
|
||||||
title: r.title || r.url,
|
|
||||||
url: r.url,
|
|
||||||
description: r.description ?? undefined,
|
|
||||||
})),
|
|
||||||
input,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
hits,
|
|
||||||
providerName: 'duckduckgo',
|
|
||||||
durationSeconds: (performance.now() - start) / 1000,
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
import { afterEach, beforeEach, expect, mock, test } from 'bun:test'
|
||||||
|
|
||||||
import { saveGlobalConfig } from '../config.js'
|
import { saveGlobalConfig } from '../config.js'
|
||||||
import { getUserSpecifiedModelSetting } from './model.js'
|
import {
|
||||||
|
getDefaultHaikuModel,
|
||||||
|
getDefaultOpusModel,
|
||||||
|
getDefaultSonnetModel,
|
||||||
|
getSmallFastModel,
|
||||||
|
getUserSpecifiedModelSetting,
|
||||||
|
} from './model.js'
|
||||||
|
|
||||||
const SAVED_ENV = {
|
const SAVED_ENV = {
|
||||||
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
|
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
|
||||||
@@ -28,6 +34,11 @@ function restoreEnv(key: keyof typeof SAVED_ENV): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
// Other test files (notably modelOptions.github.test.ts) install a
|
||||||
|
// persistent mock.module for './providers.js' that overrides getAPIProvider
|
||||||
|
// globally. Without mock.restore() here, those overrides bleed into this
|
||||||
|
// suite and the provider-kind branches we're testing become unreachable.
|
||||||
|
mock.restore()
|
||||||
delete process.env.CLAUDE_CODE_USE_OPENAI
|
delete process.env.CLAUDE_CODE_USE_OPENAI
|
||||||
delete process.env.CLAUDE_CODE_USE_GEMINI
|
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||||
delete process.env.CLAUDE_CODE_USE_GITHUB
|
delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||||
@@ -113,3 +124,76 @@ test('github provider still reads OPENAI_MODEL (regression guard)', () => {
|
|||||||
expect(model).toBe('github:copilot')
|
expect(model).toBe('github:copilot')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Default model helpers — must not fall through to claude-haiku-4-5 etc. for
|
||||||
|
// OpenAI-shim providers whose endpoints don't speak Anthropic model names.
|
||||||
|
// Hitting that fallthrough caused WebFetch to hang for 60s on MiniMax/Codex
|
||||||
|
// because queryHaiku() shipped an unknown model id to the shim endpoint.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test('getSmallFastModel returns OPENAI_MODEL for MiniMax (regression: WebFetch hang)', () => {
|
||||||
|
process.env.MINIMAX_API_KEY = 'minimax-test'
|
||||||
|
process.env.OPENAI_MODEL = 'MiniMax-M2.5-highspeed'
|
||||||
|
|
||||||
|
expect(getSmallFastModel()).toBe('MiniMax-M2.5-highspeed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getSmallFastModel returns OPENAI_MODEL for Codex (regression)', () => {
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
|
process.env.OPENAI_BASE_URL = 'https://chatgpt.com/backend-api/codex'
|
||||||
|
process.env.OPENAI_MODEL = 'codexspark'
|
||||||
|
process.env.CODEX_API_KEY = 'codex-test'
|
||||||
|
process.env.CHATGPT_ACCOUNT_ID = 'acct_test'
|
||||||
|
|
||||||
|
expect(getSmallFastModel()).toBe('codexspark')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getSmallFastModel returns OPENAI_MODEL for NVIDIA NIM (regression)', () => {
|
||||||
|
process.env.NVIDIA_NIM = '1'
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
|
process.env.OPENAI_MODEL = 'nvidia/llama-3.1-nemotron-70b-instruct'
|
||||||
|
|
||||||
|
expect(getSmallFastModel()).toBe('nvidia/llama-3.1-nemotron-70b-instruct')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getDefaultOpusModel returns OPENAI_MODEL for MiniMax', () => {
|
||||||
|
process.env.MINIMAX_API_KEY = 'minimax-test'
|
||||||
|
process.env.OPENAI_MODEL = 'MiniMax-M2.7'
|
||||||
|
|
||||||
|
expect(getDefaultOpusModel()).toBe('MiniMax-M2.7')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getDefaultSonnetModel returns OPENAI_MODEL for NVIDIA NIM', () => {
|
||||||
|
process.env.NVIDIA_NIM = '1'
|
||||||
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||||
|
process.env.OPENAI_MODEL = 'nvidia/llama-3.1-nemotron-70b-instruct'
|
||||||
|
|
||||||
|
expect(getDefaultSonnetModel()).toBe('nvidia/llama-3.1-nemotron-70b-instruct')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getDefaultHaikuModel returns OPENAI_MODEL for MiniMax', () => {
|
||||||
|
process.env.MINIMAX_API_KEY = 'minimax-test'
|
||||||
|
process.env.OPENAI_MODEL = 'MiniMax-M2.5-highspeed'
|
||||||
|
|
||||||
|
expect(getDefaultHaikuModel()).toBe('MiniMax-M2.5-highspeed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('default helpers do not leak claude-* names to shim providers', () => {
|
||||||
|
// Umbrella guard: for each OpenAI-shim provider, none of the default-model
|
||||||
|
// helpers may return an Anthropic-branded model name. That was the source
|
||||||
|
// of the WebFetch 60s hang — MiniMax received "claude-haiku-4-5" and sat
|
||||||
|
// on the connection.
|
||||||
|
process.env.MINIMAX_API_KEY = 'minimax-test'
|
||||||
|
process.env.OPENAI_MODEL = 'MiniMax-M2.7'
|
||||||
|
|
||||||
|
for (const fn of [
|
||||||
|
getSmallFastModel,
|
||||||
|
getDefaultOpusModel,
|
||||||
|
getDefaultSonnetModel,
|
||||||
|
getDefaultHaikuModel,
|
||||||
|
]) {
|
||||||
|
const model = fn()
|
||||||
|
expect(model.toLowerCase()).not.toContain('claude')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -52,10 +52,25 @@ export function getSmallFastModel(): ModelName {
|
|||||||
if (getAPIProvider() === 'openai') {
|
if (getAPIProvider() === 'openai') {
|
||||||
return process.env.OPENAI_MODEL || 'gpt-4o-mini'
|
return process.env.OPENAI_MODEL || 'gpt-4o-mini'
|
||||||
}
|
}
|
||||||
|
// Codex provider — OPENAI_MODEL is always set for Codex profiles; only fall
|
||||||
|
// back to a codex-spark alias when an override env strips it.
|
||||||
|
if (getAPIProvider() === 'codex') {
|
||||||
|
return process.env.OPENAI_MODEL || 'codexspark'
|
||||||
|
}
|
||||||
// For GitHub Copilot provider
|
// For GitHub Copilot provider
|
||||||
if (getAPIProvider() === 'github') {
|
if (getAPIProvider() === 'github') {
|
||||||
return process.env.OPENAI_MODEL || 'github:copilot'
|
return process.env.OPENAI_MODEL || 'github:copilot'
|
||||||
}
|
}
|
||||||
|
// NVIDIA NIM — OPENAI_MODEL carries the user's active NIM model; use a
|
||||||
|
// small Meta Llama variant as the conservative fallback.
|
||||||
|
if (getAPIProvider() === 'nvidia-nim') {
|
||||||
|
return process.env.OPENAI_MODEL || 'meta/llama-3.1-8b-instruct'
|
||||||
|
}
|
||||||
|
// MiniMax — OPENAI_MODEL carries the active MiniMax model; fall back to
|
||||||
|
// the fastest tier (M2.5-highspeed) when missing.
|
||||||
|
if (getAPIProvider() === 'minimax') {
|
||||||
|
return process.env.OPENAI_MODEL || 'MiniMax-M2.5-highspeed'
|
||||||
|
}
|
||||||
return getDefaultHaikuModel()
|
return getDefaultHaikuModel()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +186,14 @@ export function getDefaultOpusModel(): ModelName {
|
|||||||
if (getAPIProvider() === 'github') {
|
if (getAPIProvider() === 'github') {
|
||||||
return process.env.OPENAI_MODEL || 'github:copilot'
|
return process.env.OPENAI_MODEL || 'github:copilot'
|
||||||
}
|
}
|
||||||
|
// NVIDIA NIM
|
||||||
|
if (getAPIProvider() === 'nvidia-nim') {
|
||||||
|
return process.env.OPENAI_MODEL || 'nvidia/llama-3.1-nemotron-70b-instruct'
|
||||||
|
}
|
||||||
|
// MiniMax — flagship tier for "opus"-equivalent.
|
||||||
|
if (getAPIProvider() === 'minimax') {
|
||||||
|
return process.env.OPENAI_MODEL || 'MiniMax-M2.7'
|
||||||
|
}
|
||||||
// 3P providers (Bedrock, Vertex, Foundry) — kept as a separate branch
|
// 3P providers (Bedrock, Vertex, Foundry) — kept as a separate branch
|
||||||
// even when values match, since 3P availability lags firstParty and
|
// even when values match, since 3P availability lags firstParty and
|
||||||
// these will diverge again at the next model launch.
|
// these will diverge again at the next model launch.
|
||||||
@@ -205,6 +228,14 @@ export function getDefaultSonnetModel(): ModelName {
|
|||||||
if (getAPIProvider() === 'github') {
|
if (getAPIProvider() === 'github') {
|
||||||
return process.env.OPENAI_MODEL || 'github:copilot'
|
return process.env.OPENAI_MODEL || 'github:copilot'
|
||||||
}
|
}
|
||||||
|
// NVIDIA NIM
|
||||||
|
if (getAPIProvider() === 'nvidia-nim') {
|
||||||
|
return process.env.OPENAI_MODEL || 'nvidia/llama-3.1-nemotron-70b-instruct'
|
||||||
|
}
|
||||||
|
// MiniMax — mid tier for "sonnet"-equivalent.
|
||||||
|
if (getAPIProvider() === 'minimax') {
|
||||||
|
return process.env.OPENAI_MODEL || 'MiniMax-M2.5'
|
||||||
|
}
|
||||||
// Default to Sonnet 4.5 for 3P since they may not have 4.6 yet
|
// Default to Sonnet 4.5 for 3P since they may not have 4.6 yet
|
||||||
if (getAPIProvider() !== 'firstParty') {
|
if (getAPIProvider() !== 'firstParty') {
|
||||||
return getModelStrings().sonnet45
|
return getModelStrings().sonnet45
|
||||||
@@ -237,6 +268,14 @@ export function getDefaultHaikuModel(): ModelName {
|
|||||||
if (getAPIProvider() === 'gemini') {
|
if (getAPIProvider() === 'gemini') {
|
||||||
return process.env.GEMINI_MODEL || 'gemini-2.0-flash-lite'
|
return process.env.GEMINI_MODEL || 'gemini-2.0-flash-lite'
|
||||||
}
|
}
|
||||||
|
// NVIDIA NIM
|
||||||
|
if (getAPIProvider() === 'nvidia-nim') {
|
||||||
|
return process.env.OPENAI_MODEL || 'meta/llama-3.1-8b-instruct'
|
||||||
|
}
|
||||||
|
// MiniMax — fastest tier for "haiku"-equivalent.
|
||||||
|
if (getAPIProvider() === 'minimax') {
|
||||||
|
return process.env.OPENAI_MODEL || 'MiniMax-M2.5-highspeed'
|
||||||
|
}
|
||||||
|
|
||||||
// Haiku 4.5 is available on all platforms (first-party, Foundry, Bedrock, Vertex)
|
// Haiku 4.5 is available on all platforms (first-party, Foundry, Bedrock, Vertex)
|
||||||
return getModelStrings().haiku45
|
return getModelStrings().haiku45
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ export function getAPIProvider(): APIProvider {
|
|||||||
if (isEnvTruthy(process.env.NVIDIA_NIM)) {
|
if (isEnvTruthy(process.env.NVIDIA_NIM)) {
|
||||||
return 'nvidia-nim'
|
return 'nvidia-nim'
|
||||||
}
|
}
|
||||||
if (isEnvTruthy(process.env.MINIMAX_API_KEY)) {
|
// MiniMax is signalled by a real API key, not a '1'/'true' flag. Using
|
||||||
|
// isEnvTruthy() here silently treated every MiniMax user as 'firstParty'
|
||||||
|
// (or 'openai' once they set CLAUDE_CODE_USE_OPENAI via the profile),
|
||||||
|
// making every provider-kind-specific branch for 'minimax' elsewhere in
|
||||||
|
// the codebase unreachable. Presence check is the correct signal.
|
||||||
|
if (typeof process.env.MINIMAX_API_KEY === 'string' && process.env.MINIMAX_API_KEY.trim() !== '') {
|
||||||
return 'minimax'
|
return 'minimax'
|
||||||
}
|
}
|
||||||
return isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
|
return isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
|
||||||
|
|||||||
Reference in New Issue
Block a user