import type { ImportedModelWebSearchCapability } from '../../../../shared/imported-model-profile'; import type { ModelWebSearchErrorCode, ModelWebSearchSuccessV1, } from '../../../../shared/model-tools'; import { proxyAwareFetch, runWithDeadline } from '../../../utils/proxy-fetch'; const MAX_QUERY_CHARS = 2_000; const MAX_ANSWER_CHARS = 20_000; const MAX_SOURCE_COUNT = 20; const MAX_SOURCE_TITLE_CHARS = 240; const MAX_SOURCE_URL_CHARS = 2_048; const MAX_RESPONSE_BYTES = 1_048_576; const DEFAULT_TIMEOUT_MS = 120_000; type FetchImplementation = ( input: string | URL, init?: RequestInit, ) => Promise; export type FrozenSelectedModel = Readonly<{ accountId: string; runtimeProviderId: string; modelId: string; generation: number; baseUrl: string; headers: Readonly>; capability?: ImportedModelWebSearchCapability; }>; export class ModelWebSearchError extends Error { constructor( public readonly code: ModelWebSearchErrorCode, public readonly status: 400 | 409 | 429 | 502 | 503, public readonly retryable: boolean, message: string, ) { super(message); this.name = 'ModelWebSearchError'; } } export interface ModelWebSearchAdapter { search(input: Readonly<{ query: string; selectedModel: FrozenSelectedModel; parentTurnId: string; toolCallId: string; }>, signal: AbortSignal): Promise; } export interface CreateModelWebSearchAdapterOptions { fetchImpl?: FetchImplementation; timeoutMs?: number; } function asRecord(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : null; } function endpoint(baseUrl: string, pathname: 'responses' | 'chat/completions'): string { return `${baseUrl.trim().replace(/\/+$/, '')}/${pathname}`; } function requestHeaders(headers: Readonly>): Record { const result = { ...headers }; const contentType = Object.keys(result).find((name) => name.toLowerCase() === 'content-type'); if (contentType) delete result[contentType]; result['Content-Type'] = 'application/json'; return result; } async function readBoundedJson(response: Response): Promise { const contentLength = Number(response.headers.get('content-length')); if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) { throw new ModelWebSearchError( 'model_web_search_invalid_result', 502, false, 'The selected model returned an oversized Web Search response', ); } if (!response.body) { throw new ModelWebSearchError( 'model_web_search_invalid_result', 502, false, 'The selected model returned an empty Web Search response', ); } const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let byteLength = 0; try { while (true) { const { value, done } = await reader.read(); if (done) break; if (!value) continue; byteLength += value.byteLength; if (byteLength > MAX_RESPONSE_BYTES) { await reader.cancel(); throw new ModelWebSearchError( 'model_web_search_invalid_result', 502, false, 'The selected model returned an oversized Web Search response', ); } chunks.push(value); } } finally { reader.releaseLock(); } const bytes = new Uint8Array(byteLength); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } try { return JSON.parse(new TextDecoder().decode(bytes)) as unknown; } catch { throw new ModelWebSearchError( 'model_web_search_invalid_result', 502, false, 'The selected model returned an invalid Web Search response', ); } } function normalizedSourceUrl(rawUrl: unknown): { identity: string; url: string } | null { if (typeof rawUrl !== 'string') return null; const url = rawUrl.trim(); if (!url || url.length > MAX_SOURCE_URL_CHARS) return null; try { const parsed = new URL(url); if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.username || parsed.password) { return null; } const identity = new URL(parsed.toString()); identity.hash = ''; return { identity: identity.toString(), url: parsed.toString() }; } catch { return null; } } function sourceCandidate(value: unknown): { title: string; url: string; identity: string } | null { const record = asRecord(value); const normalized = normalizedSourceUrl(record?.url); if (!record || !normalized) return null; const rawTitle = typeof record.title === 'string' ? record.title.trim() : ''; let title = rawTitle || new URL(normalized.url).hostname; if (!title || title.length > MAX_SOURCE_TITLE_CHARS) return null; title = title.slice(0, MAX_SOURCE_TITLE_CHARS); return { title, url: normalized.url, identity: normalized.identity }; } function collectSources(payload: Record): Array<{ title: string; url: string }> { const candidates: unknown[] = []; const choices = Array.isArray(payload.choices) ? payload.choices : []; for (const choice of choices) { const message = asRecord(asRecord(choice)?.message); if (Array.isArray(message?.sources)) candidates.push(...message.sources); } const searchInfo = asRecord(payload.search_info ?? payload.searchInfo); if (Array.isArray(searchInfo?.search_results)) candidates.push(...searchInfo.search_results); if (Array.isArray(searchInfo?.searchResults)) candidates.push(...searchInfo.searchResults); const output = Array.isArray(payload.output) ? payload.output : []; for (const item of output) { const outputItem = asRecord(item); const action = asRecord(outputItem?.action); if (Array.isArray(action?.sources)) candidates.push(...action.sources); const content = Array.isArray(outputItem?.content) ? outputItem.content : []; for (const part of content) { const annotations = Array.isArray(asRecord(part)?.annotations) ? asRecord(part)?.annotations as unknown[] : []; for (const annotation of annotations) { const annotationRecord = asRecord(annotation); if (annotationRecord?.type === 'url_citation') candidates.push(annotationRecord); } } } const seen = new Set(); const sources: Array<{ title: string; url: string }> = []; for (const candidate of candidates) { const source = sourceCandidate(candidate); if (!source || seen.has(source.identity)) continue; seen.add(source.identity); sources.push({ title: source.title, url: source.url }); if (sources.length === MAX_SOURCE_COUNT) break; } return sources; } function answerFromPayload(payload: Record): string | null { if (typeof payload.output_text === 'string' && payload.output_text.trim()) { return payload.output_text.trim(); } const choices = Array.isArray(payload.choices) ? payload.choices : []; for (const choice of choices) { const content = asRecord(asRecord(choice)?.message)?.content; if (typeof content === 'string' && content.trim()) return content.trim(); } const parts: string[] = []; const output = Array.isArray(payload.output) ? payload.output : []; for (const item of output) { const content = Array.isArray(asRecord(item)?.content) ? asRecord(item)?.content as unknown[] : []; for (const part of content) { const text = asRecord(part)?.text; if (typeof text === 'string' && text.trim()) parts.push(text.trim()); } } return parts.length > 0 ? parts.join('\n') : null; } function mapHttpError(response: Response): ModelWebSearchError { if (response.status === 429) { return new ModelWebSearchError( 'model_web_search_rate_limited', 429, false, 'The selected model rate-limited Web Search', ); } return new ModelWebSearchError( 'model_web_search_unavailable', 503, true, 'The selected model Web Search transport is unavailable', ); } export function createModelWebSearchAdapter( options: CreateModelWebSearchAdapterOptions = {}, ): ModelWebSearchAdapter { const fetchImpl = options.fetchImpl ?? proxyAwareFetch; const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; return { async search(input, signal) { const query = input.query.trim(); if (!query || query.length > MAX_QUERY_CHARS) { throw new ModelWebSearchError( 'model_web_search_invalid_result', 502, false, 'Web Search query must contain between 1 and 2000 characters', ); } const capability = input.selectedModel.capability; if (!capability || capability.supportsForcedSearch !== true) { throw new ModelWebSearchError( 'model_web_search_unsupported', 400, false, 'The selected model does not support Web Search', ); } const isResponses = capability.adapter === 'openai-responses' || capability.adapter === 'bailian-responses'; const url = endpoint(input.selectedModel.baseUrl, isResponses ? 'responses' : 'chat/completions'); const body = isResponses ? { model: input.selectedModel.modelId, input: query, tools: [{ type: 'web_search' }], tool_choice: 'required', } : { model: input.selectedModel.modelId, messages: [{ role: 'user', content: query }], stream: false, enable_search: true, search_options: { forced_search: true }, }; let response: Response; try { response = await runWithDeadline( async (deadlineSignal) => await fetchImpl(url, { method: 'POST', headers: requestHeaders(input.selectedModel.headers), body: JSON.stringify(body), signal: deadlineSignal, }), timeoutMs, signal, ); } catch (error) { if (error instanceof ModelWebSearchError) throw error; throw new ModelWebSearchError( 'model_web_search_unavailable', 503, true, 'The selected model Web Search transport is unavailable', ); } if (!response.ok) throw mapHttpError(response); const payload = asRecord(await readBoundedJson(response)); const answer = payload ? answerFromPayload(payload) : null; if (!payload || !answer || answer.length > MAX_ANSWER_CHARS) { throw new ModelWebSearchError( 'model_web_search_invalid_result', 502, false, 'The selected model returned an invalid Web Search result', ); } return { schema: 'makelore-model-tool.v1', tool: 'web_search', status: 'succeeded', modelId: input.selectedModel.modelId, answer, sources: collectSources(payload), sourceMode: capability.sourceMode, }; }, }; }