import OpenAI from "openai";
import type { WebSearchToolParams } from "../types/mcp.js";
import type { WebSearchRequest, ModelType, Source, Annotation } from "../types/openai.js";
import {
getModelOrDefault,
} from "../utils/models.js";
import { ValidationError, OpenAIError } from "../utils/errors.js";
export interface WebSearchResult {
text: string;
sources?: Source[];
}
export class WebSearchTool {
private client: OpenAI;
private defaultModel: string;
constructor(apiKey: string, defaultModel: string) {
this.client = new OpenAI({ apiKey });
this.defaultModel = defaultModel;
}
async execute(params: WebSearchToolParams): Promise<WebSearchResult> {
this.validateParams(params);
const model = getModelOrDefault(params.model, this.defaultModel);
const request = this.buildRequest(params, model);
try {
const response = await this.client.chat.completions.create(request as any);
return this.formatResponse(response);
} catch (error: any) {
if (error.status || error.response) {
throw new OpenAIError(
`OpenAI API error: ${error.message || error.error?.message || "Unknown error"}`,
error.status
);
}
throw error;
}
}
private validateParams(params: WebSearchToolParams): void {
if (!params.input || params.input.trim().length === 0) {
throw new ValidationError(
"Input parameter is required and cannot be empty"
);
}
if (params.input.length > 10000) {
throw new ValidationError(
"Input query is too long. Maximum length is 10000 characters."
);
}
}
private buildRequest(
params: WebSearchToolParams,
model: ModelType
): WebSearchRequest {
const request: WebSearchRequest = {
model,
messages: [
{
role: "user",
content: params.input,
},
],
web_search_options: {},
};
if (params.user_location) {
const { type, ...approximate } = params.user_location;
request.web_search_options = {
user_location: {
type: type || "approximate",
approximate,
},
};
}
return request;
}
private formatResponse(response: any): WebSearchResult {
if (!response.choices || response.choices.length === 0) {
throw new OpenAIError("No response choices returned from OpenAI API");
}
const message = response.choices[0]?.message;
const content = message?.content;
if (!content) {
throw new OpenAIError("No content in response from OpenAI API");
}
const result: WebSearchResult = {
text: content,
};
const annotations = message?.annotations;
if (annotations && Array.isArray(annotations)) {
const sources: Source[] = [];
const seenUrls = new Set<string>();
for (const annotation of annotations) {
if (annotation.type === "url_citation" && annotation.url_citation) {
const url = annotation.url_citation.url;
const title = annotation.url_citation.title;
if (url && !seenUrls.has(url)) {
sources.push({
url,
title,
});
seenUrls.add(url);
}
}
}
if (sources.length > 0) {
result.sources = sources;
}
}
return result;
}
}