feat(tools): replace web search with CopilotHub API
Remove Brave and Perplexity providers in favor of a single CopilotHub search endpoint (api-dev.copilothub.ai/web-search). Simplifies schema to query-only, removes credential dependencies, retains caching. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
363c65b392
commit
8ea2f2a1bf
1 changed files with 39 additions and 357 deletions
|
|
@ -13,387 +13,96 @@ import {
|
|||
writeCache,
|
||||
} from "./cache.js";
|
||||
import type { CacheEntry } from "./cache.js";
|
||||
import { jsonResult, readNumberParam, readStringParam } from "./param-helpers.js";
|
||||
import { credentialManager } from "../../credentials.js";
|
||||
import { jsonResult, readStringParam } from "./param-helpers.js";
|
||||
|
||||
export const SEARCH_PROVIDERS = ["brave", "perplexity"] as const;
|
||||
export type SearchProvider = (typeof SEARCH_PROVIDERS)[number];
|
||||
|
||||
export const DEFAULT_SEARCH_COUNT = 5;
|
||||
export const MAX_SEARCH_COUNT = 10;
|
||||
|
||||
const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
|
||||
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
||||
const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai";
|
||||
// Model names differ by provider: Perplexity direct uses "sonar-pro", OpenRouter uses "perplexity/sonar-pro"
|
||||
const PERPLEXITY_DIRECT_MODEL = "sonar-pro";
|
||||
const OPENROUTER_PERPLEXITY_MODEL = "perplexity/sonar-pro";
|
||||
const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
|
||||
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
|
||||
const COPILOTHUB_SEARCH_ENDPOINT = "https://api-dev.copilothub.ai/web-search";
|
||||
|
||||
const SEARCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
|
||||
const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]);
|
||||
const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/;
|
||||
|
||||
const WebSearchSchema = Type.Object({
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
provider: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
'Search provider: "brave" (default, traditional search results) or "perplexity" (AI-synthesized answers).',
|
||||
}),
|
||||
),
|
||||
count: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of results to return (1-10). Default: 5. Brave only.",
|
||||
minimum: 1,
|
||||
maximum: MAX_SEARCH_COUNT,
|
||||
}),
|
||||
),
|
||||
country: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"2-letter country code for region-specific results (e.g., 'DE', 'US'). Default: 'US'.",
|
||||
}),
|
||||
),
|
||||
freshness: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Filter results by time (Brave only): 'pd' (past day), 'pw' (past week), 'pm' (past month), 'py' (past year), or 'YYYY-MM-DDtoYYYY-MM-DD'.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type WebSearchArgs = {
|
||||
query: string;
|
||||
provider?: string;
|
||||
count?: number;
|
||||
country?: string;
|
||||
freshness?: string;
|
||||
};
|
||||
|
||||
type BraveSearchResult = {
|
||||
title?: string;
|
||||
url?: string;
|
||||
description?: string;
|
||||
age?: string;
|
||||
};
|
||||
|
||||
type BraveSearchResponse = {
|
||||
web?: {
|
||||
results?: BraveSearchResult[];
|
||||
};
|
||||
};
|
||||
|
||||
type PerplexitySearchResponse = {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: string;
|
||||
};
|
||||
type CopilotHubSearchResponse = {
|
||||
items: Array<{
|
||||
title: string;
|
||||
link: string;
|
||||
displayLink: string;
|
||||
snippet: string;
|
||||
}>;
|
||||
citations?: string[];
|
||||
};
|
||||
|
||||
export type WebSearchResult = {
|
||||
query: string;
|
||||
provider: SearchProvider;
|
||||
tookMs: number;
|
||||
cached?: boolean;
|
||||
} & (
|
||||
| {
|
||||
// Brave result
|
||||
count: number;
|
||||
results: Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
description: string;
|
||||
published?: string;
|
||||
siteName?: string;
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
// Perplexity result
|
||||
model: string;
|
||||
content: string;
|
||||
citations: string[];
|
||||
}
|
||||
);
|
||||
|
||||
export function resolveSearchCount(value: unknown, fallback: number): number {
|
||||
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
const clamped = Math.max(1, Math.min(MAX_SEARCH_COUNT, Math.floor(parsed)));
|
||||
return clamped;
|
||||
}
|
||||
|
||||
export function normalizeFreshness(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (BRAVE_FRESHNESS_SHORTCUTS.has(lower)) return lower;
|
||||
|
||||
const match = trimmed.match(BRAVE_FRESHNESS_RANGE);
|
||||
if (!match) return undefined;
|
||||
|
||||
const start = match[1];
|
||||
const end = match[2];
|
||||
if (!start || !end) return undefined;
|
||||
if (!isValidIsoDate(start) || !isValidIsoDate(end)) return undefined;
|
||||
if (start > end) return undefined;
|
||||
|
||||
return `${start}to${end}`;
|
||||
}
|
||||
|
||||
function isValidIsoDate(value: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||||
const parts = value.split("-").map((part) => Number.parseInt(part, 10));
|
||||
const year = parts[0];
|
||||
const month = parts[1];
|
||||
const day = parts[2];
|
||||
if (year === undefined || month === undefined || day === undefined) return false;
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return false;
|
||||
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
return (
|
||||
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSiteName(url: string | undefined): string | undefined {
|
||||
if (!url) return undefined;
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function inferPerplexityConfig(apiKey: string): { baseUrl: string; defaultModel: string } {
|
||||
const normalized = apiKey.toLowerCase();
|
||||
if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
|
||||
return { baseUrl: PERPLEXITY_DIRECT_BASE_URL, defaultModel: PERPLEXITY_DIRECT_MODEL };
|
||||
}
|
||||
if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
|
||||
return { baseUrl: OPENROUTER_BASE_URL, defaultModel: OPENROUTER_PERPLEXITY_MODEL };
|
||||
}
|
||||
// Default to OpenRouter for unknown key formats
|
||||
return { baseUrl: OPENROUTER_BASE_URL, defaultModel: OPENROUTER_PERPLEXITY_MODEL };
|
||||
}
|
||||
|
||||
export function resolvePerplexityApiKey(): { apiKey: string; source: string } | { apiKey: null; source: "none" } {
|
||||
const perplexityKey = (credentialManager.getToolConfig("perplexity")?.apiKey ?? "").trim();
|
||||
if (perplexityKey) {
|
||||
return { apiKey: perplexityKey, source: "perplexity" };
|
||||
}
|
||||
|
||||
const openrouterKey = (credentialManager.getToolConfig("openrouter")?.apiKey ?? "").trim();
|
||||
if (openrouterKey) {
|
||||
return { apiKey: openrouterKey, source: "openrouter" };
|
||||
}
|
||||
|
||||
return { apiKey: null, source: "none" };
|
||||
}
|
||||
|
||||
export function resolveBraveApiKey(): string | undefined {
|
||||
return (credentialManager.getToolConfig("brave")?.apiKey ?? "").trim() || undefined;
|
||||
}
|
||||
|
||||
export function resolveProvider(requested?: string): SearchProvider {
|
||||
if (requested === "perplexity") return "perplexity";
|
||||
if (requested === "brave") return "brave";
|
||||
|
||||
// Auto-detect based on available API keys
|
||||
const braveKey = resolveBraveApiKey();
|
||||
if (braveKey) return "brave";
|
||||
|
||||
const perplexityResult = resolvePerplexityApiKey();
|
||||
if (perplexityResult.apiKey) return "perplexity";
|
||||
|
||||
// Default to brave
|
||||
return "brave";
|
||||
}
|
||||
|
||||
export async function runPerplexitySearch(params: {
|
||||
query: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
timeoutSeconds: number;
|
||||
}): Promise<{ content: string; citations: string[] }> {
|
||||
const endpoint = `${params.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"HTTP-Referer": "https://multica.ai",
|
||||
"X-Title": "Multica Web Search",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: params.model,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: params.query,
|
||||
},
|
||||
],
|
||||
}),
|
||||
signal: withTimeout(undefined, params.timeoutSeconds * 1000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await readResponseText(res);
|
||||
throw new Error(`Perplexity API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as PerplexitySearchResponse;
|
||||
const content = data.choices?.[0]?.message?.content ?? "No response";
|
||||
const citations = data.citations ?? [];
|
||||
|
||||
return { content, citations };
|
||||
}
|
||||
|
||||
export async function runBraveSearch(params: {
|
||||
query: string;
|
||||
count: number;
|
||||
apiKey: string;
|
||||
results: Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
displayLink: string;
|
||||
snippet: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
async function runCopilotHubSearch(params: {
|
||||
query: string;
|
||||
timeoutSeconds: number;
|
||||
country: string | undefined;
|
||||
freshness: string | undefined;
|
||||
}): Promise<{
|
||||
results: Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
description: string;
|
||||
published?: string;
|
||||
siteName?: string;
|
||||
displayLink: string;
|
||||
snippet: string;
|
||||
}>;
|
||||
}> {
|
||||
const url = new URL(BRAVE_SEARCH_ENDPOINT);
|
||||
url.searchParams.set("q", params.query);
|
||||
url.searchParams.set("count", String(params.count));
|
||||
if (params.country) {
|
||||
url.searchParams.set("country", params.country);
|
||||
}
|
||||
if (params.freshness) {
|
||||
url.searchParams.set("freshness", params.freshness);
|
||||
}
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"X-Subscription-Token": params.apiKey,
|
||||
},
|
||||
const res = await fetch(COPILOTHUB_SEARCH_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ q: params.query }),
|
||||
signal: withTimeout(undefined, params.timeoutSeconds * 1000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await readResponseText(res);
|
||||
throw new Error(`Brave Search API error (${res.status}): ${detail || res.statusText}`);
|
||||
throw new Error(`CopilotHub search API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as BraveSearchResponse;
|
||||
const rawResults = Array.isArray(data.web?.results) ? (data.web?.results ?? []) : [];
|
||||
const results = rawResults.map((entry) => {
|
||||
const result: {
|
||||
title: string;
|
||||
url: string;
|
||||
description: string;
|
||||
published?: string;
|
||||
siteName?: string;
|
||||
} = {
|
||||
title: entry.title ?? "",
|
||||
url: entry.url ?? "",
|
||||
description: entry.description ?? "",
|
||||
};
|
||||
if (entry.age) {
|
||||
result.published = entry.age;
|
||||
}
|
||||
const siteName = resolveSiteName(entry.url);
|
||||
if (siteName) {
|
||||
result.siteName = siteName;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
return { results };
|
||||
const data = (await res.json()) as CopilotHubSearchResponse;
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
|
||||
return {
|
||||
results: items.map((item) => ({
|
||||
title: item.title ?? "",
|
||||
url: item.link ?? "",
|
||||
displayLink: item.displayLink ?? "",
|
||||
snippet: item.snippet ?? "",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function runWebSearch(params: {
|
||||
query: string;
|
||||
provider: SearchProvider;
|
||||
count: number;
|
||||
timeoutSeconds: number;
|
||||
cacheTtlMs: number;
|
||||
country: string | undefined;
|
||||
freshness: string | undefined;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const cacheKey = normalizeCacheKey(
|
||||
`${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.freshness || "default"}`,
|
||||
);
|
||||
const cacheKey = normalizeCacheKey(params.query);
|
||||
const cached = readCache(SEARCH_CACHE, cacheKey);
|
||||
if (cached) return { ...cached.value, cached: true };
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
if (params.provider === "perplexity") {
|
||||
const perplexityResult = resolvePerplexityApiKey();
|
||||
if (!perplexityResult.apiKey) {
|
||||
return {
|
||||
error: "missing_api_key",
|
||||
message:
|
||||
"Perplexity search requires tools.perplexity.apiKey (or tools.openrouter.apiKey) in credentials.json5.",
|
||||
};
|
||||
}
|
||||
|
||||
const apiKey = perplexityResult.apiKey;
|
||||
const perplexityConfig = credentialManager.getToolConfig("perplexity");
|
||||
const inferred = inferPerplexityConfig(apiKey);
|
||||
const baseUrl = (perplexityConfig?.baseUrl ?? "").trim() || inferred.baseUrl;
|
||||
const model = (perplexityConfig?.model ?? "").trim() || inferred.defaultModel;
|
||||
const { content, citations } = await runPerplexitySearch({
|
||||
query: params.query,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
query: params.query,
|
||||
provider: params.provider,
|
||||
model,
|
||||
tookMs: Date.now() - start,
|
||||
content,
|
||||
citations,
|
||||
};
|
||||
writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs);
|
||||
return payload;
|
||||
}
|
||||
|
||||
// Brave search
|
||||
const apiKey = resolveBraveApiKey();
|
||||
if (!apiKey) {
|
||||
return {
|
||||
error: "missing_api_key",
|
||||
message: "Brave search requires tools.brave.apiKey in credentials.json5.",
|
||||
};
|
||||
}
|
||||
|
||||
const { results } = await runBraveSearch({
|
||||
const { results } = await runCopilotHubSearch({
|
||||
query: params.query,
|
||||
count: params.count,
|
||||
apiKey,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
country: params.country,
|
||||
freshness: params.freshness,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
query: params.query,
|
||||
provider: params.provider,
|
||||
count: results.length,
|
||||
tookMs: Date.now() - start,
|
||||
results,
|
||||
|
|
@ -407,44 +116,17 @@ export function createWebSearchTool(): AgentTool<typeof WebSearchSchema, unknown
|
|||
name: "web_search",
|
||||
label: "Web Search",
|
||||
description:
|
||||
'Search the web. Supports "brave" (traditional results with titles/URLs/snippets) and "perplexity" (AI-synthesized answers with citations). Provider auto-detected from available API keys if not specified.',
|
||||
"Search the web for information. Returns a list of results with titles, URLs, and snippets.",
|
||||
parameters: WebSearchSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as WebSearchArgs;
|
||||
const query = readStringParam(params as Record<string, unknown>, "query", { required: true });
|
||||
const providerRaw = readStringParam(params as Record<string, unknown>, "provider");
|
||||
const provider = resolveProvider(providerRaw);
|
||||
const count =
|
||||
readNumberParam(params as Record<string, unknown>, "count", { integer: true }) ??
|
||||
DEFAULT_SEARCH_COUNT;
|
||||
const country = readStringParam(params as Record<string, unknown>, "country");
|
||||
const rawFreshness = readStringParam(params as Record<string, unknown>, "freshness");
|
||||
|
||||
if (rawFreshness && provider !== "brave") {
|
||||
return jsonResult({
|
||||
error: "unsupported_parameter",
|
||||
message: "freshness parameter is only supported by the Brave search provider.",
|
||||
});
|
||||
}
|
||||
|
||||
const freshness = rawFreshness ? normalizeFreshness(rawFreshness) : undefined;
|
||||
if (rawFreshness && !freshness) {
|
||||
return jsonResult({
|
||||
error: "invalid_freshness",
|
||||
message:
|
||||
"freshness must be one of: pd (past day), pw (past week), pm (past month), py (past year), or YYYY-MM-DDtoYYYY-MM-DD.",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runWebSearch({
|
||||
query,
|
||||
provider,
|
||||
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
timeoutSeconds: resolveTimeoutSeconds(DEFAULT_TIMEOUT_SECONDS, DEFAULT_TIMEOUT_SECONDS),
|
||||
cacheTtlMs: resolveCacheTtlMs(DEFAULT_CACHE_TTL_MINUTES, DEFAULT_CACHE_TTL_MINUTES),
|
||||
country,
|
||||
freshness,
|
||||
});
|
||||
return jsonResult(result);
|
||||
} catch (error) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue