Skip to main content
Glama
api.ts9.96 kB
import { config as loadEnv } from "dotenv"; import { logger } from "./logger.js"; loadEnv(); export class DeepSearchAPIError extends Error { constructor(message: string, options?: ErrorOptions) { super(message, options); this.name = "DeepSearchAPIError"; } } export interface DeepSearchUsage { input_tokens: number; output_tokens: number; } export interface DeepSearchItem { title: string; snippet?: string; url: string; score?: number | null; } export interface DeepSearchResponsePayload { items: DeepSearchItem[]; metadata: Record<string, unknown>; usage: DeepSearchUsage; } export interface InvokePayload { query: string; top_k?: number; locale?: string; filters?: Record<string, unknown>; } export class DeepSearchConfig { readonly apiKey: string; readonly baseUrl: string; readonly model: string; readonly timeoutMs: number; constructor(params: { apiKey: string; baseUrl?: string; model?: string; timeoutMs?: number }) { this.apiKey = params.apiKey; this.baseUrl = params.baseUrl ? normalizeBaseUrl(params.baseUrl) : "https://yunwu.ai"; this.model = params.model ?? "gemini-2.5-flash"; this.timeoutMs = params.timeoutMs ?? 400_000; } static fromEnv(): DeepSearchConfig { const apiKey = getFirstEnv(["DEEPSEARCH_API_KEY", "API_KEY", "DEEPSEARCH_TOKEN"]); if (!apiKey) { throw new DeepSearchAPIError("缺少 DEEPSEARCH_API_KEY 配置"); } const rawBase = getFirstEnv(["DEEPSEARCH_BASE_URL", "BASE_URL"]) ?? "https://yunwu.ai"; const model = getFirstEnv(["DEEPSEARCH_MODEL", "MODEL_NAME", "MODEL"]) ?? "gemini-2.5-flash"; const timeoutRaw = getFirstEnv(["DEEPSEARCH_TIMEOUT", "TIMEOUT"]) ?? "400"; const timeoutValue = Number(timeoutRaw); if (Number.isNaN(timeoutValue)) { throw new DeepSearchAPIError("DEEPSEARCH_TIMEOUT 必须为数字"); } return new DeepSearchConfig({ apiKey, baseUrl: rawBase, model, timeoutMs: timeoutValue * 1000, }); } } function getFirstEnv(keys: string[]): string | undefined { for (const key of keys) { const value = process.env[key]; if (value && value.trim().length > 0) { return value.trim(); } } return undefined; } function normalizeBaseUrl(rawUrl: string): string { try { const url = new URL(rawUrl); return `${url.protocol}//${url.host}`; } catch (error) { throw new DeepSearchAPIError("DEEPSEARCH_BASE_URL 配置无效", { cause: error as Error }); } } export interface DeepSearchTransportOptions { apiKey: string; baseUrl?: string; model?: string; timeoutMs?: number; } export class DeepSearchTransport { private readonly config: DeepSearchConfig; private readonly endpoint: URL; private readonly logger = logger.child({ module: "DeepSearchTransport" }); constructor(options: DeepSearchTransportOptions | DeepSearchConfig) { this.config = options instanceof DeepSearchConfig ? options : new DeepSearchConfig(options); this.endpoint = new URL("/v1beta/models/gemini-2.5-flash:generateContent", this.config.baseUrl); this.endpoint.searchParams.set("key", this.config.apiKey); } static fromEnv(): DeepSearchTransport { return new DeepSearchTransport(DeepSearchConfig.fromEnv()); } async invokeTool(toolName: string, payload: InvokePayload): Promise<DeepSearchResponsePayload> { const body = this.buildRequest(toolName, payload); this.logger.info("调用 DeepSearch API", { toolName, endpoint: this.endpoint.toString(), timeoutMs: this.config.timeoutMs, }); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs); try { const response = await fetch(this.endpoint, { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json", }, body: JSON.stringify(body), signal: controller.signal, }); if (!response.ok) { const message = `DeepSearch API 返回错误状态: ${response.status}`; this.logger.warn(message, { responseHeaders: Object.fromEntries(response.headers.entries()) }); throw new DeepSearchAPIError(message); } const data = (await response.json()) as Record<string, unknown>; this.logger.debug("收到 DeepSearch API 响应", data); return this.parseResponse(data); } catch (error) { if (error instanceof DeepSearchAPIError) { this.logger.error("DeepSearch API 调用失败", error); throw error; } if ((error as Error).name === "AbortError") { const timeoutError = new DeepSearchAPIError("DeepSearch API 请求超时", { cause: error as Error }); this.logger.error("DeepSearch API 请求超时", timeoutError); throw timeoutError; } const wrapped = new DeepSearchAPIError(`DeepSearch API 请求失败: ${(error as Error).message}`, { cause: error as Error, }); this.logger.error("DeepSearch API 调用过程中发生异常", wrapped); throw wrapped; } finally { clearTimeout(timeout); } } private buildRequest(toolName: string, payload: InvokePayload): Record<string, unknown> { const prompt = this.buildUserPrompt(toolName, payload); return { contents: [ { role: "user", parts: [{ text: prompt }], }, ], tools: [{ googleSearch: {} }], }; } private parseResponse(data: Record<string, unknown>): DeepSearchResponsePayload { const candidates = data?.candidates; if (!Array.isArray(candidates) || candidates.length === 0) { this.logger.error("DeepSearch API 响应缺少候选项", data); throw new DeepSearchAPIError("DeepSearch API 响应缺少有效的消息内容"); } const content = extractTextFromCandidate(candidates[0] as Record<string, unknown>); if (!content) { this.logger.error("DeepSearch API 响应无法提取文本", candidates[0]); throw new DeepSearchAPIError("DeepSearch API 响应内容不是合法的 JSON"); } let parsed: Record<string, unknown>; try { parsed = JSON.parse(sanitizeJsonContent(content)); } catch (error) { this.logger.error("DeepSearch API 响应 JSON 解析失败", { content, error }); throw new DeepSearchAPIError("DeepSearch API 响应内容不是合法的 JSON", { cause: error as Error }); } const itemsRaw = Array.isArray(parsed.items) ? (parsed.items as DeepSearchItem[]) : []; const metadata = (parsed.metadata as Record<string, unknown>) ?? {}; let usage = parsed.usage as DeepSearchUsage | undefined; if (!usage) { const usageMetadata = (data.usageMetadata ?? {}) as Record<string, unknown>; usage = { input_tokens: Number(usageMetadata.promptTokenCount ?? 0), output_tokens: Number(usageMetadata.candidatesTokenCount ?? usageMetadata.cachedContentTokenCount ?? 0), }; } return { items: itemsRaw.map((item) => ({ title: item.title, snippet: item.snippet ?? "", url: item.url, score: item.score ?? null, })), metadata, usage, }; } private buildUserPrompt(toolName: string, payload: InvokePayload): string { const topK = payload.top_k ?? 5; const locale = payload.locale ?? "zh-CN"; const filters = payload.filters ?? {}; const filterInstruction = toolName === "deepsearch-web" ? "必须使用 filters 中的 site/time_range 限制,确保返回结果满足条件。" : "可结合 filters 中提供的约束优化检索。"; return [ "任务: 调用 googleSearch 工具检索并汇总最新的权威信息。", `查询: ${payload.query}`, `语言: ${locale}`, `返回条数: ${topK}`, `附加筛选: ${JSON.stringify(filters)}`, filterInstruction, "输出格式要求: 必须返回合法 JSON,不能包含 Markdown、注释、额外文本或代码块标记。", "最终只输出以下结构: {\"items\":[{\"title\":string,\"snippet\":string,\"url\":string,\"score\":number|null}],\"metadata\":{\"source\":string,\"locale\":string,\"top_k\":number,\"filters\":object},\"usage\":{\"input_tokens\":number,\"output_tokens\":number}}。", "items 按相关度降序,snippet 使用中文简洁总结,score 为可信度(0-1),无法给出则为 null。", "metadata.source 固定为 'google-search',并补充 locale/top_k/filters 信息。", "⚠️ 严禁输出任何额外字符(包括 ```、解释文字、列表、粗体等)。", ].join("\n"); } close(): void { // 当前实现使用无状态 HTTP 请求,无需保留连接 } } function extractTextFromCandidate(candidate: Record<string, unknown>): string | undefined { const content = candidate?.content as Record<string, unknown> | undefined; const parts = content?.parts; if (!Array.isArray(parts)) { return undefined; } for (const part of parts) { const text = (part as Record<string, unknown>)?.text; if (typeof text === "string" && text.trim().length > 0) { return text; } } return undefined; } function sanitizeJsonContent(raw: string): string { const trimmed = raw.trim(); if (trimmed.startsWith("```")) { const lines = trimmed.split(/\r?\n/); // remove first fence lines.shift(); // remove closing fence if present at end if (lines.length > 0 && lines[lines.length - 1].trim().startsWith("```")) { lines.pop(); } return lines.join("\n").trim(); } // 去除常见的 Markdown 前缀(如 **、- 、序号等) const withoutMarkdown = trimmed .replace(/^\s*[-*+]\s+/gm, "") .replace(/^\s*\d+\.\s+/gm, "") .replace(/^\*\*(.*?)\*\*/gm, "$1"); const match = withoutMarkdown.match(/\{[\s\S]*\}/); if (match) { return match[0]; } return withoutMarkdown; }

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/yuemingruoan/DeepSearch-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server