327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
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<Response>;
|
|
|
|
export type FrozenSelectedModel = Readonly<{
|
|
accountId: string;
|
|
runtimeProviderId: string;
|
|
modelId: string;
|
|
generation: number;
|
|
baseUrl: string;
|
|
headers: Readonly<Record<string, string>>;
|
|
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<ModelWebSearchSuccessV1>;
|
|
}
|
|
|
|
export interface CreateModelWebSearchAdapterOptions {
|
|
fetchImpl?: FetchImplementation;
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: null;
|
|
}
|
|
|
|
function endpoint(baseUrl: string, pathname: 'responses' | 'chat/completions'): string {
|
|
return `${baseUrl.trim().replace(/\/+$/, '')}/${pathname}`;
|
|
}
|
|
|
|
function requestHeaders(headers: Readonly<Record<string, string>>): Record<string, string> {
|
|
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<unknown> {
|
|
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<string, unknown>): 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<string>();
|
|
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, unknown>): 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,
|
|
};
|
|
},
|
|
};
|
|
}
|