Compare commits
1 Commits
fix/stale-
...
fix/websea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
149b1eb8fb |
22
.env.example
22
.env.example
@@ -149,23 +149,6 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
|
|||||||
# Use a custom OpenAI-compatible endpoint (optional — defaults to api.openai.com)
|
# Use a custom OpenAI-compatible endpoint (optional — defaults to api.openai.com)
|
||||||
# OPENAI_BASE_URL=https://api.openai.com/v1
|
# OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
|
||||||
# Fallback context window size (tokens) when the model is not found in the
|
|
||||||
# built-in table (default: 128000). Increase this for models with larger
|
|
||||||
# context windows (e.g. 200000 for Claude-sized contexts).
|
|
||||||
# CLAUDE_CODE_OPENAI_FALLBACK_CONTEXT_WINDOW=128000
|
|
||||||
|
|
||||||
# Per-model context window overrides as a JSON object.
|
|
||||||
# Takes precedence over the built-in table, so you can register new or
|
|
||||||
# custom models without patching source.
|
|
||||||
# Example: CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS={"my-corp/llm-v3":262144,"gpt-4o-mini":128000}
|
|
||||||
# CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS=
|
|
||||||
|
|
||||||
# Per-model maximum output token overrides as a JSON object.
|
|
||||||
# Use this alongside CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS when your model
|
|
||||||
# supports a different output limit than what the built-in table specifies.
|
|
||||||
# Example: CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS={"my-corp/llm-v3":8192}
|
|
||||||
# CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS=
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Option 3: Google Gemini
|
# Option 3: Google Gemini
|
||||||
@@ -289,11 +272,6 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
|
|||||||
# trigger "Extra required key ... supplied" errors from OpenAI-compatible endpoints
|
# trigger "Extra required key ... supplied" errors from OpenAI-compatible endpoints
|
||||||
# OPENCLAUDE_DISABLE_STRICT_TOOLS=1
|
# OPENCLAUDE_DISABLE_STRICT_TOOLS=1
|
||||||
|
|
||||||
# Disable hidden <system-reminder> messages injected into tool output
|
|
||||||
# Suppresses the file-read cyber-risk reminder and the todo/task tool nudges
|
|
||||||
# Useful for users who want full transparency over what the model sees
|
|
||||||
# OPENCLAUDE_DISABLE_TOOL_REMINDERS=1
|
|
||||||
|
|
||||||
# Custom timeout for API requests in milliseconds (default: varies)
|
# Custom timeout for API requests in milliseconds (default: varies)
|
||||||
# API_TIMEOUT_MS=60000
|
# API_TIMEOUT_MS=60000
|
||||||
|
|
||||||
|
|||||||
@@ -169,14 +169,6 @@ 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.')")
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
||||||
import { detectProvider } from './StartupScreen.js'
|
|
||||||
|
|
||||||
const ENV_KEYS = [
|
|
||||||
'CLAUDE_CODE_USE_OPENAI',
|
|
||||||
'CLAUDE_CODE_USE_GEMINI',
|
|
||||||
'CLAUDE_CODE_USE_GITHUB',
|
|
||||||
'CLAUDE_CODE_USE_BEDROCK',
|
|
||||||
'CLAUDE_CODE_USE_VERTEX',
|
|
||||||
'CLAUDE_CODE_USE_MISTRAL',
|
|
||||||
'OPENAI_BASE_URL',
|
|
||||||
'OPENAI_API_KEY',
|
|
||||||
'OPENAI_MODEL',
|
|
||||||
'GEMINI_MODEL',
|
|
||||||
'MISTRAL_MODEL',
|
|
||||||
'ANTHROPIC_MODEL',
|
|
||||||
'NVIDIA_NIM',
|
|
||||||
'MINIMAX_API_KEY',
|
|
||||||
]
|
|
||||||
|
|
||||||
const originalEnv: Record<string, string | undefined> = {}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
for (const key of ENV_KEYS) {
|
|
||||||
originalEnv[key] = process.env[key]
|
|
||||||
delete process.env[key]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
for (const key of ENV_KEYS) {
|
|
||||||
if (originalEnv[key] === undefined) {
|
|
||||||
delete process.env[key]
|
|
||||||
} else {
|
|
||||||
process.env[key] = originalEnv[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function setupOpenAIMode(baseUrl: string, model: string): void {
|
|
||||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
|
||||||
process.env.OPENAI_BASE_URL = baseUrl
|
|
||||||
process.env.OPENAI_MODEL = model
|
|
||||||
process.env.OPENAI_API_KEY = 'test-key'
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Issue #855: aggregator URL must win over vendor-prefixed model name ---
|
|
||||||
|
|
||||||
describe('detectProvider — aggregator URL authoritative over model-name substring (#855)', () => {
|
|
||||||
test('OpenRouter + deepseek/deepseek-chat labels as OpenRouter', () => {
|
|
||||||
setupOpenAIMode('https://openrouter.ai/api/v1', 'deepseek/deepseek-chat')
|
|
||||||
expect(detectProvider().name).toBe('OpenRouter')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('OpenRouter + moonshotai/kimi-k2 labels as OpenRouter', () => {
|
|
||||||
setupOpenAIMode('https://openrouter.ai/api/v1', 'moonshotai/kimi-k2')
|
|
||||||
expect(detectProvider().name).toBe('OpenRouter')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('OpenRouter + mistralai/mistral-large labels as OpenRouter', () => {
|
|
||||||
setupOpenAIMode('https://openrouter.ai/api/v1', 'mistralai/mistral-large')
|
|
||||||
expect(detectProvider().name).toBe('OpenRouter')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('OpenRouter + meta-llama/llama-3.3 labels as OpenRouter', () => {
|
|
||||||
setupOpenAIMode('https://openrouter.ai/api/v1', 'meta-llama/llama-3.3-70b-instruct')
|
|
||||||
expect(detectProvider().name).toBe('OpenRouter')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Together + deepseek-ai/DeepSeek-V3 labels as Together AI', () => {
|
|
||||||
setupOpenAIMode('https://api.together.xyz/v1', 'deepseek-ai/DeepSeek-V3')
|
|
||||||
expect(detectProvider().name).toBe('Together AI')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Together + meta-llama/Llama-3.3 labels as Together AI', () => {
|
|
||||||
setupOpenAIMode('https://api.together.xyz/v1', 'meta-llama/Llama-3.3-70B-Instruct-Turbo')
|
|
||||||
expect(detectProvider().name).toBe('Together AI')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Groq + deepseek-r1-distill-llama-70b labels as Groq', () => {
|
|
||||||
setupOpenAIMode('https://api.groq.com/openai/v1', 'deepseek-r1-distill-llama-70b')
|
|
||||||
expect(detectProvider().name).toBe('Groq')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Groq + llama-3.3-70b-versatile labels as Groq', () => {
|
|
||||||
setupOpenAIMode('https://api.groq.com/openai/v1', 'llama-3.3-70b-versatile')
|
|
||||||
expect(detectProvider().name).toBe('Groq')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('Azure + any deepseek deployment labels as Azure OpenAI', () => {
|
|
||||||
setupOpenAIMode('https://my-resource.openai.azure.com/', 'deepseek-chat')
|
|
||||||
expect(detectProvider().name).toBe('Azure OpenAI')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- Direct vendor endpoints still label correctly (regression) ---
|
|
||||||
|
|
||||||
describe('detectProvider — direct vendor endpoints', () => {
|
|
||||||
test('api.deepseek.com labels as DeepSeek', () => {
|
|
||||||
setupOpenAIMode('https://api.deepseek.com/v1', 'deepseek-chat')
|
|
||||||
expect(detectProvider().name).toBe('DeepSeek')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('api.moonshot.cn labels as Moonshot (Kimi)', () => {
|
|
||||||
setupOpenAIMode('https://api.moonshot.cn/v1', 'moonshot-v1-8k')
|
|
||||||
expect(detectProvider().name).toBe('Moonshot (Kimi)')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('api.mistral.ai labels as Mistral', () => {
|
|
||||||
setupOpenAIMode('https://api.mistral.ai/v1', 'mistral-large-latest')
|
|
||||||
expect(detectProvider().name).toBe('Mistral')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('default OpenAI URL + gpt-4o labels as OpenAI', () => {
|
|
||||||
setupOpenAIMode('https://api.openai.com/v1', 'gpt-4o')
|
|
||||||
expect(detectProvider().name).toBe('OpenAI')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- rawModel fallback for generic/custom endpoints ---
|
|
||||||
|
|
||||||
describe('detectProvider — rawModel fallback when URL is generic', () => {
|
|
||||||
test('custom proxy + deepseek-chat falls back to DeepSeek', () => {
|
|
||||||
setupOpenAIMode('https://my-proxy.internal/v1', 'deepseek-chat')
|
|
||||||
expect(detectProvider().name).toBe('DeepSeek')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('custom proxy + kimi-k2 falls back to Moonshot (Kimi)', () => {
|
|
||||||
setupOpenAIMode('https://my-proxy.internal/v1', 'kimi-k2-instruct')
|
|
||||||
expect(detectProvider().name).toBe('Moonshot (Kimi)')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('custom proxy + llama-3.3 falls back to Meta Llama', () => {
|
|
||||||
setupOpenAIMode('https://my-proxy.internal/v1', 'llama-3.3-70b')
|
|
||||||
expect(detectProvider().name).toBe('Meta Llama')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('custom proxy + mistral-large falls back to Mistral', () => {
|
|
||||||
setupOpenAIMode('https://my-proxy.internal/v1', 'mistral-large-latest')
|
|
||||||
expect(detectProvider().name).toBe('Mistral')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- Explicit env flags win over URL heuristics ---
|
|
||||||
|
|
||||||
describe('detectProvider — explicit dedicated-provider env flags', () => {
|
|
||||||
test('NVIDIA_NIM=1 overrides aggregator URL', () => {
|
|
||||||
setupOpenAIMode('https://openrouter.ai/api/v1', 'some-nim-model')
|
|
||||||
process.env.NVIDIA_NIM = '1'
|
|
||||||
expect(detectProvider().name).toBe('NVIDIA NIM')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('MINIMAX_API_KEY overrides aggregator URL', () => {
|
|
||||||
setupOpenAIMode('https://openrouter.ai/api/v1', 'any-model')
|
|
||||||
process.env.MINIMAX_API_KEY = 'test-key'
|
|
||||||
expect(detectProvider().name).toBe('MiniMax')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -83,7 +83,7 @@ const LOGO_CLAUDE = [
|
|||||||
|
|
||||||
// ─── Provider detection ───────────────────────────────────────────────────────
|
// ─── Provider detection ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function detectProvider(): { name: string; model: string; baseUrl: string; isLocal: boolean } {
|
function detectProvider(): { name: string; model: string; baseUrl: string; isLocal: boolean } {
|
||||||
const useGemini = process.env.CLAUDE_CODE_USE_GEMINI === '1' || process.env.CLAUDE_CODE_USE_GEMINI === 'true'
|
const useGemini = process.env.CLAUDE_CODE_USE_GEMINI === '1' || process.env.CLAUDE_CODE_USE_GEMINI === 'true'
|
||||||
const useGithub = process.env.CLAUDE_CODE_USE_GITHUB === '1' || process.env.CLAUDE_CODE_USE_GITHUB === 'true'
|
const useGithub = process.env.CLAUDE_CODE_USE_GITHUB === '1' || process.env.CLAUDE_CODE_USE_GITHUB === 'true'
|
||||||
const useOpenAI = process.env.CLAUDE_CODE_USE_OPENAI === '1' || process.env.CLAUDE_CODE_USE_OPENAI === 'true'
|
const useOpenAI = process.env.CLAUDE_CODE_USE_OPENAI === '1' || process.env.CLAUDE_CODE_USE_OPENAI === 'true'
|
||||||
@@ -117,34 +117,30 @@ export function detectProvider(): { name: string; model: string; baseUrl: string
|
|||||||
const baseUrl = resolvedRequest.baseUrl
|
const baseUrl = resolvedRequest.baseUrl
|
||||||
const isLocal = isLocalProviderUrl(baseUrl)
|
const isLocal = isLocalProviderUrl(baseUrl)
|
||||||
let name = 'OpenAI'
|
let name = 'OpenAI'
|
||||||
// Explicit dedicated-provider env flags win.
|
if (/nvidia/i.test(baseUrl) || /nvidia/i.test(rawModel) || process.env.NVIDIA_NIM)
|
||||||
if (process.env.NVIDIA_NIM) name = 'NVIDIA NIM'
|
name = 'NVIDIA NIM'
|
||||||
else if (process.env.MINIMAX_API_KEY) name = 'MiniMax'
|
else if (/minimax/i.test(baseUrl) || /minimax/i.test(rawModel) || process.env.MINIMAX_API_KEY)
|
||||||
else if (
|
name = 'MiniMax'
|
||||||
resolvedRequest.transport === 'codex_responses' ||
|
else if (resolvedRequest.transport === 'codex_responses' || baseUrl.includes('chatgpt.com/backend-api/codex'))
|
||||||
baseUrl.includes('chatgpt.com/backend-api/codex')
|
|
||||||
)
|
|
||||||
name = 'Codex'
|
name = 'Codex'
|
||||||
// Base URL is authoritative — must precede rawModel checks so aggregators
|
else if (/moonshot/i.test(baseUrl) || /kimi/i.test(rawModel))
|
||||||
// (OpenRouter/Together/Groq) aren't mislabelled as DeepSeek/Kimi/etc.
|
name = 'Moonshot (Kimi)'
|
||||||
// when routed to models whose IDs contain a vendor prefix. See issue #855.
|
else if (/deepseek/i.test(baseUrl) || /deepseek/i.test(rawModel))
|
||||||
else if (/openrouter/i.test(baseUrl)) name = 'OpenRouter'
|
name = 'DeepSeek'
|
||||||
else if (/together/i.test(baseUrl)) name = 'Together AI'
|
else if (/openrouter/i.test(baseUrl))
|
||||||
else if (/groq/i.test(baseUrl)) name = 'Groq'
|
name = 'OpenRouter'
|
||||||
else if (/azure/i.test(baseUrl)) name = 'Azure OpenAI'
|
else if (/together/i.test(baseUrl))
|
||||||
else if (/nvidia/i.test(baseUrl)) name = 'NVIDIA NIM'
|
name = 'Together AI'
|
||||||
else if (/minimax/i.test(baseUrl)) name = 'MiniMax'
|
else if (/groq/i.test(baseUrl))
|
||||||
else if (/moonshot/i.test(baseUrl)) name = 'Moonshot (Kimi)'
|
name = 'Groq'
|
||||||
else if (/deepseek/i.test(baseUrl)) name = 'DeepSeek'
|
else if (/mistral/i.test(baseUrl) || /mistral/i.test(rawModel))
|
||||||
else if (/mistral/i.test(baseUrl)) name = 'Mistral'
|
name = 'Mistral'
|
||||||
// rawModel fallback — fires only when base URL is generic/custom.
|
else if (/azure/i.test(baseUrl))
|
||||||
else if (/nvidia/i.test(rawModel)) name = 'NVIDIA NIM'
|
name = 'Azure OpenAI'
|
||||||
else if (/minimax/i.test(rawModel)) name = 'MiniMax'
|
else if (/llama/i.test(rawModel))
|
||||||
else if (/kimi/i.test(rawModel)) name = 'Moonshot (Kimi)'
|
name = 'Meta Llama'
|
||||||
else if (/deepseek/i.test(rawModel)) name = 'DeepSeek'
|
else if (isLocal)
|
||||||
else if (/mistral/i.test(rawModel)) name = 'Mistral'
|
name = getLocalOpenAICompatibleProviderLabel(baseUrl)
|
||||||
else if (/llama/i.test(rawModel)) name = 'Meta Llama'
|
|
||||||
else if (isLocal) name = getLocalOpenAICompatibleProviderLabel(baseUrl)
|
|
||||||
|
|
||||||
// Resolve model alias to actual model name + reasoning effort
|
// Resolve model alias to actual model name + reasoning effort
|
||||||
let displayModel = resolvedRequest.resolvedModel
|
let displayModel = resolvedRequest.resolvedModel
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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 = {
|
||||||
@@ -610,164 +609,6 @@ 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',
|
||||||
|
|||||||
@@ -733,9 +733,6 @@ export const CYBER_RISK_MITIGATION_REMINDER =
|
|||||||
const MITIGATION_EXEMPT_MODELS = new Set(['claude-opus-4-6'])
|
const MITIGATION_EXEMPT_MODELS = new Set(['claude-opus-4-6'])
|
||||||
|
|
||||||
function shouldIncludeFileReadMitigation(): boolean {
|
function shouldIncludeFileReadMitigation(): boolean {
|
||||||
if (isEnvTruthy(process.env.OPENCLAUDE_DISABLE_TOOL_REMINDERS)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const shortName = getCanonicalName(getMainLoopModel())
|
const shortName = getCanonicalName(getMainLoopModel())
|
||||||
return !MITIGATION_EXEMPT_MODELS.has(shortName)
|
return !MITIGATION_EXEMPT_MODELS.has(shortName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
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,11 +20,8 @@ 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 } }),
|
||||||
@@ -40,11 +37,8 @@ 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 } }),
|
||||||
@@ -63,11 +57,8 @@ 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,76 +275,20 @@ 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, axiosConfig)
|
return await axios.get(url, {
|
||||||
|
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 &&
|
||||||
@@ -545,58 +489,6 @@ 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,
|
||||||
@@ -616,35 +508,18 @@ export async function applyPromptToMarkdown(
|
|||||||
prompt,
|
prompt,
|
||||||
isPreapprovedDomain,
|
isPreapprovedDomain,
|
||||||
)
|
)
|
||||||
let assistantMessage
|
const assistantMessage = await queryHaiku({
|
||||||
try {
|
systemPrompt: asSystemPrompt([]),
|
||||||
assistantMessage = await raceWithTimeout(
|
userPrompt: modelPrompt,
|
||||||
queryHaiku({
|
signal,
|
||||||
systemPrompt: asSystemPrompt([]),
|
options: {
|
||||||
userPrompt: modelPrompt,
|
querySource: 'web_fetch_apply',
|
||||||
signal,
|
agents: [],
|
||||||
options: {
|
isNonInteractiveSession,
|
||||||
querySource: 'web_fetch_apply',
|
hasAppendSystemPrompt: false,
|
||||||
agents: [],
|
mcpTools: [],
|
||||||
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.
|
||||||
@@ -659,5 +534,5 @@ export async function applyPromptToMarkdown(
|
|||||||
return contentBlock.text
|
return contentBlock.text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return buildFallbackMarkdownSummary(truncatedContent)
|
return 'No response from model'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,61 +203,6 @@ 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,
|
||||||
@@ -269,12 +214,18 @@ 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 failure = extractCodexWebSearchFailure(item)
|
const sources = Array.isArray(item.action?.sources)
|
||||||
if (failure) {
|
? item.action.sources
|
||||||
results.push(failure)
|
: []
|
||||||
}
|
for (const source of sources) {
|
||||||
for (const source of getCodexSources(item)) {
|
if (typeof source?.url !== 'string' || !source.url) continue
|
||||||
addCodexSource(sourceMap, source)
|
sourceMap.set(source.url, {
|
||||||
|
title:
|
||||||
|
typeof source.title === 'string' && source.title
|
||||||
|
? source.title
|
||||||
|
: source.url,
|
||||||
|
url: source.url,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -284,12 +235,11 @@ function makeOutputFromCodexWebSearchResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const part of item.content) {
|
for (const part of item.content) {
|
||||||
if (part?.type === 'output_text' || part?.type === 'text') {
|
if (part?.type === 'output_text' && typeof part.text === 'string') {
|
||||||
pushCodexTextResult(results, part.text)
|
const trimmed = part.text.trim()
|
||||||
}
|
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)
|
||||||
@@ -297,13 +247,23 @@ function makeOutputFromCodexWebSearchResponse(
|
|||||||
: []
|
: []
|
||||||
for (const annotation of annotations) {
|
for (const annotation of annotations) {
|
||||||
if (annotation?.type !== 'url_citation') continue
|
if (annotation?.type !== 'url_citation') continue
|
||||||
addCodexSource(sourceMap, annotation)
|
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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (results.length === 0) {
|
if (results.length === 0 && typeof response.output_text === 'string') {
|
||||||
pushCodexTextResult(results, response.output_text)
|
const trimmed = response.output_text.trim()
|
||||||
|
if (trimmed) {
|
||||||
|
results.push(trimmed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sourceMap.size > 0) {
|
if (sourceMap.size > 0) {
|
||||||
@@ -313,10 +273,6 @@ function makeOutputFromCodexWebSearchResponse(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (results.length === 0) {
|
|
||||||
results.push('No results found.')
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
query,
|
query,
|
||||||
results,
|
results,
|
||||||
@@ -324,10 +280,6 @@ function makeOutputFromCodexWebSearchResponse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const __test = {
|
|
||||||
makeOutputFromCodexWebSearchResponse,
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runCodexWebSearch(
|
async function runCodexWebSearch(
|
||||||
input: Input,
|
input: Input,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
@@ -505,19 +457,6 @@ 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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -670,17 +609,6 @@ 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,33 +12,12 @@ 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',
|
||||||
|
|
||||||
@@ -57,44 +36,31 @@ 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 lastErr: unknown
|
let response: Awaited<ReturnType<typeof search>>
|
||||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
try {
|
||||||
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
|
response = await search(input.query, { safeSearch: SafeSearchType.STRICT })
|
||||||
try {
|
} catch (err) {
|
||||||
// TODO: duck-duck-scrape doesn't accept AbortSignal — can't cancel in-flight searches
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
const response = await search(input.query, { safeSearch: SafeSearchType.STRICT })
|
if (isAnomalyError(msg)) {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
throw lastErr
|
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,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { execFileSync, spawn } from 'child_process'
|
import { execFileSync, spawn } from 'child_process'
|
||||||
import { constants as fsConstants, readFileSync, unlinkSync } from 'fs'
|
import { constants as fsConstants, readFileSync, unlinkSync } from 'fs'
|
||||||
import { type FileHandle, mkdir, open, stat } from 'fs/promises'
|
import { type FileHandle, mkdir, open, realpath } from 'fs/promises'
|
||||||
import memoize from 'lodash-es/memoize.js'
|
import memoize from 'lodash-es/memoize.js'
|
||||||
import { isAbsolute, resolve } from 'path'
|
import { isAbsolute, resolve } from 'path'
|
||||||
import { join as posixJoin } from 'path/posix'
|
import { join as posixJoin } from 'path/posix'
|
||||||
@@ -217,34 +217,22 @@ export async function exec(
|
|||||||
|
|
||||||
let cwd = pwd()
|
let cwd = pwd()
|
||||||
|
|
||||||
// Recover if the current working directory no longer exists on disk,
|
// Recover if the current working directory no longer exists on disk.
|
||||||
// or was replaced by a non-directory (e.g., the path was renamed and a file
|
// This can happen when a command deletes its own CWD (e.g., temp dir cleanup).
|
||||||
// was created in its place). realpath() succeeds on any existing path
|
|
||||||
// regardless of type, so we must also verify it's a directory — otherwise
|
|
||||||
// spawn would fail later with ENOTDIR / exit 126.
|
|
||||||
let cwdIsValidDir = false
|
|
||||||
try {
|
try {
|
||||||
cwdIsValidDir = (await stat(cwd)).isDirectory()
|
await realpath(cwd)
|
||||||
} catch {
|
} catch {
|
||||||
cwdIsValidDir = false
|
|
||||||
}
|
|
||||||
if (!cwdIsValidDir) {
|
|
||||||
const fallback = getOriginalCwd()
|
const fallback = getOriginalCwd()
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`Shell CWD "${cwd}" is not a valid directory, recovering to "${fallback}"`,
|
`Shell CWD "${cwd}" no longer exists, recovering to "${fallback}"`,
|
||||||
)
|
)
|
||||||
let fallbackIsValidDir = false
|
|
||||||
try {
|
try {
|
||||||
fallbackIsValidDir = (await stat(fallback)).isDirectory()
|
await realpath(fallback)
|
||||||
} catch {
|
|
||||||
fallbackIsValidDir = false
|
|
||||||
}
|
|
||||||
if (fallbackIsValidDir) {
|
|
||||||
setCwdState(fallback)
|
setCwdState(fallback)
|
||||||
cwd = fallback
|
cwd = fallback
|
||||||
} else {
|
} catch {
|
||||||
return createFailedCommand(
|
return createFailedCommand(
|
||||||
`Working directory "${cwd}" is no longer a valid directory. Please restart Claude from an existing directory.`,
|
`Working directory "${cwd}" no longer exists. Please restart Claude from an existing directory.`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,7 @@ export const MODEL_CONTEXT_WINDOW_DEFAULT = 200_000
|
|||||||
// Fallback context window for unknown 3P models. Must be large enough that
|
// Fallback context window for unknown 3P models. Must be large enough that
|
||||||
// the effective context (this minus output token reservation) stays positive,
|
// the effective context (this minus output token reservation) stays positive,
|
||||||
// otherwise auto-compact fires on every message (issue #635).
|
// otherwise auto-compact fires on every message (issue #635).
|
||||||
// Override via CLAUDE_CODE_OPENAI_FALLBACK_CONTEXT_WINDOW env var to avoid
|
export const OPENAI_FALLBACK_CONTEXT_WINDOW = 128_000
|
||||||
// hardcoding when deploying models not yet in openaiContextWindows.ts.
|
|
||||||
export const OPENAI_FALLBACK_CONTEXT_WINDOW = (() => {
|
|
||||||
const v = parseInt(process.env.CLAUDE_CODE_OPENAI_FALLBACK_CONTEXT_WINDOW ?? '', 10)
|
|
||||||
return !isNaN(v) && v > 0 ? v : 128_000
|
|
||||||
})()
|
|
||||||
|
|
||||||
// Maximum output tokens for compact operations
|
// Maximum output tokens for compact operations
|
||||||
export const COMPACT_MAX_OUTPUT_TOKENS = 20_000
|
export const COMPACT_MAX_OUTPUT_TOKENS = 20_000
|
||||||
|
|||||||
@@ -75,13 +75,6 @@ async function importHookChainsHarness(
|
|||||||
getAgentName: () => senderName,
|
getAgentName: () => senderName,
|
||||||
getTeamName: () => teamName,
|
getTeamName: () => teamName,
|
||||||
getTeammateColor: () => 'blue',
|
getTeammateColor: () => 'blue',
|
||||||
// Keep parity with the real module's surface so later tests that
|
|
||||||
// run after this file (mock.module is process-global and mock.restore
|
|
||||||
// does not undo module mocks in Bun) do not see undefined members.
|
|
||||||
isTeammate: () => false,
|
|
||||||
isPlanModeRequired: () => false,
|
|
||||||
getAgentId: () => undefined,
|
|
||||||
getParentSessionId: () => undefined,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module('../bridge/replBridgeHandle.js', () => ({
|
mock.module('../bridge/replBridgeHandle.js', () => ({
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ import type {
|
|||||||
import { isAdvisorBlock } from './advisor.js'
|
import { isAdvisorBlock } from './advisor.js'
|
||||||
import { isAgentSwarmsEnabled } from './agentSwarmsEnabled.js'
|
import { isAgentSwarmsEnabled } from './agentSwarmsEnabled.js'
|
||||||
import { count } from './array.js'
|
import { count } from './array.js'
|
||||||
import { isEnvTruthy } from './envUtils.js'
|
|
||||||
import {
|
import {
|
||||||
type Attachment,
|
type Attachment,
|
||||||
type HookAttachment,
|
type HookAttachment,
|
||||||
@@ -3667,9 +3666,6 @@ Read the team config to discover your teammates' names. Check the task list peri
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
case 'todo_reminder': {
|
case 'todo_reminder': {
|
||||||
if (isEnvTruthy(process.env.OPENCLAUDE_DISABLE_TOOL_REMINDERS)) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
const todoItems = attachment.content
|
const todoItems = attachment.content
|
||||||
.map((todo, index) => `${index + 1}. [${todo.status}] ${todo.content}`)
|
.map((todo, index) => `${index + 1}. [${todo.status}] ${todo.content}`)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
@@ -3690,9 +3686,6 @@ Read the team config to discover your teammates' names. Check the task list peri
|
|||||||
if (!isTodoV2Enabled()) {
|
if (!isTodoV2Enabled()) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
if (isEnvTruthy(process.env.OPENCLAUDE_DISABLE_TOOL_REMINDERS)) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
const taskItems = attachment.content
|
const taskItems = attachment.content
|
||||||
.map(task => `#${task.id}. [${task.status}] ${task.subject}`)
|
.map(task => `#${task.id}. [${task.status}] ${task.subject}`)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
import { afterEach, beforeEach, expect, mock, test } from 'bun:test'
|
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||||
|
|
||||||
import { saveGlobalConfig } from '../config.js'
|
import { saveGlobalConfig } from '../config.js'
|
||||||
import {
|
import { getUserSpecifiedModelSetting } from './model.js'
|
||||||
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,
|
||||||
@@ -34,11 +28,6 @@ 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
|
||||||
@@ -124,76 +113,3 @@ 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,25 +52,10 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,14 +171,6 @@ 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.
|
||||||
@@ -228,14 +205,6 @@ 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
|
||||||
@@ -268,14 +237,6 @@ 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
|
||||||
|
|||||||
@@ -413,51 +413,16 @@ const OPENAI_MAX_OUTPUT_TOKENS: Record<string, number> = {
|
|||||||
'moonshot-v1-128k': 32_768,
|
'moonshot-v1-128k': 32_768,
|
||||||
}
|
}
|
||||||
|
|
||||||
// External context-window overrides loaded once at startup.
|
function lookupByModel<T>(table: Record<string, T>, model: string): T | undefined {
|
||||||
// Set CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS to a JSON object mapping model name
|
|
||||||
// → context-window token count to add or override entries without editing
|
|
||||||
// this file. Example:
|
|
||||||
// CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS='{"my-corp/llm-v2":200000}'
|
|
||||||
const OPENAI_EXTERNAL_CONTEXT_WINDOWS: Record<string, number> = (() => {
|
|
||||||
try {
|
|
||||||
const raw = process.env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS
|
|
||||||
if (raw) {
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
if (typeof parsed === 'object' && parsed !== null) return parsed as Record<string, number>
|
|
||||||
}
|
|
||||||
} catch { /* ignore malformed JSON */ }
|
|
||||||
return {}
|
|
||||||
})()
|
|
||||||
|
|
||||||
// External max-output-token overrides.
|
|
||||||
// Set CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS to a JSON object mapping model name
|
|
||||||
// → max output token count.
|
|
||||||
const OPENAI_EXTERNAL_MAX_OUTPUT_TOKENS: Record<string, number> = (() => {
|
|
||||||
try {
|
|
||||||
const raw = process.env.CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS
|
|
||||||
if (raw) {
|
|
||||||
const parsed = JSON.parse(raw)
|
|
||||||
if (typeof parsed === 'object' && parsed !== null) return parsed as Record<string, number>
|
|
||||||
}
|
|
||||||
} catch { /* ignore malformed JSON */ }
|
|
||||||
return {}
|
|
||||||
})()
|
|
||||||
|
|
||||||
function lookupByModel<T>(table: Record<string, T>, externalTable: Record<string, T>, model: string): T | undefined {
|
|
||||||
// Try provider-qualified key first: "{OPENAI_MODEL}:{model}" so that
|
// Try provider-qualified key first: "{OPENAI_MODEL}:{model}" so that
|
||||||
// e.g. "github:copilot:claude-haiku-4.5" can have different limits than
|
// e.g. "github:copilot:claude-haiku-4.5" can have different limits than
|
||||||
// a bare "claude-haiku-4.5" served by another provider.
|
// a bare "claude-haiku-4.5" served by another provider.
|
||||||
const providerModel = process.env.OPENAI_MODEL?.trim()
|
const providerModel = process.env.OPENAI_MODEL?.trim()
|
||||||
if (providerModel && providerModel !== model) {
|
if (providerModel && providerModel !== model) {
|
||||||
const qualified = `${providerModel}:${model}`
|
const qualified = `${providerModel}:${model}`
|
||||||
// External table takes precedence over the built-in table.
|
|
||||||
const externalQualified = lookupByKey(externalTable, qualified)
|
|
||||||
if (externalQualified !== undefined) return externalQualified
|
|
||||||
const qualifiedResult = lookupByKey(table, qualified)
|
const qualifiedResult = lookupByKey(table, qualified)
|
||||||
if (qualifiedResult !== undefined) return qualifiedResult
|
if (qualifiedResult !== undefined) return qualifiedResult
|
||||||
}
|
}
|
||||||
const externalResult = lookupByKey(externalTable, model)
|
|
||||||
if (externalResult !== undefined) return externalResult
|
|
||||||
return lookupByKey(table, model)
|
return lookupByKey(table, model)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,7 +446,7 @@ function lookupByKey<T>(table: Record<string, T>, model: string): T | undefined
|
|||||||
* "gpt-4o-2024-11-20" resolve to the base "gpt-4o" entry.
|
* "gpt-4o-2024-11-20" resolve to the base "gpt-4o" entry.
|
||||||
*/
|
*/
|
||||||
export function getOpenAIContextWindow(model: string): number | undefined {
|
export function getOpenAIContextWindow(model: string): number | undefined {
|
||||||
return lookupByModel(OPENAI_CONTEXT_WINDOWS, OPENAI_EXTERNAL_CONTEXT_WINDOWS, model)
|
return lookupByModel(OPENAI_CONTEXT_WINDOWS, model)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -489,5 +454,5 @@ export function getOpenAIContextWindow(model: string): number | undefined {
|
|||||||
* Returns undefined if the model is not in the table.
|
* Returns undefined if the model is not in the table.
|
||||||
*/
|
*/
|
||||||
export function getOpenAIMaxOutputTokens(model: string): number | undefined {
|
export function getOpenAIMaxOutputTokens(model: string): number | undefined {
|
||||||
return lookupByModel(OPENAI_MAX_OUTPUT_TOKENS, OPENAI_EXTERNAL_MAX_OUTPUT_TOKENS, model)
|
return lookupByModel(OPENAI_MAX_OUTPUT_TOKENS, model)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,7 @@ export function getAPIProvider(): APIProvider {
|
|||||||
if (isEnvTruthy(process.env.NVIDIA_NIM)) {
|
if (isEnvTruthy(process.env.NVIDIA_NIM)) {
|
||||||
return 'nvidia-nim'
|
return 'nvidia-nim'
|
||||||
}
|
}
|
||||||
// MiniMax is signalled by a real API key, not a '1'/'true' flag. Using
|
if (isEnvTruthy(process.env.MINIMAX_API_KEY)) {
|
||||||
// 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