diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index c6d2b6405..a6bc69d71 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -2,8 +2,14 @@ import { describe, expect, it } from "vitest"; import { __testing } from "./web-search.js"; -const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, normalizeFreshness } = - __testing; +const { + inferPerplexityBaseUrlFromApiKey, + resolvePerplexityBaseUrl, + normalizeFreshness, + resolveGrokApiKey, + resolveGrokModel, + resolveGrokInlineCitations, +} = __testing; describe("web_search perplexity baseUrl defaults", () => { it("detects a Perplexity key prefix", () => { @@ -69,3 +75,33 @@ describe("web_search freshness normalization", () => { expect(normalizeFreshness("2024-03-10to2024-03-01")).toBeUndefined(); }); }); + +describe("web_search grok config resolution", () => { + it("uses config apiKey when provided", () => { + expect(resolveGrokApiKey({ apiKey: "xai-test-key" })).toBe("xai-test-key"); + }); + + it("returns undefined when no apiKey is available", () => { + expect(resolveGrokApiKey({})).toBeUndefined(); + expect(resolveGrokApiKey(undefined)).toBeUndefined(); + }); + + it("uses default model when not specified", () => { + expect(resolveGrokModel({})).toBe("grok-4-1-fast"); + expect(resolveGrokModel(undefined)).toBe("grok-4-1-fast"); + }); + + it("uses config model when provided", () => { + expect(resolveGrokModel({ model: "grok-3" })).toBe("grok-3"); + }); + + it("defaults inlineCitations to false", () => { + expect(resolveGrokInlineCitations({})).toBe(false); + expect(resolveGrokInlineCitations(undefined)).toBe(false); + }); + + it("respects inlineCitations config", () => { + expect(resolveGrokInlineCitations({ inlineCitations: true })).toBe(true); + expect(resolveGrokInlineCitations({ inlineCitations: false })).toBe(false); + }); +}); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 1d87676e8..b237afc3b 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -17,7 +17,7 @@ import { writeCache, } from "./web-shared.js"; -const SEARCH_PROVIDERS = ["brave", "perplexity"] as const; +const SEARCH_PROVIDERS = ["brave", "perplexity", "grok"] as const; const DEFAULT_SEARCH_COUNT = 5; const MAX_SEARCH_COUNT = 10; @@ -28,6 +28,9 @@ const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro"; const PERPLEXITY_KEY_PREFIXES = ["pplx-"]; const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; +const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses"; +const DEFAULT_GROK_MODEL = "grok-4-1-fast"; + const SEARCH_CACHE = new Map>>(); 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})$/; @@ -92,6 +95,22 @@ type PerplexityConfig = { type PerplexityApiKeySource = "config" | "perplexity_env" | "openrouter_env" | "none"; +type GrokConfig = { + apiKey?: string; + model?: string; + inlineCitations?: boolean; +}; + +type GrokSearchResponse = { + output_text?: string; + citations?: string[]; + inline_citations?: Array<{ + start_index: number; + end_index: number; + url: string; + }>; +}; + type PerplexitySearchResponse = { choices?: Array<{ message?: { @@ -131,6 +150,14 @@ function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) { docs: "https://docs.molt.bot/tools/web", }; } + if (provider === "grok") { + return { + error: "missing_xai_api_key", + message: + "web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure tools.web.search.grok.apiKey.", + docs: "https://docs.molt.bot/tools/web", + }; + } return { error: "missing_brave_api_key", message: `web_search needs a Brave Search API key. Run \`${formatCliCommand("moltbot configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`, @@ -144,6 +171,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE ? search.provider.trim().toLowerCase() : ""; if (raw === "perplexity") return "perplexity"; + if (raw === "grok") return "grok"; if (raw === "brave") return "brave"; return "brave"; } @@ -221,6 +249,30 @@ function resolvePerplexityModel(perplexity?: PerplexityConfig): string { return fromConfig || DEFAULT_PERPLEXITY_MODEL; } +function resolveGrokConfig(search?: WebSearchConfig): GrokConfig { + if (!search || typeof search !== "object") return {}; + const grok = "grok" in search ? search.grok : undefined; + if (!grok || typeof grok !== "object") return {}; + return grok as GrokConfig; +} + +function resolveGrokApiKey(grok?: GrokConfig): string | undefined { + const fromConfig = normalizeApiKey(grok?.apiKey); + if (fromConfig) return fromConfig; + const fromEnv = normalizeApiKey(process.env.XAI_API_KEY); + return fromEnv || undefined; +} + +function resolveGrokModel(grok?: GrokConfig): string { + const fromConfig = + grok && "model" in grok && typeof grok.model === "string" ? grok.model.trim() : ""; + return fromConfig || DEFAULT_GROK_MODEL; +} + +function resolveGrokInlineCitations(grok?: GrokConfig): boolean { + return grok?.inlineCitations === true; +} + 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))); @@ -306,6 +358,50 @@ async function runPerplexitySearch(params: { return { content, citations }; } +async function runGrokSearch(params: { + query: string; + apiKey: string; + model: string; + timeoutSeconds: number; + inlineCitations: boolean; +}): Promise<{ content: string; citations: string[] }> { + const body: Record = { + model: params.model, + input: [ + { + role: "user", + content: params.query, + }, + ], + tools: [{ type: "web_search" }], + }; + + if (params.inlineCitations) { + body.include = ["inline_citations"]; + } + + const res = await fetch(XAI_API_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + }, + body: JSON.stringify(body), + signal: withTimeout(undefined, params.timeoutSeconds * 1000), + }); + + if (!res.ok) { + const detail = await readResponseText(res); + throw new Error(`xAI API error (${res.status}): ${detail || res.statusText}`); + } + + const data = (await res.json()) as GrokSearchResponse; + const content = data.output_text ?? "No response"; + const citations = data.citations ?? []; + + return { content, citations }; +} + async function runWebSearch(params: { query: string; count: number; @@ -319,6 +415,8 @@ async function runWebSearch(params: { freshness?: string; perplexityBaseUrl?: string; perplexityModel?: string; + grokModel?: string; + grokInlineCitations?: boolean; }): Promise> { const cacheKey = normalizeCacheKey( params.provider === "brave" @@ -351,6 +449,27 @@ async function runWebSearch(params: { return payload; } + if (params.provider === "grok") { + const { content, citations } = await runGrokSearch({ + query: params.query, + apiKey: params.apiKey, + model: params.grokModel ?? DEFAULT_GROK_MODEL, + timeoutSeconds: params.timeoutSeconds, + inlineCitations: params.grokInlineCitations ?? false, + }); + + const payload = { + query: params.query, + provider: params.provider, + model: params.grokModel ?? DEFAULT_GROK_MODEL, + tookMs: Date.now() - start, + content, + citations, + }; + writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + } + if (params.provider !== "brave") { throw new Error("Unsupported web search provider."); } @@ -415,11 +534,14 @@ export function createWebSearchTool(options?: { const provider = resolveSearchProvider(search); const perplexityConfig = resolvePerplexityConfig(search); + const grokConfig = resolveGrokConfig(search); const description = provider === "perplexity" ? "Search the web using Perplexity Sonar (direct or via OpenRouter). Returns AI-synthesized answers with citations from real-time web search." - : "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research."; + : provider === "grok" + ? "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search." + : "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research."; return { label: "Web Search", @@ -430,7 +552,11 @@ export function createWebSearchTool(options?: { const perplexityAuth = provider === "perplexity" ? resolvePerplexityApiKey(perplexityConfig) : undefined; const apiKey = - provider === "perplexity" ? perplexityAuth?.apiKey : resolveSearchApiKey(search); + provider === "perplexity" + ? perplexityAuth?.apiKey + : provider === "grok" + ? resolveGrokApiKey(grokConfig) + : resolveSearchApiKey(search); if (!apiKey) { return jsonResult(missingSearchKeyPayload(provider)); @@ -476,6 +602,8 @@ export function createWebSearchTool(options?: { perplexityAuth?.apiKey, ), perplexityModel: resolvePerplexityModel(perplexityConfig), + grokModel: resolveGrokModel(grokConfig), + grokInlineCitations: resolveGrokInlineCitations(grokConfig), }); return jsonResult(result); }, @@ -486,4 +614,7 @@ export const __testing = { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, normalizeFreshness, + resolveGrokApiKey, + resolveGrokModel, + resolveGrokInlineCitations, } as const; diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index db32cb59d..b5c15e176 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -336,8 +336,8 @@ export type ToolsConfig = { search?: { /** Enable web search tool (default: true when API key is present). */ enabled?: boolean; - /** Search provider ("brave" or "perplexity"). */ - provider?: "brave" | "perplexity"; + /** Search provider ("brave", "perplexity", or "grok"). */ + provider?: "brave" | "perplexity" | "grok"; /** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */ apiKey?: string; /** Default search results count (1-10). */ @@ -355,6 +355,15 @@ export type ToolsConfig = { /** Model to use (defaults to "perplexity/sonar-pro"). */ model?: string; }; + /** Grok-specific configuration (used when provider="grok"). */ + grok?: { + /** API key for xAI (defaults to XAI_API_KEY env var). */ + apiKey?: string; + /** Model to use (defaults to "grok-4-1-fast"). */ + model?: string; + /** Include inline citations in response text as markdown links (default: false). */ + inlineCitations?: boolean; + }; }; fetch?: { /** Enable web fetch tool (default: true). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 7e95c3538..102297d1d 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -165,7 +165,7 @@ export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) => export const ToolsWebSearchSchema = z .object({ enabled: z.boolean().optional(), - provider: z.union([z.literal("brave"), z.literal("perplexity")]).optional(), + provider: z.union([z.literal("brave"), z.literal("perplexity"), z.literal("grok")]).optional(), apiKey: z.string().optional(), maxResults: z.number().int().positive().optional(), timeoutSeconds: z.number().int().positive().optional(), @@ -178,6 +178,14 @@ export const ToolsWebSearchSchema = z }) .strict() .optional(), + grok: z + .object({ + apiKey: z.string().optional(), + model: z.string().optional(), + inlineCitations: z.boolean().optional(), + }) + .strict() + .optional(), }) .strict() .optional();