feat: enable native web search in copilot (#9522)

* feat: enable native web search in copilot

* fix: add web search fallback and settings

* test: use frontend uuid helper

* fix: tighten web search fallback

* fix: add web search error hint

* fix: classify web search fallback errors

* fix: avoid web search fallback tool error

* fix: handle anthropic web search enablement errors
This commit is contained in:
centdix
2026-06-12 00:18:22 +02:00
committed by GitHub
parent 5bdc4f83ce
commit d3f5fe1c8c
14 changed files with 808 additions and 56 deletions
+2
View File
@@ -177,6 +177,8 @@ impl TryFrom<&str> for AIProvider {
pub struct ProviderConfig {
pub resource_path: String,
pub models: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub web_search_enabled: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug)]
+2
View File
@@ -21635,6 +21635,8 @@ components:
type: array
items:
type: string
web_search_enabled:
type: boolean
required:
- resource_path
- models
+20 -1
View File
@@ -10,7 +10,7 @@ vi.mock('./gen', () => ({
WorkspaceService: { getCopilotInfo: async () => ({}) }
}))
import { setCopilotInfo, copilotInfo } from './aiStore'
import { isWebSearchEnabledForProvider, setCopilotInfo, copilotInfo } from './aiStore'
describe('setCopilotInfo legacy /thinking migration', () => {
it('strips the /thinking suffix from configured model slots', () => {
@@ -34,4 +34,23 @@ describe('setCopilotInfo legacy /thinking migration', () => {
// the model list is stripped and deduped
expect(info.aiModels.map((m) => m.model)).toEqual(['claude-sonnet-4-6'])
})
it('defaults provider web search on unless explicitly disabled', () => {
setCopilotInfo({
providers: {
openai: {
resource_path: 'u/admin/openai',
models: ['gpt-4.1'],
web_search_enabled: false
},
anthropic: {
resource_path: 'u/admin/anthropic',
models: ['claude-sonnet-4-6']
}
}
})
expect(isWebSearchEnabledForProvider('openai')).toBe(false)
expect(isWebSearchEnabledForProvider('anthropic')).toBe(true)
})
})
+20 -3
View File
@@ -37,6 +37,7 @@ export const copilotInfo = writable<{
aiModels: AIProviderModel[]
customPrompts?: Record<string, string>
maxTokensPerModel?: Record<string, number>
webSearchEnabledProviders?: Partial<Record<AIProvider, boolean>>
}>({
enabled: false,
codeCompletionModel: undefined,
@@ -44,7 +45,8 @@ export const copilotInfo = writable<{
metadataModel: undefined,
aiModels: [],
customPrompts: {},
maxTokensPerModel: {}
maxTokensPerModel: {},
webSearchEnabledProviders: {}
})
/** Strip the deprecated /thinking suffix from a configured model slot, if present. */
@@ -87,6 +89,12 @@ export function setCopilotInfo(aiConfig: AIConfig) {
}))
)
)
const webSearchEnabledProviders = Object.fromEntries(
Object.entries(aiConfig.providers ?? {}).map(([provider, providerConfig]) => [
provider,
providerConfig.web_search_enabled !== false
])
) as Partial<Record<AIProvider, boolean>>
copilotSessionModel.update((model) => {
if (
@@ -107,7 +115,8 @@ export function setCopilotInfo(aiConfig: AIConfig) {
metadataModel: stripModelSuffix(aiConfig.metadata_model),
aiModels: aiModels,
customPrompts: aiConfig.custom_prompts ?? {},
maxTokensPerModel: aiConfig.max_tokens_per_model ?? {}
maxTokensPerModel: aiConfig.max_tokens_per_model ?? {},
webSearchEnabledProviders
})
} else {
copilotSessionModel.set(undefined)
@@ -119,11 +128,19 @@ export function setCopilotInfo(aiConfig: AIConfig) {
metadataModel: undefined,
aiModels: [],
customPrompts: {},
maxTokensPerModel: {}
maxTokensPerModel: {},
webSearchEnabledProviders: {}
})
}
}
export function isWebSearchEnabledForProvider(provider: AIProvider | undefined): boolean {
if (!provider) {
return false
}
return get(copilotInfo).webSearchEnabledProviders?.[provider] ?? true
}
export function getCurrentModel(): ReasoningProviderModel {
const model =
get(copilotSessionModel) ?? get(copilotInfo).defaultModel ?? get(copilotInfo).aiModels[0]
@@ -26,6 +26,11 @@ would be surprising.
type Kind = WorkspaceItemKind
type ScopeKind = Kind | 'all'
type DrillPickerHandle = {
focus: () => void
handleKeydown: (e: KeyboardEvent) => void
pickHighlighted: () => void
}
export type Scope = { kind: ScopeKind; dir?: string } | undefined
@@ -51,7 +56,7 @@ would be surprising.
flush = false
}: Props = $props()
let inner = $state<DrillPicker<WorkspaceItem> | undefined>(undefined)
let inner = $state<DrillPickerHandle | undefined>(undefined)
export function focus() {
inner?.focus()
@@ -61,7 +61,12 @@ import type AIChatInput from './AIChatInput.svelte'
import { prepareApiSystemMessage, prepareApiUserMessage } from './api/core'
import { runChatLoop } from './chatLoop'
import type { ReviewChangesOpts } from './monaco-adapter'
import { getCurrentModel, tryGetCurrentModel, getCombinedCustomPrompt } from '$lib/aiStore'
import {
getCurrentModel,
tryGetCurrentModel,
getCombinedCustomPrompt,
isWebSearchEnabledForProvider
} from '$lib/aiStore'
import type { WorkspaceMutationTarget } from './workspaceTools'
import {
globalToolsFor,
@@ -76,6 +81,8 @@ const MAX_TOKENS_THRESHOLD_PERCENTAGE = 0.05
const MAX_TOKENS_HARD_LIMIT = 5000
const AI_AUTONOMY_MODE_STORAGE_KEY = 'ai-chat-autonomy-mode'
const LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY = 'ai-chat-yolo-mode'
const WEB_SEARCH_ERROR_HINT =
'Web search is unavailable for this provider/model/key. Disable web search in workspace settings and try again.'
export enum AIMode {
SCRIPT = 'script',
@@ -154,6 +161,20 @@ function persistAutonomyMode(mode: AIAutonomyMode) {
localStorage.setItem(AI_AUTONOMY_MODE_STORAGE_KEY, mode)
}
function appendWebSearchErrorHint(message: string, shouldAppend: boolean): string {
if (!shouldAppend) {
return message
}
const separator = /[.!?]$/.test(message.trim()) ? ' ' : '. '
return `${message}${separator}${WEB_SEARCH_ERROR_HINT}`
}
function getSendRequestErrorMessage(err: unknown, webSearchUnavailable: boolean): string {
const errorMessage = err instanceof Error ? err.message : typeof err === 'string' ? err : undefined
const message = errorMessage ? `Failed to send request: ${errorMessage}` : 'Failed to send request'
return appendWebSearchErrorHint(message, webSearchUnavailable)
}
export class AIChatManager {
contextManager = new ContextManager()
historyManager = new HistoryManager()
@@ -652,7 +673,8 @@ export class AIChatManager {
messages,
abortController,
callbacks,
systemMessage: systemMessageOverride
systemMessage: systemMessageOverride,
onWebSearchUnavailable
}: {
messages: ChatCompletionMessageParam[]
abortController: AbortController
@@ -661,6 +683,7 @@ export class AIChatManager {
onMessageEnd: () => void
}
systemMessage?: ChatCompletionSystemMessageParam
onWebSearchUnavailable?: () => void
}) => {
try {
// Use JS getters so runChatLoop re-reads tools/helpers/systemMessage/modelProvider
@@ -683,6 +706,9 @@ export class AIChatManager {
get modelProvider() {
return getCurrentModel()
},
get webSearch() {
return isWebSearchEnabledForProvider(getCurrentModel().provider)
},
clients: {
openai: workspaceAIClients.getOpenaiClient(),
anthropic: workspaceAIClients.getAnthropicClient()
@@ -692,6 +718,7 @@ export class AIChatManager {
onSkipResponsesApi: () => {
this.skipResponsesApi = true
},
onWebSearchUnavailable,
getPendingUserMessage: () => {
const pendingPrompt = this.pendingPrompt
if (!pendingPrompt) return undefined
@@ -873,6 +900,7 @@ export class AIChatManager {
}
}
const isFirstUserTurn = !this.displayMessages.some((message) => message.role === 'user')
let webSearchUnavailable = false
try {
const oldSelectedContext = this.contextManager?.getSelectedContext() ?? []
if (this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW) {
@@ -1063,7 +1091,10 @@ export class AIChatManager {
}
const addedMessages = await this.chatRequest({
...params
...params,
onWebSearchUnavailable: () => {
webSearchUnavailable = true
}
})
this.messages = [...this.messages, ...(addedMessages ?? [])]
if (this.autoAcceptEditsActive) {
@@ -1078,11 +1109,7 @@ export class AIChatManager {
} catch (err) {
console.error(err)
this.flagLastMessageAsError()
if (err instanceof Error) {
sendUserToast('Failed to send request: ' + err.message, true)
} else {
sendUserToast('Failed to send request', true)
}
sendUserToast(getSendRequestErrorMessage(err, webSearchUnavailable), true)
} finally {
this.loading = false
}
@@ -4,12 +4,25 @@ import type { CurrentEditor } from '$lib/components/flows/types'
import type { ReviewChangesOpts } from './monaco-adapter'
import { AIChatManager, AIMode, AIAutonomyMode } from './AIChatManager.svelte'
const mocks = vi.hoisted(() => ({
getCurrentModel: vi.fn(),
tryGetCurrentModel: vi.fn(),
isWebSearchEnabledForProvider: vi.fn(),
logAiChat: vi.fn(),
sendUserToast: vi.fn(),
getOpenaiClient: vi.fn(),
getAnthropicClient: vi.fn(),
runChatLoop: vi.fn()
}))
vi.mock('monaco-editor', () => ({
Selection: class Selection {}
}))
vi.mock('$lib/gen', () => ({
WorkspaceService: {},
WorkspaceService: {
logAiChat: mocks.logAiChat
},
ScriptService: {},
FlowService: {},
JobService: {}
@@ -26,18 +39,23 @@ vi.mock('$lib/stores', () => ({
}))
vi.mock('$lib/toast', () => ({
sendUserToast: vi.fn()
sendUserToast: mocks.sendUserToast
}))
vi.mock('$lib/aiStore', () => ({
getCurrentModel: () => undefined,
tryGetCurrentModel: () => undefined,
getCombinedCustomPrompt: () => ''
getCurrentModel: mocks.getCurrentModel,
tryGetCurrentModel: mocks.tryGetCurrentModel,
getCombinedCustomPrompt: () => '',
isWebSearchEnabledForProvider: mocks.isWebSearchEnabledForProvider
}))
vi.mock('../lib', () => ({
getModelContextWindow: () => 128000,
workspaceAIClients: { subscribe: () => () => undefined }
workspaceAIClients: {
subscribe: () => () => undefined,
getOpenaiClient: mocks.getOpenaiClient,
getAnthropicClient: mocks.getAnthropicClient
}
}))
vi.mock('./api/apiTools', () => ({
@@ -45,7 +63,7 @@ vi.mock('./api/apiTools', () => ({
}))
vi.mock('./chatLoop', () => ({
runChatLoop: vi.fn()
runChatLoop: mocks.runChatLoop
}))
vi.mock('./global/gate', () => ({
@@ -59,6 +77,21 @@ vi.mock('esm-env', async (importOriginal) => ({
BROWSER: true
}))
beforeEach(() => {
vi.clearAllMocks()
mocks.getCurrentModel.mockReturnValue(undefined)
mocks.tryGetCurrentModel.mockReturnValue(undefined)
mocks.isWebSearchEnabledForProvider.mockReturnValue(true)
mocks.logAiChat.mockResolvedValue(undefined)
mocks.getOpenaiClient.mockReturnValue({})
mocks.getAnthropicClient.mockReturnValue({})
mocks.runChatLoop.mockResolvedValue({
addedMessages: [],
tokenUsage: { prompt: 0, completion: 0, total: 0 },
hitMaxIterations: false
})
})
function createFlowHelpers({
hasPendingChanges,
acceptAllModuleActions,
@@ -87,6 +120,61 @@ function createFlowHelpers({
} as unknown as FlowAIChatHelpers
}
describe('AIChatManager request errors', () => {
const openaiModel = { provider: 'openai', model: 'gpt-4o' }
beforeEach(() => {
localStorage.clear()
mocks.getCurrentModel.mockReturnValue(openaiModel)
mocks.tryGetCurrentModel.mockReturnValue(openaiModel)
})
it('does not add a web-search hint to generic request errors', async () => {
const manager = new AIChatManager()
manager.instructions = 'Search for recent docs'
mocks.isWebSearchEnabledForProvider.mockReturnValue(true)
mocks.runChatLoop.mockRejectedValueOnce(new Error('provider quota exceeded'))
await manager.sendRequest()
expect(mocks.sendUserToast).toHaveBeenLastCalledWith(
'Failed to send request: provider quota exceeded',
true
)
})
it('adds the web-search hint when fallback happened and the request still fails', async () => {
const manager = new AIChatManager()
manager.instructions = 'Search for recent docs'
mocks.isWebSearchEnabledForProvider.mockReturnValue(true)
mocks.runChatLoop.mockImplementationOnce(async (config) => {
config.onWebSearchUnavailable?.()
throw new Error('provider quota exceeded')
})
await manager.sendRequest()
expect(mocks.sendUserToast).toHaveBeenLastCalledWith(
'Failed to send request: provider quota exceeded. Web search is unavailable for this provider/model/key. Disable web search in workspace settings and try again.',
true
)
})
it('does not add the web search settings hint when web search is disabled', async () => {
const manager = new AIChatManager()
manager.instructions = 'Search for recent docs'
mocks.isWebSearchEnabledForProvider.mockReturnValue(false)
mocks.runChatLoop.mockRejectedValueOnce(new Error('provider quota exceeded'))
await manager.sendRequest()
expect(mocks.sendUserToast).toHaveBeenLastCalledWith(
'Failed to send request: provider quota exceeded',
true
)
})
})
describe('AIChatManager autonomy mode', () => {
beforeEach(() => {
localStorage.clear()
@@ -58,8 +58,11 @@ at message-prep time by `AIChatManager` — see PR #9216.
// Chat tree leaves carry either a workspace path (resolved to content
// at pick time) or a runtime ContextElement (added directly).
type ChatLeafData = WorkspaceItem | ContextElement
type DrillPickerHandle = {
handleKeydown: (e: KeyboardEvent) => void
}
let inner = $state<DrillPicker<ChatLeafData> | undefined>(undefined)
let inner = $state<DrillPickerHandle | undefined>(undefined)
export function handleKeydown(e: KeyboardEvent) {
inner?.handleKeydown(e)
@@ -24,6 +24,29 @@ interface ParsedCompletionResult {
tokenUsage: ChatTokenUsage
}
type WebSearchStatus = 'searching' | 'completed' | 'failed'
function setAnthropicWebSearchStatus(
callbacks: ToolCallbacks & { onMessageEnd: () => void },
toolId: string,
status: WebSearchStatus,
errorCode?: string
) {
const isLoading = status === 'searching'
const failed = status === 'failed'
callbacks.onMessageEnd()
callbacks.setToolStatus(`anthropic_web_search:${toolId}`, {
content: failed ? 'Web search failed' : isLoading ? 'Searching the web...' : 'Searched the web',
error: failed ? `Web search failed${errorCode ? `: ${errorCode}` : ''}` : undefined,
isLoading,
isStreamingArguments: false,
needsConfirmation: false,
toolName: 'web_search',
showDetails: false,
autoCollapseDetails: true
})
}
export async function getAnthropicCompletion(
messages: ChatCompletionMessageParam[],
abortController: AbortController,
@@ -31,6 +54,7 @@ export async function getAnthropicCompletion(
options?: {
forceModelProvider?: AIProviderModel
anthropicClient?: Anthropic
webSearch?: boolean
reasoningEffort?: string
}
): Promise<MessageStream> {
@@ -40,7 +64,17 @@ export async function getAnthropicCompletion(
forceModelProvider: options?.forceModelProvider
})
const { system, messages: anthropicMessages } = convertOpenAIToAnthropicMessages(messages)
const anthropicTools = convertOpenAIToolsToAnthropic(tools)
let anthropicTools = convertOpenAIToolsToAnthropic(tools)
// Enable Anthropic's server-side web search tool. The proxy forwards the body
// verbatim, so this reaches Anthropic as a native server tool that it executes
// itself (no client round-trip).
if (options?.webSearch) {
anthropicTools = [
...(anthropicTools ?? []),
{ type: 'web_search_20250305', name: 'web_search', max_uses: 5 }
]
}
const client = options?.anthropicClient ?? workspaceAIClients.getAnthropicClient()
@@ -117,6 +151,8 @@ export async function parseAnthropicCompletion(
showDetails: tool?.showDetails,
autoCollapseDetails: tool?.autoCollapseDetails
})
} else if (block.type === 'server_tool_use' && block.name === 'web_search') {
setAnthropicWebSearchStatus(callbacks, block.id, 'searching')
}
}
})
@@ -173,6 +209,14 @@ export async function parseAnthropicCompletion(
messages.push(assistantMessage)
addedMessages.push(assistantMessage)
callbacks.onMessageEnd()
} else if (block.type === 'web_search_tool_result') {
const errorCode = Array.isArray(block.content) ? undefined : block.content.error_code
setAnthropicWebSearchStatus(
callbacks,
block.tool_use_id,
errorCode ? 'failed' : 'completed',
errorCode
)
} else if (block.type === 'tool_use') {
// Convert Anthropic tool calls to OpenAI format for compatibility
toolCallsToProcess.push({
@@ -0,0 +1,294 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { randomUUID } from '$lib/utils/uuid'
import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.mjs'
import { runChatLoop, type ChatLoopConfig } from './chatLoop'
import type { ReasoningProviderModel } from '../reasoningRegistry'
const mocks = vi.hoisted(() => ({
getCompletion: vi.fn(),
parseOpenAICompletion: vi.fn(),
providerSupportsWebSearch: vi.fn(),
getOpenAIResponsesCompletion: vi.fn(),
parseOpenAIResponsesCompletion: vi.fn(),
getAnthropicCompletion: vi.fn(),
parseAnthropicCompletion: vi.fn(),
resolveEffectiveReasoning: vi.fn()
}))
vi.mock('../lib', () => ({
getCompletion: mocks.getCompletion,
parseOpenAICompletion: mocks.parseOpenAICompletion,
providerSupportsWebSearch: mocks.providerSupportsWebSearch
}))
vi.mock('../reasoningRegistry', () => ({
resolveEffectiveReasoning: mocks.resolveEffectiveReasoning
}))
vi.mock('./openai-responses', () => ({
getOpenAIResponsesCompletion: mocks.getOpenAIResponsesCompletion,
parseOpenAIResponsesCompletion: mocks.parseOpenAIResponsesCompletion
}))
vi.mock('./anthropic', () => ({
getAnthropicCompletion: mocks.getAnthropicCompletion,
parseAnthropicCompletion: mocks.parseAnthropicCompletion
}))
const tokenUsage = { prompt: 0, completion: 0, total: 0 }
function createCallbacks(): ChatLoopConfig['callbacks'] {
return {
onNewToken: vi.fn(),
onMessageEnd: vi.fn(),
setToolStatus: vi.fn(),
removeToolStatus: vi.fn()
}
}
function createConfig({
workspace,
modelProvider = { provider: 'openai', model: 'gpt-4.1' },
callbacks = createCallbacks(),
onWebSearchUnavailable
}: {
workspace: string
modelProvider?: ReasoningProviderModel
callbacks?: ChatLoopConfig['callbacks']
onWebSearchUnavailable?: ChatLoopConfig['onWebSearchUnavailable']
}): ChatLoopConfig {
const messages: ChatCompletionMessageParam[] = [{ role: 'user', content: 'search this' }]
return {
messages,
systemMessage: { role: 'system', content: '' },
tools: [],
helpers: undefined,
abortController: new AbortController(),
callbacks,
modelProvider,
clients: {
openai: {} as ChatLoopConfig['clients']['openai'],
anthropic: {} as ChatLoopConfig['clients']['anthropic']
},
workspace,
maxIterations: 1,
onWebSearchUnavailable
}
}
describe('runChatLoop web search fallback', () => {
beforeEach(() => {
vi.resetAllMocks()
mocks.providerSupportsWebSearch.mockImplementation(
(provider) => provider === 'openai' || provider === 'anthropic'
)
mocks.resolveEffectiveReasoning.mockReturnValue(undefined)
mocks.parseOpenAICompletion.mockResolvedValue({
shouldContinue: false,
tokenUsage
})
mocks.parseOpenAIResponsesCompletion.mockResolvedValue({
shouldContinue: false,
tokenUsage
})
mocks.parseAnthropicCompletion.mockResolvedValue({
shouldContinue: false,
tokenUsage
})
})
it('retries once without OpenAI web search and caches unsupported provider models', async () => {
const callbacks = createCallbacks()
const onWebSearchUnavailable = vi.fn()
const workspace = `workspace-${randomUUID()}`
mocks.getOpenAIResponsesCompletion
.mockRejectedValueOnce(
Object.assign(new Error("Hosted tool 'web_search' is not supported with this model"), {
status: 400,
error: { type: 'invalid_request_error' }
})
)
.mockResolvedValue({})
await runChatLoop(createConfig({ workspace, callbacks, onWebSearchUnavailable }))
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(2)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[0][3]).toEqual(
expect.objectContaining({ webSearch: true })
)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[1][3]).toEqual(
expect.objectContaining({ webSearch: false })
)
expect(onWebSearchUnavailable).toHaveBeenCalledTimes(1)
expect(callbacks.setToolStatus).not.toHaveBeenCalled()
await runChatLoop(createConfig({ workspace }))
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(3)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[2][3]).toEqual(
expect.objectContaining({ webSearch: false })
)
})
it('does not treat unrelated OpenAI tool errors as web search failures', async () => {
const callbacks = createCallbacks()
const workspace = `workspace-${randomUUID()}`
mocks.getOpenAIResponsesCompletion.mockRejectedValueOnce(
new Error('Unknown tool call: lookup_customer')
)
mocks.getCompletion.mockResolvedValue({})
await runChatLoop(createConfig({ workspace, callbacks }))
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(1)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[0][3]).toEqual(
expect.objectContaining({ webSearch: true })
)
expect(mocks.getCompletion).toHaveBeenCalledTimes(1)
expect(callbacks.setToolStatus).not.toHaveBeenCalled()
await runChatLoop(createConfig({ workspace }))
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(2)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[1][3]).toEqual(
expect.objectContaining({ webSearch: true })
)
})
it('treats OpenAI hosted-tool incompatibility as web search unavailable', async () => {
const callbacks = createCallbacks()
const onWebSearchUnavailable = vi.fn()
const workspace = `workspace-${randomUUID()}`
mocks.getOpenAIResponsesCompletion
.mockRejectedValueOnce(
Object.assign(new Error('Hosted tools are not supported with this model'), {
status: 400,
error: { type: 'invalid_request_error' }
})
)
.mockResolvedValue({})
await runChatLoop(createConfig({ workspace, callbacks, onWebSearchUnavailable }))
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(2)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[0][3]).toEqual(
expect.objectContaining({ webSearch: true })
)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[1][3]).toEqual(
expect.objectContaining({ webSearch: false })
)
expect(onWebSearchUnavailable).toHaveBeenCalledTimes(1)
expect(callbacks.setToolStatus).not.toHaveBeenCalled()
})
it('does not cache malformed web search requests as unavailable', async () => {
const callbacks = createCallbacks()
const workspace = `workspace-${randomUUID()}`
mocks.getOpenAIResponsesCompletion.mockRejectedValueOnce(
Object.assign(new Error('Invalid value for web_search.search_context_size'), {
status: 400,
error: { type: 'invalid_request_error' }
})
)
mocks.getCompletion.mockResolvedValue({})
await runChatLoop(createConfig({ workspace, callbacks }))
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(1)
expect(mocks.getCompletion).toHaveBeenCalledTimes(1)
expect(callbacks.setToolStatus).not.toHaveBeenCalled()
await runChatLoop(createConfig({ workspace }))
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(2)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[1][3]).toEqual(
expect.objectContaining({ webSearch: true })
)
})
it('does not cache web-search rate limits as unavailable', async () => {
const callbacks = createCallbacks()
const workspace = `workspace-${randomUUID()}`
mocks.getOpenAIResponsesCompletion.mockRejectedValueOnce(
Object.assign(new Error('Rate limit exceeded for web_search'), {
status: 429,
error: { type: 'rate_limit_error' }
})
)
mocks.getCompletion.mockResolvedValue({})
await runChatLoop(createConfig({ workspace, callbacks }))
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(1)
expect(mocks.getCompletion).toHaveBeenCalledTimes(1)
expect(callbacks.setToolStatus).not.toHaveBeenCalled()
await runChatLoop(createConfig({ workspace }))
expect(mocks.getOpenAIResponsesCompletion).toHaveBeenCalledTimes(2)
expect(mocks.getOpenAIResponsesCompletion.mock.calls[1][3]).toEqual(
expect.objectContaining({ webSearch: true })
)
})
it('retries once without Anthropic web search when the model rejects it', async () => {
const callbacks = createCallbacks()
const onWebSearchUnavailable = vi.fn()
const workspace = `workspace-${randomUUID()}`
const modelProvider: ReasoningProviderModel = {
provider: 'anthropic',
model: 'claude-3-5-sonnet-latest'
}
mocks.getAnthropicCompletion
.mockRejectedValueOnce(
Object.assign(new Error('Your organization must enable web search in the Claude Console'), {
status: 400,
error: { type: 'invalid_request_error' }
})
)
.mockResolvedValue({})
await runChatLoop(
createConfig({ workspace, callbacks, modelProvider, onWebSearchUnavailable })
)
expect(mocks.getAnthropicCompletion).toHaveBeenCalledTimes(2)
expect(mocks.getAnthropicCompletion.mock.calls[0][3]).toEqual(
expect.objectContaining({ webSearch: true })
)
expect(mocks.getAnthropicCompletion.mock.calls[1][3]).toEqual(
expect.objectContaining({ webSearch: false })
)
expect(onWebSearchUnavailable).toHaveBeenCalledTimes(1)
expect(callbacks.setToolStatus).not.toHaveBeenCalled()
})
it('does not retry Anthropic request errors that only mention web search input', async () => {
const callbacks = createCallbacks()
const workspace = `workspace-${randomUUID()}`
const modelProvider: ReasoningProviderModel = {
provider: 'anthropic',
model: 'claude-sonnet-4-6'
}
const error = Object.assign(new Error('Invalid web_search query parameter'), {
status: 400,
error: { type: 'invalid_request_error' }
})
mocks.getAnthropicCompletion.mockRejectedValueOnce(error)
await expect(runChatLoop(createConfig({ workspace, callbacks, modelProvider }))).rejects.toBe(
error
)
expect(mocks.getAnthropicCompletion).toHaveBeenCalledTimes(1)
expect(callbacks.setToolStatus).not.toHaveBeenCalled()
})
})
@@ -5,7 +5,7 @@ import type {
ChatCompletionSystemMessageParam,
ChatCompletionUserMessageParam
} from 'openai/resources/chat/completions.mjs'
import { getCompletion, parseOpenAICompletion } from '../lib'
import { getCompletion, parseOpenAICompletion, providerSupportsWebSearch } from '../lib'
import { resolveEffectiveReasoning, type ReasoningProviderModel } from '../reasoningRegistry'
import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic'
import { getOpenAIResponsesCompletion, parseOpenAIResponsesCompletion } from './openai-responses'
@@ -35,10 +35,17 @@ export interface ChatLoopConfig {
modelProvider: ReasoningProviderModel
clients: ChatClients
workspace: string
/**
* Enable provider-native web search. Defaults to true for compatible providers
* and is re-read each iteration so model/provider changes take effect. Explicit
* true is still ignored for providers without native web search support.
*/
webSearch?: boolean
/** Maximum iterations for the loop. undefined = unlimited (production). */
maxIterations?: number
skipResponsesApi?: boolean
onSkipResponsesApi?: () => void
onWebSearchUnavailable?: () => void
/** Return a pending user message to inject between iterations, or undefined. */
getPendingUserMessage?: () => ChatCompletionUserMessageParam | undefined
/** Called before each iteration (e.g. to refresh tool schemas). */
@@ -51,6 +58,101 @@ export interface ChatLoopResult {
hitMaxIterations: boolean
}
const unsupportedWebSearchCache = new Set<string>()
const WEB_SEARCH_UNAVAILABLE_STATUS_CODES = new Set([400, 403, 404])
function getWebSearchCacheKey(workspace: string, modelProvider: ReasoningProviderModel): string {
return [workspace, modelProvider.provider, modelProvider.model].join(':')
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function appendTextPart(parts: string[], value: unknown) {
if (typeof value === 'string' && value.trim()) {
parts.push(value)
}
}
function getErrorText(err: unknown): string {
const parts: string[] = []
if (err instanceof Error) {
appendTextPart(parts, err.message)
}
if (typeof err === 'string') {
appendTextPart(parts, err)
}
if (isRecord(err)) {
appendTextPart(parts, err.message)
appendTextPart(parts, err.type)
appendTextPart(parts, err.code)
appendTextPart(parts, err.param)
const nested = err.error
if (isRecord(nested)) {
appendTextPart(parts, nested.message)
appendTextPart(parts, nested.type)
appendTextPart(parts, nested.code)
appendTextPart(parts, nested.param)
} else {
appendTextPart(parts, nested)
}
}
if (parts.length > 0) {
return parts.join(' ')
}
try {
return JSON.stringify(err)
} catch {
return String(err)
}
}
function getErrorStatus(err: unknown): number | undefined {
if (!isRecord(err)) {
return undefined
}
const candidates = [err.status]
if (isRecord(err.response)) {
candidates.push(err.response.status)
}
if (isRecord(err.error)) {
candidates.push(err.error.status)
}
return candidates.find((status): status is number => typeof status === 'number')
}
function hasWebSearchUnavailableSignal(err: unknown): boolean {
const message = getErrorText(err).toLowerCase()
const webSearchTerm = '(?:web[_ -]?search|web search|web-search)'
const unavailableTerm =
'(?:not supported|unsupported|not available|unavailable|disabled|not enabled|enable web search|forbidden|not permitted|permission|policy|blocked|access)'
const patterns = [
new RegExp(`${webSearchTerm}.*${unavailableTerm}`),
new RegExp(`${unavailableTerm}.*${webSearchTerm}`),
/\bmust\s+enable\s+web[_ -]?search\b/,
/\bweb search options\b.*\bnot supported\b/,
/\bhosted tools?\b.*\b(?:not supported|unsupported)\b/,
/\bhosted tool ['"]web_search(?:_preview)?['"].*\b(?:not supported|unsupported)\b/
]
return patterns.some((pattern) => pattern.test(message))
}
function shouldRetryWithoutWebSearch(err: unknown): boolean {
if (!hasWebSearchUnavailableSignal(err)) {
return false
}
const status = getErrorStatus(err)
return status === undefined || WEB_SEARCH_UNAVAILABLE_STATUS_CODES.has(status)
}
function markWebSearchUnsupported(cacheKey: string, err: unknown, onWebSearchUnavailable?: () => void) {
unsupportedWebSearchCache.add(cacheKey)
console.warn('Native web search unavailable; retrying without web search:', err)
onWebSearchUnavailable?.()
}
export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResult> {
const {
messages,
@@ -84,6 +186,11 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul
const helpers = config.helpers
const systemMessage = config.systemMessage
const modelProvider = config.modelProvider
const webSearchCacheKey = getWebSearchCacheKey(workspace, modelProvider)
const webSearch =
(config.webSearch ?? true) &&
providerSupportsWebSearch(modelProvider.provider) &&
!unsupportedWebSearchCache.has(webSearchCacheKey)
if (onBeforeIteration) {
await onBeforeIteration(tools, helpers)
@@ -108,35 +215,56 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul
const parseOptions = { workspace }
if (isOpenAI) {
const runOpenAIResponses = async (useWebSearch: boolean): Promise<boolean> => {
const completion = await getOpenAIResponsesCompletion(
messageParams,
abortController,
toolDefs,
{
forceModelProvider: modelProvider,
openaiClient: clients.openai,
webSearch: useWebSearch,
reasoningEffort
}
)
const continueCompletion = await parseOpenAIResponsesCompletion(
completion,
callbacks,
messages,
addedMessages,
tools,
helpers,
parseOptions
)
tokenUsage = addChatTokenUsage(tokenUsage, continueCompletion.tokenUsage)
return continueCompletion.shouldContinue
}
let useCompletionsApi = skipResponsesApi
if (!skipResponsesApi) {
try {
const completion = await getOpenAIResponsesCompletion(
messageParams,
abortController,
toolDefs,
{
forceModelProvider: modelProvider,
openaiClient: clients.openai,
reasoningEffort
}
)
const continueCompletion = await parseOpenAIResponsesCompletion(
completion,
callbacks,
messages,
addedMessages,
tools,
helpers,
parseOptions
)
tokenUsage = addChatTokenUsage(tokenUsage, continueCompletion.tokenUsage)
if (!continueCompletion.shouldContinue) {
if (!(await runOpenAIResponses(webSearch))) {
break
}
} catch (err) {
console.warn('OpenAI Responses API failed, falling back to Completions API:', err)
const errorMessage = err instanceof Error ? err.message : String(err)
let fallbackError = err
if (webSearch && shouldRetryWithoutWebSearch(err)) {
markWebSearchUnsupported(webSearchCacheKey, err, config.onWebSearchUnavailable)
try {
if (!(await runOpenAIResponses(false))) {
break
}
continue
} catch (retryErr) {
fallbackError = retryErr
}
}
console.warn(
'OpenAI Responses API failed, falling back to Completions API:',
fallbackError
)
const errorMessage = getErrorText(fallbackError)
if (errorMessage.includes('Responses API is not enabled')) {
skipResponsesApi = true
onSkipResponsesApi?.()
@@ -146,6 +274,11 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul
}
if (useCompletionsApi) {
if (webSearch) {
console.warn(
'Web search is only supported via the OpenAI Responses API; ignoring it for the Completions API fallback.'
)
}
const completion = await getCompletion(messageParams, abortController, toolDefs, {
forceCompletions: true,
forceModelProvider: modelProvider,
@@ -168,12 +301,21 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul
}
}
} else if (isAnthropic) {
const completion = await getAnthropicCompletion(messageParams, abortController, toolDefs, {
forceModelProvider: modelProvider,
anthropicClient: clients.anthropic,
reasoningEffort
})
if (completion) {
const runAnthropic = async (useWebSearch: boolean): Promise<boolean> => {
const completion = await getAnthropicCompletion(
messageParams,
abortController,
toolDefs,
{
forceModelProvider: modelProvider,
anthropicClient: clients.anthropic,
webSearch: useWebSearch,
reasoningEffort
}
)
if (!completion) {
return true
}
const continueCompletion = await parseAnthropicCompletion(
completion,
callbacks,
@@ -185,9 +327,22 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul
parseOptions
)
tokenUsage = addChatTokenUsage(tokenUsage, continueCompletion.tokenUsage)
if (!continueCompletion.shouldContinue) {
return continueCompletion.shouldContinue
}
try {
if (!(await runAnthropic(webSearch))) {
break
}
} catch (err) {
if (webSearch && shouldRetryWithoutWebSearch(err)) {
markWebSearchUnsupported(webSearchCacheKey, err, config.onWebSearchUnavailable)
if (!(await runAnthropic(false))) {
break
}
} else {
throw err
}
}
} else {
const completion = await getCompletion(messageParams, abortController, toolDefs, {
@@ -9,6 +9,7 @@ import {
createOpenAIProxyClient,
getAiProxyBaseURL,
getProviderAndCompletionConfig,
providerSupportsWebSearch,
workspaceAIClients
} from '../lib'
import { applyReasoningToConfig } from '../reasoningRegistry'
@@ -22,6 +23,30 @@ interface ParsedCompletionResult {
tokenUsage: ChatTokenUsage
}
type WebSearchStatus = 'in_progress' | 'searching' | 'completed' | 'failed'
const openAIWebSearchToolId = (itemId: string) => `openai_web_search:${itemId}`
function setOpenAIWebSearchStatus(
callbacks: ToolCallbacks & { onMessageEnd: () => void },
itemId: string,
status: WebSearchStatus
) {
const isLoading = status === 'in_progress' || status === 'searching'
const failed = status === 'failed'
callbacks.onMessageEnd()
callbacks.setToolStatus(openAIWebSearchToolId(itemId), {
content: failed ? 'Web search failed' : isLoading ? 'Searching the web...' : 'Searched the web',
error: failed ? 'Web search failed' : undefined,
isLoading,
isStreamingArguments: false,
needsConfirmation: false,
toolName: 'web_search',
showDetails: false,
autoCollapseDetails: true
})
}
// Conversion utilities for Responses API
function convertMessagesToResponsesInput(messages: ChatCompletionMessageParam[]): {
instructions?: string
@@ -136,6 +161,7 @@ export async function getOpenAIResponsesCompletion(
options?: {
forceModelProvider?: AIProviderModel
openaiClient?: OpenAI
webSearch?: boolean
reasoningEffort?: string
}
) {
@@ -152,6 +178,12 @@ export async function getOpenAIResponsesCompletion(
options?.reasoningEffort
)
// Enable OpenAI's built-in web search tool. The proxy forwards the body
// verbatim, so this reaches OpenAI as a native server-side tool.
if (options?.webSearch && providerSupportsWebSearch(provider)) {
responsesConfig.tools = [...(responsesConfig.tools ?? []), { type: 'web_search' }]
}
const client = options?.openaiClient ?? workspaceAIClients.getOpenaiClient()
const runner = client.responses.stream(
@@ -294,9 +326,23 @@ export async function parseOpenAIResponsesCompletion(
showDetails: tool?.showDetails,
autoCollapseDetails: tool?.autoCollapseDetails
})
} else if (item.type === 'web_search_call' && item.id) {
setOpenAIWebSearchStatus(callbacks, item.id, item.status)
}
})
runner.on('response.web_search_call.in_progress', (event) => {
setOpenAIWebSearchStatus(callbacks, event.item_id, 'in_progress')
})
runner.on('response.web_search_call.searching', (event) => {
setOpenAIWebSearchStatus(callbacks, event.item_id, 'searching')
})
runner.on('response.web_search_call.completed', (event) => {
setOpenAIWebSearchStatus(callbacks, event.item_id, 'completed')
})
// Stream function call arguments incrementally
runner.on('response.function_call_arguments.delta', (event) => {
if (currentStreamingTool?.shouldStream && currentStreamingTool.itemId === event.item_id) {
@@ -371,6 +417,12 @@ export async function parseOpenAIResponsesCompletion(
const finalResponse = await runner.finalResponse()
const tokenUsage = openAIResponsesUsageToChatTokenUsage(finalResponse.usage)
for (const item of finalResponse.output ?? []) {
if (item.type === 'web_search_call') {
setOpenAIWebSearchStatus(callbacks, item.id, item.status)
}
}
// Process tool calls if any
if (toolCallsToProcess.length > 0) {
const assistantWithTools = {
@@ -711,6 +711,18 @@ const PROMPTS_CONFIGS = {
gen: GEN_CONFIG
}
/**
* Whether a provider can use native web search automatically in the web chat.
* Azure OpenAI can expose Responses API `web_search` for some deployments, but
* it is subscription/admin controlled and routes data through Grounding with
* Bing, so do not silently enable it until there is explicit Azure-specific UI.
* Providers behind OpenAI-compatible/native-translation proxy paths have no
* forwardable native web-search tool.
*/
export function providerSupportsWebSearch(provider: AIProvider | undefined): boolean {
return provider === 'openai' || provider === 'anthropic'
}
export function getProviderAndCompletionConfig<K extends boolean>({
messages,
stream,
@@ -9,7 +9,7 @@
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { AI_PROVIDERS, fetchAvailableModels } from '../copilot/lib'
import { AI_PROVIDERS, fetchAvailableModels, providerSupportsWebSearch } from '../copilot/lib'
import { supportsAutocomplete } from '../copilot/utils'
import TestAiKey from '../copilot/TestAIKey.svelte'
import Label from '../Label.svelte'
@@ -88,8 +88,21 @@
return JSON.parse(JSON.stringify(v))
}
function normalizeProviderSettings(
providers: Exclude<AIConfig['providers'], undefined>
): Exclude<AIConfig['providers'], undefined> {
return Object.fromEntries(
Object.entries(providers).map(([provider, config]) => [
provider,
providerSupportsWebSearch(provider as AIProvider)
? { ...config, web_search_enabled: config.web_search_enabled ?? true }
: config
])
)
}
function applyConfig(config: AIConfig | undefined) {
aiProviders = clone(config?.providers ?? {})
aiProviders = normalizeProviderSettings(clone(config?.providers ?? {}))
defaultModel = config?.default_model?.model
metadataModel = config?.metadata_model?.model
codeCompletionModel = config?.code_completion_model?.model
@@ -390,7 +403,10 @@
models:
availableAiModels[provider].length > 0
? [availableAiModels[provider][0]]
: []
: [],
...(providerSupportsWebSearch(provider as AIProvider)
? { web_search_enabled: true }
: {})
}
}
} else {
@@ -459,6 +475,22 @@
If you don't see the model you want, you can type it manually in the selector.
</p>
</Label>
{#if providerSupportsWebSearch(provider as AIProvider)}
<Label label="Web search">
<Toggle
options={{
right: 'Enable native web search',
rightTooltip:
'Uses the provider-native web search tool automatically in chat.'
}}
checked={aiProviders[provider].web_search_enabled !== false}
on:change={(e) => {
aiProviders[provider].web_search_enabled = e.detail
}}
/>
</Label>
{/if}
</div>
{/if}
</div>