feat(tools): add Grok (xAI) as web_search provider
Add xAI's Grok as a new web_search provider alongside Brave and Perplexity.
Uses the xAI /v1/responses API with tools: [{type: "web_search"}].
Configuration:
- tools.web.search.provider: "grok"
- tools.web.search.grok.apiKey or XAI_API_KEY env var
- tools.web.search.grok.model (default: grok-4-1-fast)
- tools.web.search.grok.inlineCitations (optional, embeds markdown links)
Returns AI-synthesized answers with citations similar to Perplexity.
This commit is contained in:
parent
4583f88626
commit
a25356dea7
@ -2,8 +2,14 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import { __testing } from "./web-search.js";
|
import { __testing } from "./web-search.js";
|
||||||
|
|
||||||
const { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, normalizeFreshness } =
|
const {
|
||||||
__testing;
|
inferPerplexityBaseUrlFromApiKey,
|
||||||
|
resolvePerplexityBaseUrl,
|
||||||
|
normalizeFreshness,
|
||||||
|
resolveGrokApiKey,
|
||||||
|
resolveGrokModel,
|
||||||
|
resolveGrokInlineCitations,
|
||||||
|
} = __testing;
|
||||||
|
|
||||||
describe("web_search perplexity baseUrl defaults", () => {
|
describe("web_search perplexity baseUrl defaults", () => {
|
||||||
it("detects a Perplexity key prefix", () => {
|
it("detects a Perplexity key prefix", () => {
|
||||||
@ -69,3 +75,33 @@ describe("web_search freshness normalization", () => {
|
|||||||
expect(normalizeFreshness("2024-03-10to2024-03-01")).toBeUndefined();
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import {
|
|||||||
writeCache,
|
writeCache,
|
||||||
} from "./web-shared.js";
|
} 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 DEFAULT_SEARCH_COUNT = 5;
|
||||||
const MAX_SEARCH_COUNT = 10;
|
const MAX_SEARCH_COUNT = 10;
|
||||||
|
|
||||||
@ -28,6 +28,9 @@ const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro";
|
|||||||
const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
|
const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
|
||||||
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
|
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<string, CacheEntry<Record<string, unknown>>>();
|
const SEARCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
|
||||||
const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]);
|
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 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 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 = {
|
type PerplexitySearchResponse = {
|
||||||
choices?: Array<{
|
choices?: Array<{
|
||||||
message?: {
|
message?: {
|
||||||
@ -131,6 +150,14 @@ function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) {
|
|||||||
docs: "https://docs.molt.bot/tools/web",
|
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 {
|
return {
|
||||||
error: "missing_brave_api_key",
|
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.`,
|
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()
|
? search.provider.trim().toLowerCase()
|
||||||
: "";
|
: "";
|
||||||
if (raw === "perplexity") return "perplexity";
|
if (raw === "perplexity") return "perplexity";
|
||||||
|
if (raw === "grok") return "grok";
|
||||||
if (raw === "brave") return "brave";
|
if (raw === "brave") return "brave";
|
||||||
return "brave";
|
return "brave";
|
||||||
}
|
}
|
||||||
@ -221,6 +249,30 @@ function resolvePerplexityModel(perplexity?: PerplexityConfig): string {
|
|||||||
return fromConfig || DEFAULT_PERPLEXITY_MODEL;
|
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 {
|
function resolveSearchCount(value: unknown, fallback: number): number {
|
||||||
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||||
const clamped = Math.max(1, Math.min(MAX_SEARCH_COUNT, Math.floor(parsed)));
|
const clamped = Math.max(1, Math.min(MAX_SEARCH_COUNT, Math.floor(parsed)));
|
||||||
@ -306,6 +358,50 @@ async function runPerplexitySearch(params: {
|
|||||||
return { content, citations };
|
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<string, unknown> = {
|
||||||
|
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: {
|
async function runWebSearch(params: {
|
||||||
query: string;
|
query: string;
|
||||||
count: number;
|
count: number;
|
||||||
@ -319,6 +415,8 @@ async function runWebSearch(params: {
|
|||||||
freshness?: string;
|
freshness?: string;
|
||||||
perplexityBaseUrl?: string;
|
perplexityBaseUrl?: string;
|
||||||
perplexityModel?: string;
|
perplexityModel?: string;
|
||||||
|
grokModel?: string;
|
||||||
|
grokInlineCitations?: boolean;
|
||||||
}): Promise<Record<string, unknown>> {
|
}): Promise<Record<string, unknown>> {
|
||||||
const cacheKey = normalizeCacheKey(
|
const cacheKey = normalizeCacheKey(
|
||||||
params.provider === "brave"
|
params.provider === "brave"
|
||||||
@ -351,6 +449,27 @@ async function runWebSearch(params: {
|
|||||||
return payload;
|
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") {
|
if (params.provider !== "brave") {
|
||||||
throw new Error("Unsupported web search provider.");
|
throw new Error("Unsupported web search provider.");
|
||||||
}
|
}
|
||||||
@ -415,11 +534,14 @@ export function createWebSearchTool(options?: {
|
|||||||
|
|
||||||
const provider = resolveSearchProvider(search);
|
const provider = resolveSearchProvider(search);
|
||||||
const perplexityConfig = resolvePerplexityConfig(search);
|
const perplexityConfig = resolvePerplexityConfig(search);
|
||||||
|
const grokConfig = resolveGrokConfig(search);
|
||||||
|
|
||||||
const description =
|
const description =
|
||||||
provider === "perplexity"
|
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 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 {
|
return {
|
||||||
label: "Web Search",
|
label: "Web Search",
|
||||||
@ -430,7 +552,11 @@ export function createWebSearchTool(options?: {
|
|||||||
const perplexityAuth =
|
const perplexityAuth =
|
||||||
provider === "perplexity" ? resolvePerplexityApiKey(perplexityConfig) : undefined;
|
provider === "perplexity" ? resolvePerplexityApiKey(perplexityConfig) : undefined;
|
||||||
const apiKey =
|
const apiKey =
|
||||||
provider === "perplexity" ? perplexityAuth?.apiKey : resolveSearchApiKey(search);
|
provider === "perplexity"
|
||||||
|
? perplexityAuth?.apiKey
|
||||||
|
: provider === "grok"
|
||||||
|
? resolveGrokApiKey(grokConfig)
|
||||||
|
: resolveSearchApiKey(search);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return jsonResult(missingSearchKeyPayload(provider));
|
return jsonResult(missingSearchKeyPayload(provider));
|
||||||
@ -476,6 +602,8 @@ export function createWebSearchTool(options?: {
|
|||||||
perplexityAuth?.apiKey,
|
perplexityAuth?.apiKey,
|
||||||
),
|
),
|
||||||
perplexityModel: resolvePerplexityModel(perplexityConfig),
|
perplexityModel: resolvePerplexityModel(perplexityConfig),
|
||||||
|
grokModel: resolveGrokModel(grokConfig),
|
||||||
|
grokInlineCitations: resolveGrokInlineCitations(grokConfig),
|
||||||
});
|
});
|
||||||
return jsonResult(result);
|
return jsonResult(result);
|
||||||
},
|
},
|
||||||
@ -486,4 +614,7 @@ export const __testing = {
|
|||||||
inferPerplexityBaseUrlFromApiKey,
|
inferPerplexityBaseUrlFromApiKey,
|
||||||
resolvePerplexityBaseUrl,
|
resolvePerplexityBaseUrl,
|
||||||
normalizeFreshness,
|
normalizeFreshness,
|
||||||
|
resolveGrokApiKey,
|
||||||
|
resolveGrokModel,
|
||||||
|
resolveGrokInlineCitations,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -336,8 +336,8 @@ export type ToolsConfig = {
|
|||||||
search?: {
|
search?: {
|
||||||
/** Enable web search tool (default: true when API key is present). */
|
/** Enable web search tool (default: true when API key is present). */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Search provider ("brave" or "perplexity"). */
|
/** Search provider ("brave", "perplexity", or "grok"). */
|
||||||
provider?: "brave" | "perplexity";
|
provider?: "brave" | "perplexity" | "grok";
|
||||||
/** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */
|
/** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
/** Default search results count (1-10). */
|
/** Default search results count (1-10). */
|
||||||
@ -355,6 +355,15 @@ export type ToolsConfig = {
|
|||||||
/** Model to use (defaults to "perplexity/sonar-pro"). */
|
/** Model to use (defaults to "perplexity/sonar-pro"). */
|
||||||
model?: string;
|
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?: {
|
fetch?: {
|
||||||
/** Enable web fetch tool (default: true). */
|
/** Enable web fetch tool (default: true). */
|
||||||
|
|||||||
@ -165,7 +165,7 @@ export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) =>
|
|||||||
export const ToolsWebSearchSchema = z
|
export const ToolsWebSearchSchema = z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
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(),
|
apiKey: z.string().optional(),
|
||||||
maxResults: z.number().int().positive().optional(),
|
maxResults: z.number().int().positive().optional(),
|
||||||
timeoutSeconds: z.number().int().positive().optional(),
|
timeoutSeconds: z.number().int().positive().optional(),
|
||||||
@ -178,6 +178,14 @@ export const ToolsWebSearchSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
grok: z
|
||||||
|
.object({
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
inlineCitations: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional();
|
.optional();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user