diff --git a/.changeset/ninety-paws-cough.md b/.changeset/ninety-paws-cough.md new file mode 100644 index 000000000000..5df928a33f31 --- /dev/null +++ b/.changeset/ninety-paws-cough.md @@ -0,0 +1,5 @@ +--- +"@langchain/xai": minor +--- + +Adds native Live Search support to xAI provider diff --git a/libs/providers/langchain-xai/README.md b/libs/providers/langchain-xai/README.md index ef4a518403ef..6e7811252297 100644 --- a/libs/providers/langchain-xai/README.md +++ b/libs/providers/langchain-xai/README.md @@ -31,6 +31,156 @@ const message = new HumanMessage("What color is the sky?"); const res = await model.invoke([message]); ``` +## Server Tool Calling (Live Search) + +xAI supports server-side tools that are executed by the API rather than requiring client-side execution. The `live_search` tool enables the model to search the web for real-time information. + +### Using the built-in live_search tool + +```typescript +import { ChatXAI, tools } from "@langchain/xai"; + +const model = new ChatXAI({ + model: "grok-2-1212", +}); + +// Create the built-in live_search tool with optional parameters +const searchTool = tools.xaiLiveSearch({ + maxSearchResults: 5, + returnCitations: true, +}); + +// Bind the live_search tool to the model +const modelWithSearch = model.bindTools([searchTool]); + +// The model will search the web for real-time information +const result = await modelWithSearch.invoke( + "What happened in tech news today?" +); +console.log(result.content); +``` + +### Using searchParameters for more control + +```typescript +import { ChatXAI } from "@langchain/xai"; + +const model = new ChatXAI({ + model: "grok-2-1212", + searchParameters: { + mode: "auto", // "auto" | "on" | "off" + max_search_results: 5, + from_date: "2024-01-01", // ISO date string + return_citations: true, + }, +}); + +const result = await model.invoke("What are the latest AI developments?"); +``` + +### Override search parameters per request + +```typescript +const result = await model.invoke("Find recent news about SpaceX", { + searchParameters: { + mode: "on", + max_search_results: 10, + sources: [ + { + type: "web", + allowed_websites: ["spacex.com", "nasa.gov"], + }, + ], + }, +}); +``` + +### Configuring data sources with `sources` + +You can configure which data sources Live Search should use via the `sources` field +in `searchParameters`. Each entry corresponds to one of the sources described in the +official xAI Live Search docs (`web`, `news`, `x`, `rss`). + +```typescript +const result = await model.invoke( + "What are the latest updates from xAI and related news?", + { + searchParameters: { + mode: "on", + sources: [ + { + type: "web", + // Only search on these websites + allowed_websites: ["x.ai"], + }, + { + type: "news", + // Exclude specific news websites + excluded_websites: ["bbc.co.uk"], + }, + { + type: "x", + // Focus on specific X handles + included_x_handles: ["xai"], + }, + ], + }, + } +); +``` + +You can also use RSS feeds as a data source: + +```typescript +const result = await model.invoke("Summarize the latest posts from this feed", { + searchParameters: { + mode: "on", + sources: [ + { + type: "rss", + links: ["https://example.com/feed.rss"], + }, + ], + }, +}); +``` + +> Notes: +> +> - The `xaiLiveSearch` tool options use **camelCase** field names in TypeScript +> (for example `maxSearchResults`, `fromDate`, `returnCitations`, +> `allowedWebsites`, `excludedWebsites`, `includedXHandles`). These are +> automatically mapped to the underlying JSON API's `search_parameters` +> object, which uses `snake_case` field names as documented in the official +> xAI Live Search docs. + +### Combining live_search with custom tools + +```typescript +import { ChatXAI, tools } from "@langchain/xai"; + +const model = new ChatXAI({ model: "grok-2-1212" }); + +const modelWithTools = model.bindTools([ + tools.xaiLiveSearch(), // Built-in server tool + { + // Custom function tool + type: "function", + function: { + name: "get_stock_price", + description: "Get the current stock price", + parameters: { + type: "object", + properties: { + symbol: { type: "string" }, + }, + required: ["symbol"], + }, + }, + }, +]); +``` + ## Development To develop the `@langchain/xai` package, you'll need to follow these instructions: diff --git a/libs/providers/langchain-xai/src/chat_models.ts b/libs/providers/langchain-xai/src/chat_models.ts index 27e201d3b879..d82ece69da5c 100644 --- a/libs/providers/langchain-xai/src/chat_models.ts +++ b/libs/providers/langchain-xai/src/chat_models.ts @@ -8,26 +8,167 @@ import { LangSmithParams, type BaseChatModelParams, } from "@langchain/core/language_models/chat_models"; +import { + isLangChainTool, + convertToOpenAITool, +} from "@langchain/core/utils/function_calling"; import { ModelProfile } from "@langchain/core/language_models/profile"; import { Serialized } from "@langchain/core/load/serializable"; -import { AIMessageChunk, BaseMessage } from "@langchain/core/messages"; +import { + AIMessageChunk, + BaseMessage, + type UsageMetadata, +} from "@langchain/core/messages"; import { Runnable } from "@langchain/core/runnables"; import { getEnvironmentVariable } from "@langchain/core/utils/env"; import { InteropZodType } from "@langchain/core/utils/types"; import { type OpenAICoreRequestOptions, type OpenAIClient, - OpenAIToolChoice, ChatOpenAICompletions, } from "@langchain/openai"; +import { + buildSearchParametersPayload, + filterXAIBuiltInTools, + mergeSearchParams, + type XAISearchParameters, + type XAISearchParametersPayload, +} from "./live_search.js"; import PROFILES from "./profiles.js"; +import { + XAI_LIVE_SEARCH_TOOL_TYPE, + XAILiveSearchTool, +} from "./tools/live_search.js"; -type ChatXAIToolType = BindToolsInput | OpenAIClient.ChatCompletionTool; +export type OpenAIToolChoice = + | OpenAIClient.ChatCompletionToolChoiceOption + | "any" + | string; + +/** + * Union type for all xAI built-in server-side tools. + */ +export type XAIBuiltInTool = XAILiveSearchTool; + +/** + * Set of all supported xAI built-in server-side tool types. + * This allows us to easily extend support for future built-in tools + * without changing the core detection logic. + */ +const XAI_BUILT_IN_TOOL_TYPES = new Set([ + XAI_LIVE_SEARCH_TOOL_TYPE, +]); + +/** + * Tool type that includes both standard tools and xAI built-in tools. + */ +type ChatXAIToolType = + | BindToolsInput + | OpenAIClient.ChatCompletionTool + | XAIBuiltInTool; + +/** + * xAI-specific invocation parameters that extend the OpenAI completion params + * with xAI's search_parameters field. + */ +export type ChatXAICompletionsInvocationParams = Omit< + OpenAIClient.Chat.Completions.ChatCompletionCreateParams, + "messages" +> & { + /** + * Search parameters for xAI's Live Search API. + * When present, enables the model to search the web for real-time information. + */ + search_parameters?: XAISearchParametersPayload; +}; + +/** + * xAI-specific additional kwargs that may be present on AI messages. + * Includes xAI-specific fields like reasoning_content. + */ +export interface XAIAdditionalKwargs { + /** + * The reasoning content from xAI models that support chain-of-thought reasoning. + * This contains the model's internal reasoning process. + */ + reasoning_content?: string; + /** + * Tool calls made by the model. + */ + tool_calls?: OpenAIClient.ChatCompletionMessageToolCall[]; + /** + * Additional properties that may be present. + */ + [key: string]: unknown; +} + +/** + * xAI-specific response metadata that may include usage information. + */ +export interface XAIResponseMetadata { + /** + * Token usage information. + */ + usage?: UsageMetadata; + /** + * Additional metadata properties. + */ + [key: string]: unknown; +} + +/** + * Checks if a tool is an xAI built-in tool (like live_search). + * Built-in tools are executed server-side by the xAI API. + * + * @param tool - The tool to check + * @returns true if the tool is an xAI built-in tool + */ +export function isXAIBuiltInTool( + tool: ChatXAIToolType +): tool is XAIBuiltInTool { + return ( + typeof tool === "object" && + tool !== null && + "type" in tool && + typeof (tool as { type?: unknown }).type === "string" && + XAI_BUILT_IN_TOOL_TYPES.has((tool as { type: string }).type) + ); +} export interface ChatXAICallOptions extends BaseChatModelCallOptions { headers?: Record; + /** + * A list of tools the model may call. + * Can include standard function tools and xAI built-in tools like `{ type: "live_search" }`. + * + * @example + * ```typescript + * // Using built-in live_search tool + * const llm = new ChatXAI().bindTools([{ type: "live_search" }]); + * const result = await llm.invoke("What happened in tech news today?"); + * ``` + */ tools?: ChatXAIToolType[]; tool_choice?: OpenAIToolChoice | string | "auto" | "any"; + /** + * Search parameters for xAI's Live Search API. + * Enables the model to search the web for real-time information. + * + * @note This is an alternative to using `tools: [{ type: "live_search" }]`. + * The Live Search API parameters approach may be deprecated in favor of + * the tool-based approach. + * + * @example + * ```typescript + * const result = await llm.invoke("What's the latest news?", { + * searchParameters: { + * mode: "auto", + * max_search_results: 5, + * } + * }); + * ``` + */ + searchParameters?: XAISearchParameters; } export interface ChatXAIInput extends BaseChatModelParams { @@ -66,6 +207,23 @@ export interface ChatXAIInput extends BaseChatModelParams { * This limits ensures computational efficiency and resource management. */ maxTokens?: number; + /** + * Default search parameters for xAI's Live Search API. + * When set, these parameters will be applied to all requests unless + * overridden in the call options. + * + * @example + * ```typescript + * const llm = new ChatXAI({ + * model: "grok-beta", + * searchParameters: { + * mode: "auto", + * max_search_results: 5, + * } + * }); + * ``` + */ + searchParameters?: XAISearchParameters; } /** @@ -393,6 +551,57 @@ export interface ChatXAIInput extends BaseChatModelParams { * * *
+ * + *
+ * Server Tool Calling (Live Search) + * + * xAI supports server-side tools that are executed by the API rather than + * requiring client-side execution. The `live_search` tool enables the model + * to search the web for real-time information. + * + * ```typescript + * // Method 1: Using the built-in live_search tool + * const llm = new ChatXAI({ + * model: "grok-beta", + * temperature: 0, + * }); + * + * const llmWithSearch = llm.bindTools([{ type: "live_search" }]); + * const result = await llmWithSearch.invoke("What happened in tech news today?"); + * console.log(result.content); + * // The model will search the web and include real-time information in its response + * ``` + * + * ```typescript + * // Method 2: Using searchParameters for more control + * const llm = new ChatXAI({ + * model: "grok-beta", + * searchParameters: { + * mode: "auto", // "auto" | "on" | "off" + * max_search_results: 5, + * from_date: "2024-01-01", // ISO date string + * return_citations: true, + * } + * }); + * + * const result = await llm.invoke("What are the latest AI developments?"); + * ``` + * + * ```typescript + * // Method 3: Override search parameters per request + * const result = await llm.invoke("Find recent news about SpaceX", { + * searchParameters: { + * mode: "on", + * max_search_results: 10, + * sources: [ + * { type: "web", allowed_websites: ["spacex.com", "nasa.gov"] }, + * ], + * } + * }); + * ``` + *
+ * + *
*/ export class ChatXAI extends ChatOpenAICompletions { static lc_name() { @@ -413,6 +622,11 @@ export class ChatXAI extends ChatOpenAICompletions { lc_namespace = ["langchain", "chat_models", "xai"]; + /** + * Default search parameters for the Live Search API. + */ + searchParameters?: XAISearchParameters; + constructor(fields?: Partial) { const apiKey = fields?.apiKey || getEnvironmentVariable("XAI_API_KEY"); if (!apiKey) { @@ -429,6 +643,8 @@ export class ChatXAI extends ChatOpenAICompletions { baseURL: "https://api.x.ai/v1", }, }); + + this.searchParameters = fields?.searchParameters; } toJSON(): Serialized { @@ -452,6 +668,93 @@ export class ChatXAI extends ChatOpenAICompletions { return params; } + /** + * Get the effective search parameters, merging defaults with call options. + * @param options Call options that may contain search parameters + * @returns Merged search parameters or undefined if none are configured + */ + protected _getEffectiveSearchParameters( + options?: this["ParsedCallOptions"] + ): XAISearchParameters | undefined { + return mergeSearchParams(this.searchParameters, options?.searchParameters); + } + + /** + * Check if any built-in tools (like live_search) are in the tools list. + * @param tools List of tools to check + * @returns true if any built-in tools are present + */ + protected _hasBuiltInTools(tools?: ChatXAIToolType[]): boolean { + return tools?.some(isXAIBuiltInTool) ?? false; + } + + /** + * Formats tools to xAI/OpenAI format, preserving provider-specific definitions. + * + * @param tools The tools to format + * @returns The formatted tools + */ + formatStructuredToolToXAI( + tools: ChatXAIToolType[] + ): (OpenAIClient.ChatCompletionTool | XAIBuiltInTool)[] | undefined { + if (!tools || !tools.length) { + return undefined; + } + return tools.map((tool) => { + // 1. Check for provider definition first (from xaiLiveSearch factory) + if (isLangChainTool(tool) && tool.extras?.providerToolDefinition) { + return tool.extras.providerToolDefinition as XAIBuiltInTool; + } + // 2. Check for built-in tools (legacy { type: "live_search" }) + if (isXAIBuiltInTool(tool)) { + return tool; + } + // 3. Convert standard tools to OpenAI format + return convertToOpenAITool(tool) as OpenAIClient.ChatCompletionTool; + }); + } + + override bindTools( + tools: ChatXAIToolType[], + kwargs?: Partial + ): Runnable { + return this.withConfig({ + tools: this.formatStructuredToolToXAI(tools), + ...kwargs, + } as Partial); + } + + /** @internal */ + override invocationParams( + options?: this["ParsedCallOptions"], + extra?: { streaming?: boolean } + ): ChatXAICompletionsInvocationParams { + const baseParams = super.invocationParams(options, extra); + + // Cast to xAI-specific params type + const params: ChatXAICompletionsInvocationParams = { ...baseParams }; + + // Check if live_search tool is being used + // We also need to extract params from the tool definition if present + const liveSearchTool = options?.tools?.find(isXAIBuiltInTool) as + | XAILiveSearchTool + | undefined; + + const mergedSearchParams = mergeSearchParams( + this.searchParameters, + options?.searchParameters, + liveSearchTool + ); + + // Add search_parameters if needed + if (mergedSearchParams) { + params.search_parameters = + buildSearchParametersPayload(mergedSearchParams); + } + + return params; + } + async completionWithRetry( request: OpenAIClient.Chat.ChatCompletionCreateParamsStreaming, options?: OpenAICoreRequestOptions @@ -492,9 +795,18 @@ export class ChatXAI extends ChatOpenAICompletions { return msg; }); + let filteredTools: OpenAIClient.ChatCompletionTool[] | undefined; + if (request.tools) { + filteredTools = filterXAIBuiltInTools({ + tools: request.tools, + excludedTypes: [XAI_LIVE_SEARCH_TOOL_TYPE], + }) as OpenAIClient.ChatCompletionTool[] | undefined; + } + const newRequest = { ...request, messages: newRequestMessages, + tools: filteredTools, }; if (newRequest.stream === true) { @@ -515,34 +827,43 @@ export class ChatXAI extends ChatOpenAICompletions { | "developer" | "assistant" | "tool" - ) { - const messageChunk: AIMessageChunk = - super._convertCompletionsDeltaToBaseMessageChunk( - delta, - rawResponse, - defaultRole - ); + ): AIMessageChunk { + const messageChunk = super._convertCompletionsDeltaToBaseMessageChunk( + delta, + rawResponse, + defaultRole + ) as AIMessageChunk; + + // Cast to xAI-specific types for proper typing + const responseMetadata = + messageChunk.response_metadata as XAIResponseMetadata; + // Make concatenating chunks work without merge warning if (!rawResponse.choices[0]?.finish_reason) { - delete messageChunk.response_metadata.usage; + delete responseMetadata.usage; delete messageChunk.usage_metadata; } else { - messageChunk.usage_metadata = messageChunk.response_metadata.usage; + messageChunk.usage_metadata = responseMetadata.usage; } return messageChunk; } protected override _convertCompletionsMessageToBaseMessage( - message: OpenAIClient.ChatCompletionMessage, + message: OpenAIClient.ChatCompletionMessage & { + reasoning_content?: string; + }, rawResponse: OpenAIClient.ChatCompletion - ) { + ): AIMessageChunk { const langChainMessage = super._convertCompletionsMessageToBaseMessage( message, rawResponse - ); - langChainMessage.additional_kwargs.reasoning_content = - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (message as any).reasoning_content; + ) as AIMessageChunk; + + // Cast additional_kwargs to xAI-specific type and add reasoning_content + const additionalKwargs = + langChainMessage.additional_kwargs as XAIAdditionalKwargs; + additionalKwargs.reasoning_content = message.reasoning_content; + return langChainMessage; } diff --git a/libs/providers/langchain-xai/src/index.ts b/libs/providers/langchain-xai/src/index.ts index 38c7cea7f478..7fc19f5d2bdc 100644 --- a/libs/providers/langchain-xai/src/index.ts +++ b/libs/providers/langchain-xai/src/index.ts @@ -1 +1,2 @@ export * from "./chat_models.js"; +export { tools } from "./tools/index.js"; diff --git a/libs/providers/langchain-xai/src/live_search.ts b/libs/providers/langchain-xai/src/live_search.ts new file mode 100644 index 000000000000..19fab0c61d5e --- /dev/null +++ b/libs/providers/langchain-xai/src/live_search.ts @@ -0,0 +1,242 @@ +/** + * Web search source configuration for xAI Live Search. + * Corresponds to a `{"type": "web", ...}` entry in `search_parameters.sources`. + */ +export interface XAIWebSource { + type: "web"; + /** + * Optional ISO alpha-2 country code used to bias results + * towards a specific country/region. + */ + country?: string; + /** + * Websites that should be excluded from the search results. + * Maximum of 5 entries. + */ + excluded_websites?: string[]; + /** + * Websites that should be exclusively included in the search results. + * Maximum of 5 entries. + */ + allowed_websites?: string[]; + /** + * Whether to enable safe search filtering for this source. + */ + safe_search?: boolean; +} + +/** + * News search source configuration for xAI Live Search. + * Corresponds to a `{"type": "news", ...}` entry in `search_parameters.sources`. + */ +export interface XAINewsSource { + type: "news"; + /** + * Optional ISO alpha-2 country code used to bias results + * towards a specific country/region. + */ + country?: string; + /** + * Websites that should be excluded from the search results. + * Maximum of 5 entries. + */ + excluded_websites?: string[]; + /** + * Whether to enable safe search filtering for this source. + */ + safe_search?: boolean; +} + +/** + * X (formerly Twitter) search source configuration for xAI Live Search. + * Corresponds to a `{"type": "x", ...}` entry in `search_parameters.sources`. + */ +export interface XAIXSource { + type: "x"; + /** + * X handles that should be explicitly included in the search. + * Maximum of 10 entries. + */ + included_x_handles?: string[]; + /** + * X handles that should be excluded from the search. + * Maximum of 10 entries. + */ + excluded_x_handles?: string[]; + /** + * Minimum number of favorites a post must have to be included. + */ + post_favorite_count?: number; + /** + * Minimum number of views a post must have to be included. + */ + post_view_count?: number; +} + +/** + * RSS feed search source configuration for xAI Live Search. + * Corresponds to a `{"type": "rss", ...}` entry in `search_parameters.sources`. + */ +export interface XAIRssSource { + type: "rss"; + /** + * Links to RSS feeds to be used as a data source. + * The API currently expects a single URL. + */ + links: string[]; +} + +export type XAISearchSource = + | XAIWebSource + | XAINewsSource + | XAIXSource + | XAIRssSource; + +/** + * Search parameters for xAI's Live Search API. + * Controls how the model searches for and retrieves real-time information. + * + * @note The Live Search API is being deprecated by xAI in favor of + * the agentic tool calling approach. Consider using `tools: [{ type: "live_search" }]` + * for future compatibility. + */ +export interface XAISearchParameters { + /** + * Controls when the model should perform a search. + * - "auto": Let the model decide when to search (default) + * - "on": Always search for every request + * - "off": Never search + */ + mode?: "auto" | "on" | "off"; + /** + * Maximum number of search results to return. + * @default 20 + */ + max_search_results?: number; + /** + * Filter search results to only include content from after this date. + * Format: ISO 8601 date string (e.g., "2024-01-01") + */ + from_date?: string; + /** + * Filter search results to only include content from before this date. + * Format: ISO 8601 date string (e.g., "2024-12-31") + */ + to_date?: string; + /** + * Whether to return citations/sources for the search results. + * @default true + */ + return_citations?: boolean; + /** + * Specific web/news/X/RSS sources that can be used for the search. + * Each entry corresponds to a `{"type": "...", ...}` object in + * `search_parameters.sources` as documented in the xAI Live Search docs. + * + * If omitted, xAI will default to enabling `web`, `news` and `x` sources. + */ + sources?: XAISearchSource[]; +} + +/** + * Concrete payload shape sent as `search_parameters` to the xAI API. + */ +export interface XAISearchParametersPayload { + mode: "auto" | "on" | "off"; + max_search_results?: number; + from_date?: string; + to_date?: string; + return_citations?: boolean; + sources?: XAISearchSource[]; +} + +/** + * Merge search parameters from instance defaults, tool definition + * and per-call overrides. + * + * Precedence (lowest → highest): + * 1. tool-level configuration (e.g. from xaiLiveSearch) + * 2. instance-level defaults + * 3. per-call overrides passed via `searchParameters` + */ +export function mergeSearchParams( + instanceParams?: XAISearchParameters, + callParams?: XAISearchParameters, + toolParams?: XAISearchParameters +): XAISearchParameters | undefined { + if (!instanceParams && !callParams && !toolParams) { + return undefined; + } + + return { + ...(toolParams ?? {}), + ...(instanceParams ?? {}), + ...(callParams ?? {}), + }; +} + +/** + * Build the `search_parameters` payload to send to the xAI API + * from high-level `XAISearchParameters`. + */ +export function buildSearchParametersPayload( + params?: XAISearchParameters +): XAISearchParametersPayload | undefined { + if (!params) { + return undefined; + } + + const payload: XAISearchParametersPayload = { + mode: params.mode ?? "auto", + }; + + if (params.max_search_results !== undefined) { + payload.max_search_results = params.max_search_results; + } + if (params.from_date !== undefined) { + payload.from_date = params.from_date; + } + if (params.to_date !== undefined) { + payload.to_date = params.to_date; + } + if (params.return_citations !== undefined) { + payload.return_citations = params.return_citations; + } + if (params.sources && params.sources.length > 0) { + payload.sources = params.sources; + } + + return payload; +} + +/** + * Filter out xAI built-in tools (like `live_search`) from a tools array. + * Used before sending the request to the xAI API, since built-in tools + * are controlled via `search_parameters` instead. + */ +export function filterXAIBuiltInTools< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + T extends { [key: string]: any } +>(payload?: { tools?: T[]; excludedTypes?: string[] }): T[] | undefined { + if (!payload?.tools) { + return undefined; + } + + const filtered = payload.tools.filter((tool) => { + if (tool == null || typeof tool !== "object") { + return true; + } + + if (!("type" in tool)) { + return true; + } + + if (!payload?.excludedTypes?.length) { + return true; + } + + return !payload.excludedTypes.includes(tool.type); + }); + + return filtered.length > 0 ? filtered : undefined; +} diff --git a/libs/providers/langchain-xai/src/tests/chat_models.int.test.ts b/libs/providers/langchain-xai/src/tests/chat_models.int.test.ts index 699d34795248..96c384f8839a 100644 --- a/libs/providers/langchain-xai/src/tests/chat_models.int.test.ts +++ b/libs/providers/langchain-xai/src/tests/chat_models.int.test.ts @@ -1,4 +1,4 @@ -import { test, expect } from "vitest"; +import { test, expect, describe } from "vitest"; import { z } from "zod/v3"; import { @@ -11,6 +11,11 @@ import { tool } from "@langchain/core/tools"; import { concat } from "@langchain/core/utils/stream"; import { ChatXAI } from "../chat_models.js"; +import { + XAI_LIVE_SEARCH_TOOL_NAME, + XAI_LIVE_SEARCH_TOOL_TYPE, + type XAILiveSearchTool, +} from "../tools/live_search.js"; test("invoke", async () => { const chat = new ChatXAI({ @@ -228,3 +233,160 @@ test("xAI can stream tool calls", async () => { expect(finalMessage.tool_calls?.[0].args).toHaveProperty("location"); expect(finalMessage.tool_calls?.[0].id).toBeDefined(); }); + +// Server Tool Calling (Live Search) Integration Tests +describe("Server Tool Calling (Live Search)", () => { + test("invoke with live_search built-in tool", async () => { + const chat = new ChatXAI({ + maxRetries: 0, + model: "grok-2-1212", + }); + + const liveSearchTool: XAILiveSearchTool = { + name: XAI_LIVE_SEARCH_TOOL_NAME, + type: XAI_LIVE_SEARCH_TOOL_TYPE, + }; + const chatWithSearch = chat.bindTools([liveSearchTool]); + + // Ask about recent events to trigger search + const message = new HumanMessage( + "What are the latest developments in AI as of today?" + ); + const res = await chatWithSearch.invoke([message]); + + // The response should contain information (live search results are incorporated) + expect(res.content).toBeDefined(); + expect((res.content as string).length).toBeGreaterThan(50); + }); + + test("invoke with searchParameters in constructor", async () => { + const chat = new ChatXAI({ + maxRetries: 0, + model: "grok-2-1212", + searchParameters: { + mode: "auto", + max_search_results: 5, + }, + }); + + const message = new HumanMessage("What happened in the news today?"); + const res = await chat.invoke([message]); + + expect(res.content).toBeDefined(); + expect((res.content as string).length).toBeGreaterThan(50); + }); + + test("invoke with searchParameters in call options", async () => { + const chat = new ChatXAI({ + maxRetries: 0, + model: "grok-2-1212", + }); + + const message = new HumanMessage( + "What is the current status of SpaceX launches?" + ); + const res = await chat.invoke([message], { + searchParameters: { + mode: "on", + max_search_results: 3, + }, + }); + + expect(res.content).toBeDefined(); + expect((res.content as string).length).toBeGreaterThan(50); + }); + + test("invoke with searchParameters sources in call options", async () => { + const chat = new ChatXAI({ + maxRetries: 0, + model: "grok-2-1212", + }); + + const message = new HumanMessage( + "What are the latest updates from xAI and related news?" + ); + const res = await chat.invoke([message], { + searchParameters: { + mode: "on", + sources: [ + { + type: "web", + allowed_websites: ["x.ai"], + }, + { + type: "news", + excluded_websites: ["bbc.co.uk"], + }, + ], + }, + }); + + expect(res.content).toBeDefined(); + expect((res.content as string).length).toBeGreaterThan(50); + }); + + test("stream with live_search tool", async () => { + const chat = new ChatXAI({ + maxRetries: 0, + model: "grok-2-1212", + }); + + const liveSearchTool: XAILiveSearchTool = { + name: XAI_LIVE_SEARCH_TOOL_NAME, + type: XAI_LIVE_SEARCH_TOOL_TYPE, + }; + const chatWithSearch = chat.bindTools([liveSearchTool]); + + const message = new HumanMessage("What are the top tech news stories?"); + const stream = await chatWithSearch.stream([message]); + + let finalMessage: AIMessageChunk | undefined; + for await (const chunk of stream) { + finalMessage = !finalMessage ? chunk : concat(finalMessage, chunk); + } + + expect(finalMessage).toBeDefined(); + expect(finalMessage?.content).toBeDefined(); + }); + + test("combine live_search with function tools", async () => { + const chat = new ChatXAI({ + maxRetries: 0, + model: "grok-2-1212", + }); + + const liveSearchTool: XAILiveSearchTool = { + type: XAI_LIVE_SEARCH_TOOL_TYPE, + name: XAI_LIVE_SEARCH_TOOL_NAME, + }; + const customTool = { + type: "function" as const, + function: { + name: "get_stock_price", + description: "Get the current stock price for a given symbol", + parameters: { + type: "object", + properties: { + symbol: { + type: "string", + description: "The stock symbol, e.g. AAPL", + }, + }, + required: ["symbol"], + }, + }, + }; + + const chatWithTools = chat.bindTools([liveSearchTool, customTool]); + + // Ask something that might trigger either tool + const message = new HumanMessage( + "What is Apple's current stock price and what are the latest news about the company?" + ); + const res = await chatWithTools.invoke([message]); + + expect(res.content).toBeDefined(); + // The response might have tool calls for the custom tool + // or might answer directly with live search data + }); +}); diff --git a/libs/providers/langchain-xai/src/tests/chat_models.test.ts b/libs/providers/langchain-xai/src/tests/chat_models.test.ts index 2acfd0d7b3bb..d03556fdaa24 100644 --- a/libs/providers/langchain-xai/src/tests/chat_models.test.ts +++ b/libs/providers/langchain-xai/src/tests/chat_models.test.ts @@ -1,5 +1,15 @@ -import { test, expect, beforeEach } from "vitest"; -import { ChatXAI } from "../chat_models.js"; +import { test, expect, beforeEach, describe } from "vitest"; +import { + ChatXAI, + isXAIBuiltInTool, + type ChatXAICompletionsInvocationParams, +} from "../chat_models.js"; +import { XAISearchParameters } from "../live_search.js"; +import { + XAI_LIVE_SEARCH_TOOL_NAME, + XAI_LIVE_SEARCH_TOOL_TYPE, + XAILiveSearchTool, +} from "../tools/live_search.js"; beforeEach(() => { process.env.XAI_API_KEY = "foo"; @@ -22,3 +32,268 @@ test("Serialization with no params", () => { `{"lc":1,"type":"constructor","id":["langchain","chat_models","xai","ChatXAI"],"kwargs":{"model":"grok-beta"}}` ); }); + +describe("Server Tool Calling", () => { + describe("isXAIBuiltInTool", () => { + test("should identify live_search as a built-in tool", () => { + const liveSearchTool: XAILiveSearchTool = { + name: XAI_LIVE_SEARCH_TOOL_NAME, + type: XAI_LIVE_SEARCH_TOOL_TYPE, + }; + expect(isXAIBuiltInTool(liveSearchTool)).toBe(true); + }); + + test("should not identify function tools as built-in", () => { + const functionTool = { + type: "function", + function: { + name: "get_weather", + description: "Get the weather", + parameters: { type: "object", properties: {} }, + }, + }; + expect(isXAIBuiltInTool(functionTool)).toBe(false); + }); + + test("should not identify invalid objects as built-in", () => { + expect(isXAIBuiltInTool(null as unknown as XAILiveSearchTool)).toBe( + false + ); + expect(isXAIBuiltInTool(undefined as unknown as XAILiveSearchTool)).toBe( + false + ); + expect(isXAIBuiltInTool({} as unknown as XAILiveSearchTool)).toBe(false); + expect( + isXAIBuiltInTool({ type: "other" } as unknown as XAILiveSearchTool) + ).toBe(false); + }); + }); + + describe("ChatXAI with searchParameters", () => { + test("should store searchParameters from constructor", () => { + const searchParams: XAISearchParameters = { + mode: "auto", + max_search_results: 5, + }; + const model = new ChatXAI({ + searchParameters: searchParams, + }); + expect(model.searchParameters).toEqual(searchParams); + }); + + test("should have undefined searchParameters by default", () => { + const model = new ChatXAI(); + expect(model.searchParameters).toBeUndefined(); + }); + + test("should merge search parameters correctly", () => { + const model = new ChatXAI({ + searchParameters: { + mode: "auto", + max_search_results: 5, + }, + }); + + // Access protected method via any cast for testing + // eslint-disable-next-line dot-notation + const effectiveParams = model["_getEffectiveSearchParameters"]({ + searchParameters: { + max_search_results: 10, + from_date: "2024-01-01", + }, + }); + + expect(effectiveParams).toEqual({ + mode: "auto", + max_search_results: 10, + from_date: "2024-01-01", + }); + }); + }); + + describe("invocationParams with server tools", () => { + test("should add search_parameters when live_search tool is bound", () => { + const model = new ChatXAI(); + + const params: ChatXAICompletionsInvocationParams = model.invocationParams( + { + tools: [ + { + type: XAI_LIVE_SEARCH_TOOL_TYPE, + name: XAI_LIVE_SEARCH_TOOL_NAME, + }, + ] satisfies [XAILiveSearchTool], + } as unknown as ChatXAI["ParsedCallOptions"] + ); + + expect(params.search_parameters).toBeDefined(); + expect(params.search_parameters?.mode).toBe("auto"); + }); + + test("should add search_parameters from call options", () => { + const model = new ChatXAI(); + + const params: ChatXAICompletionsInvocationParams = model.invocationParams( + { + searchParameters: { + mode: "on", + max_search_results: 10, + from_date: "2024-01-01", + }, + } as unknown as ChatXAI["ParsedCallOptions"] + ); + + expect(params.search_parameters).toEqual({ + mode: "on", + max_search_results: 10, + from_date: "2024-01-01", + }); + }); + + test("should include sources in search_parameters when provided", () => { + const model = new ChatXAI(); + + const params: ChatXAICompletionsInvocationParams = model.invocationParams( + { + searchParameters: { + mode: "on", + sources: [ + { + type: "web", + allowed_websites: ["x.ai"], + }, + { + type: "news", + excluded_websites: ["bbc.co.uk"], + }, + { + type: "x", + included_x_handles: ["xai"], + }, + { + type: "rss", + links: ["https://example.com/feed.rss"], + }, + ], + }, + } as unknown as ChatXAI["ParsedCallOptions"] + ); + + expect(params.search_parameters).toBeDefined(); + expect(params.search_parameters?.sources).toEqual([ + { + type: "web", + allowed_websites: ["x.ai"], + }, + { + type: "news", + excluded_websites: ["bbc.co.uk"], + }, + { + type: "x", + included_x_handles: ["xai"], + }, + { + type: "rss", + links: ["https://example.com/feed.rss"], + }, + ]); + }); + + test("should omit sources field when none are configured", () => { + const model = new ChatXAI(); + + const params: ChatXAICompletionsInvocationParams = model.invocationParams( + { + searchParameters: { + mode: "auto", + }, + } as unknown as ChatXAI["ParsedCallOptions"] + ); + + expect(params.search_parameters).toEqual({ + mode: "auto", + }); + expect( + Object.prototype.hasOwnProperty.call( + params.search_parameters as NonNullable< + ChatXAICompletionsInvocationParams["search_parameters"] + >, + "sources" + ) + ).toBe(false); + }); + + test("should merge instance and call option search parameters", () => { + const model = new ChatXAI({ + searchParameters: { + mode: "auto", + max_search_results: 5, + return_citations: true, + }, + }); + + const params: ChatXAICompletionsInvocationParams = model.invocationParams( + { + searchParameters: { + max_search_results: 10, + }, + } as unknown as ChatXAI["ParsedCallOptions"] + ); + + expect(params.search_parameters).toEqual({ + mode: "auto", + max_search_results: 10, + return_citations: true, + }); + }); + + test("should not add search_parameters when no search config is present", () => { + const model = new ChatXAI(); + + const params: ChatXAICompletionsInvocationParams = model.invocationParams( + {} as ChatXAI["ParsedCallOptions"] + ); + + expect(params.search_parameters).toBeUndefined(); + }); + }); + + describe("_hasBuiltInTools", () => { + test("should return true when live_search tool is present", () => { + const model = new ChatXAI(); + // eslint-disable-next-line dot-notation + const result = model["_hasBuiltInTools"]([ + { + type: XAI_LIVE_SEARCH_TOOL_TYPE, + name: XAI_LIVE_SEARCH_TOOL_NAME, + } satisfies XAILiveSearchTool, + { + type: "function", + function: { name: "test", parameters: {} }, + }, + ]); + expect(result).toBe(true); + }); + + test("should return false when no built-in tools are present", () => { + const model = new ChatXAI(); + // eslint-disable-next-line dot-notation + const result = model["_hasBuiltInTools"]([ + { + type: "function", + function: { name: "test", parameters: {} }, + }, + ]); + expect(result).toBe(false); + }); + + test("should return false for undefined or empty tools", () => { + const model = new ChatXAI(); + // eslint-disable-next-line dot-notation + expect(model["_hasBuiltInTools"](undefined)).toBe(false); + // eslint-disable-next-line dot-notation + expect(model["_hasBuiltInTools"]([])).toBe(false); + }); + }); +}); diff --git a/libs/providers/langchain-xai/src/tests/live_search.test.ts b/libs/providers/langchain-xai/src/tests/live_search.test.ts new file mode 100644 index 000000000000..bfd398304db3 --- /dev/null +++ b/libs/providers/langchain-xai/src/tests/live_search.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, test } from "vitest"; + +import { + buildSearchParametersPayload, + filterXAIBuiltInTools, + mergeSearchParams, + type XAISearchParameters, + type XAISearchSource, +} from "../live_search.js"; +import { XAI_LIVE_SEARCH_TOOL_TYPE } from "../tools/live_search.js"; + +describe("mergeSearchParams", () => { + test("returns undefined when no params are provided", () => { + expect(mergeSearchParams()).toBeUndefined(); + }); + + test("returns instance params when only instance params are provided", () => { + const instance: XAISearchParameters = { + mode: "auto", + max_search_results: 5, + }; + + const result = mergeSearchParams(instance, undefined, undefined); + + expect(result).toEqual(instance); + }); + + test("call-level params override instance-level params", () => { + const instance: XAISearchParameters = { + mode: "auto", + max_search_results: 5, + return_citations: true, + }; + const call: XAISearchParameters = { + mode: "on", + max_search_results: 10, + }; + + const result = mergeSearchParams(instance, call, undefined); + + expect(result).toEqual({ + mode: "on", + max_search_results: 10, + return_citations: true, + }); + }); + + test("instance params override tool params", () => { + const instance: XAISearchParameters = { + max_search_results: 5, + }; + const tool: XAISearchParameters = { + max_search_results: 10, + from_date: "2024-01-01", + }; + + const result = mergeSearchParams(instance, undefined, tool); + + expect(result).toEqual({ + max_search_results: 5, + from_date: "2024-01-01", + }); + }); + + test("applies precedence: tool < instance < call", () => { + const instance: XAISearchParameters = { + mode: "auto", + max_search_results: 5, + return_citations: true, + }; + const call: XAISearchParameters = { + mode: "on", + max_search_results: 10, + }; + const tool: XAISearchParameters = { + mode: "off", + from_date: "2024-01-01", + to_date: "2024-01-31", + }; + + const result = mergeSearchParams(instance, call, tool); + + expect(result).toEqual({ + // from tool + from_date: "2024-01-01", + to_date: "2024-01-31", + // overridden by instance / call + mode: "on", + max_search_results: 10, + return_citations: true, + }); + }); +}); + +describe("buildSearchParametersPayload", () => { + test("returns undefined when params are undefined", () => { + expect(buildSearchParametersPayload(undefined)).toBeUndefined(); + }); + + test("builds payload with basic fields", () => { + const params: XAISearchParameters = { + mode: "on", + max_search_results: 7, + from_date: "2024-01-01", + to_date: "2024-01-31", + return_citations: false, + }; + + const payload = buildSearchParametersPayload(params); + + expect(payload).toEqual({ + mode: "on", + max_search_results: 7, + from_date: "2024-01-01", + to_date: "2024-01-31", + return_citations: false, + }); + }); + + test("includes sources only when non-empty", () => { + const sources: XAISearchSource[] = [ + { + type: "web", + allowed_websites: ["x.ai"], + }, + { + type: "news", + excluded_websites: ["example.com"], + }, + ]; + + const withSources = buildSearchParametersPayload({ + mode: "auto", + sources, + }); + + expect(withSources).toEqual({ + mode: "auto", + sources, + }); + + const withoutSources = buildSearchParametersPayload({ + mode: "auto", + sources: [], + }); + expect(withoutSources).toEqual({ + mode: "auto", + }); + }); +}); + +describe("filterXAIBuiltInTools", () => { + test("returns undefined when no tools are provided", () => { + expect(filterXAIBuiltInTools()).toBeUndefined(); + expect(filterXAIBuiltInTools({})).toBeUndefined(); + expect(filterXAIBuiltInTools({ tools: [] })).toBeUndefined(); + }); + + test("returns tools unchanged when no excludedTypes are provided", () => { + const tools = [{ type: "foo" }, { type: XAI_LIVE_SEARCH_TOOL_TYPE }]; + + const result = filterXAIBuiltInTools({ tools }); + + expect(result).toEqual(tools); + }); + + test("filters out tools whose type is in excludedTypes", () => { + const liveSearchTool = { type: XAI_LIVE_SEARCH_TOOL_TYPE, name: "live" }; + const otherTool = { type: "some_other_tool", name: "other" }; + + const result = filterXAIBuiltInTools({ + tools: [liveSearchTool, otherTool], + excludedTypes: [XAI_LIVE_SEARCH_TOOL_TYPE], + }); + + expect(result).toEqual([otherTool]); + }); + + test("keeps tools without a type property", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tools: any[] = [ + { id: 1 }, // no type + { type: XAI_LIVE_SEARCH_TOOL_TYPE }, + ]; + + const result = filterXAIBuiltInTools({ + tools, + excludedTypes: [XAI_LIVE_SEARCH_TOOL_TYPE], + }); + + // The tool without `type` should be kept, the built-in one should be filtered out + expect(result).toEqual([{ id: 1 }]); + }); + + test("returns undefined when all tools are filtered out", () => { + const tools = [{ type: XAI_LIVE_SEARCH_TOOL_TYPE }]; + + const result = filterXAIBuiltInTools({ + tools, + excludedTypes: [XAI_LIVE_SEARCH_TOOL_TYPE], + }); + + expect(result).toBeUndefined(); + }); +}); diff --git a/libs/providers/langchain-xai/src/tools/index.ts b/libs/providers/langchain-xai/src/tools/index.ts new file mode 100644 index 000000000000..a9828b7ae7fb --- /dev/null +++ b/libs/providers/langchain-xai/src/tools/index.ts @@ -0,0 +1,5 @@ +import { xaiLiveSearch } from "./live_search.js"; + +export const tools = { + xaiLiveSearch, +}; diff --git a/libs/providers/langchain-xai/src/tools/live_search.ts b/libs/providers/langchain-xai/src/tools/live_search.ts new file mode 100644 index 000000000000..3354f3f3a0e4 --- /dev/null +++ b/libs/providers/langchain-xai/src/tools/live_search.ts @@ -0,0 +1,213 @@ +import type { XAISearchParameters, XAISearchSource } from "../live_search.js"; + +/** + * xAI's deprecated live_search tool type. + */ +export const XAI_LIVE_SEARCH_TOOL_TYPE = "live_search_deprecated_20251215"; +export const XAI_LIVE_SEARCH_TOOL_NAME = "live_search"; + +/** + * xAI's built-in live_search tool type. + * Enables the model to search the web for real-time information. + */ +export interface XAILiveSearchTool extends XAISearchParameters { + /** + * The name of the tool. Must be "live_search" for xAI's built-in search. + */ + name: typeof XAI_LIVE_SEARCH_TOOL_NAME; + /** + * The type of the tool. This uses a deprecated Live Search API shape: + * the advanced agentic search capabilities powering grok.com are generally + * available in the new agentic tool calling API, and the Live Search API + * will be deprecated by December 15, 2025. + */ + type: typeof XAI_LIVE_SEARCH_TOOL_TYPE; +} + +/** + * Web search source configuration for the xAI live search tool (camelCase). + * This is converted to the snake_case `XAIWebSource` internally. + */ +export interface XAIWebSearchToolSource { + type: "web"; + country?: string; + excludedWebsites?: string[]; + allowedWebsites?: string[]; + safeSearch?: boolean; +} + +/** + * News search source configuration for the xAI live search tool (camelCase). + * This is converted to the snake_case `XAINewsSource` internally. + */ +export interface XAINewsSearchToolSource { + type: "news"; + country?: string; + excludedWebsites?: string[]; + safeSearch?: boolean; +} + +/** + * X (formerly Twitter) search source configuration for the xAI live search tool (camelCase). + * This is converted to the snake_case `XAIXSource` internally. + */ +export interface XAIXSearchToolSource { + type: "x"; + includedXHandles?: string[]; + excludedXHandles?: string[]; + postFavoriteCount?: number; + postViewCount?: number; +} + +/** + * RSS feed search source configuration for the xAI live search tool. + * The structure matches `XAIRssSource` (only `links`). + */ +export interface XAIRssSearchToolSource { + type: "rss"; + links: string[]; +} + +export type XAISearchToolSource = + | XAIWebSearchToolSource + | XAINewsSearchToolSource + | XAIXSearchToolSource + | XAIRssSearchToolSource; + +/** + * Options for the xAI live search tool (camelCase). + * All fields are camel-cased for the TypeScript API and are mapped to the + * corresponding snake_case fields in the underlying `XAISearchParameters` + * object that is sent to xAI's deprecated Live Search API. + */ +export interface XAILiveSearchToolOptions { + /** + * Controls when the model should perform a search. + * - "auto": Let the model decide when to search (default) + * - "on": Always search for every request + * - "off": Never search + */ + mode?: "auto" | "on" | "off"; + /** + * Maximum number of search results to return. + * @default 20 + */ + maxSearchResults?: number; + /** + * Filter search results to only include content from after this date. + * Format: ISO 8601 date string (e.g., "2024-01-01") + */ + fromDate?: string; + /** + * Filter search results to only include content from before this date. + * Format: ISO 8601 date string (e.g., "2024-12-31") + */ + toDate?: string; + /** + * Whether to return citations/sources for the search results. + * @default true + */ + returnCitations?: boolean; + /** + * Specific web/news/X/RSS sources that can be used for the search. + * These are converted to the snake_case `XAISearchSource` structures + * used by the underlying xAI Live Search API. + */ + sources?: XAISearchToolSource[]; +} + +function mapToolSourceToSearchSource( + source: XAISearchToolSource +): XAISearchSource { + switch (source.type) { + case "web": + return { + type: "web", + ...(source.country !== undefined && { country: source.country }), + ...(source.allowedWebsites !== undefined && { + allowed_websites: source.allowedWebsites, + }), + ...(source.excludedWebsites !== undefined && { + excluded_websites: source.excludedWebsites, + }), + ...(source.safeSearch !== undefined && { + safe_search: source.safeSearch, + }), + }; + case "news": + return { + type: "news", + ...(source.country !== undefined && { country: source.country }), + ...(source.excludedWebsites !== undefined && { + excluded_websites: source.excludedWebsites, + }), + ...(source.safeSearch !== undefined && { + safe_search: source.safeSearch, + }), + }; + case "x": + return { + type: "x", + ...(source.includedXHandles !== undefined && { + included_x_handles: source.includedXHandles, + }), + ...(source.excludedXHandles !== undefined && { + excluded_x_handles: source.excludedXHandles, + }), + ...(source.postFavoriteCount !== undefined && { + post_favorite_count: source.postFavoriteCount, + }), + ...(source.postViewCount !== undefined && { + post_view_count: source.postViewCount, + }), + }; + case "rss": + return { + type: "rss", + links: source.links, + }; + default: { + const _exhaustive: never = source; + return _exhaustive; + } + } +} + +/** + * Creates an xAI built-in live search tool. + * Enables the model to search the web for real-time information. + * + * This tool is executed server-side by the xAI API. + * + * @example + * ```typescript + * import { ChatXAI, tools } from "@langchain/xai"; + * + * const llm = new ChatXAI({ + * model: "grok-beta", + * }); + * + * const searchTool = tools.xaiLiveSearch({ + * maxSearchResults: 5, + * fromDate: "2024-01-01", + * returnCitations: true + * }); + * + * const llmWithSearch = llm.bindTools([searchTool]); + * const result = await llmWithSearch.invoke("What happened in tech today?"); + * ``` + */ +export function xaiLiveSearch( + options: XAILiveSearchToolOptions = {} +): XAILiveSearchTool { + return { + type: XAI_LIVE_SEARCH_TOOL_TYPE, + name: XAI_LIVE_SEARCH_TOOL_NAME, + mode: options?.mode, + max_search_results: options?.maxSearchResults, + from_date: options?.fromDate, + to_date: options?.toDate, + return_citations: options?.returnCitations, + sources: options?.sources?.map(mapToolSourceToSearchSource), + } satisfies XAILiveSearchTool; +} diff --git a/libs/providers/langchain-xai/src/tools/tests/live_search.test.ts b/libs/providers/langchain-xai/src/tools/tests/live_search.test.ts new file mode 100644 index 000000000000..3024f4b7ce2d --- /dev/null +++ b/libs/providers/langchain-xai/src/tools/tests/live_search.test.ts @@ -0,0 +1,159 @@ +import { test, expect, describe } from "vitest"; +import { + XAI_LIVE_SEARCH_TOOL_NAME, + XAI_LIVE_SEARCH_TOOL_TYPE, + xaiLiveSearch, + XAILiveSearchTool, +} from "../live_search.js"; +import { ChatXAI } from "../../chat_models.js"; + +describe("xaiLiveSearch tool", () => { + test("creates a tool with correct provider definition", async () => { + const tool = xaiLiveSearch({ + maxSearchResults: 10, + fromDate: "2024-01-01", + returnCitations: true, + }); + + expect(tool).toMatchObject({ + type: XAI_LIVE_SEARCH_TOOL_TYPE, + name: XAI_LIVE_SEARCH_TOOL_NAME, + max_search_results: 10, + from_date: "2024-01-01", + return_citations: true, + } satisfies XAILiveSearchTool); + }); + + test("creates a tool with default options", async () => { + const tool = xaiLiveSearch(); + expect(tool).toMatchObject({ + type: XAI_LIVE_SEARCH_TOOL_TYPE, + name: XAI_LIVE_SEARCH_TOOL_NAME, + } satisfies XAILiveSearchTool); + }); + + test("creates a tool with web and news sources using excluded_websites", async () => { + const tool = xaiLiveSearch({ + sources: [ + { + type: "web", + excludedWebsites: ["wikipedia.org"], + }, + { + type: "news", + excludedWebsites: ["bbc.co.uk"], + }, + ], + }); + + expect(tool).toMatchObject({ + type: XAI_LIVE_SEARCH_TOOL_TYPE, + name: XAI_LIVE_SEARCH_TOOL_NAME, + sources: [ + { + type: "web", + excluded_websites: ["wikipedia.org"], + }, + { + type: "news", + excluded_websites: ["bbc.co.uk"], + }, + ], + } satisfies XAILiveSearchTool); + }); +}); + +describe("ChatXAI with xaiLiveSearch tool", () => { + test("formatStructuredToolToXAI preserves provider definition", () => { + const model = new ChatXAI({ apiKey: "foo" }); + const searchTool = xaiLiveSearch({ + maxSearchResults: 8, + sources: [ + { + type: "web", + allowedWebsites: ["example.com"], + }, + ], + }); + + // Access protected method for testing + const formattedTools = model.formatStructuredToolToXAI([searchTool]); + + expect(formattedTools).toHaveLength(1); + expect(formattedTools![0]).toMatchObject({ + type: XAI_LIVE_SEARCH_TOOL_TYPE, + name: XAI_LIVE_SEARCH_TOOL_NAME, + max_search_results: 8, + sources: [ + { + type: "web", + allowed_websites: ["example.com"], + }, + ], + } satisfies XAILiveSearchTool); + }); + + test("invocationParams extracts parameters from formatted tools", () => { + const model = new ChatXAI({ apiKey: "foo" }); + + // Simulate the tools being passed in options (as they would be after bindTools -> withConfig -> invoke) + const tools: [XAILiveSearchTool] = [ + { + type: XAI_LIVE_SEARCH_TOOL_TYPE, + name: XAI_LIVE_SEARCH_TOOL_NAME, + max_search_results: 8, + sources: [ + { + type: "web", + allowed_websites: ["example.com"], + }, + ], + }, + ]; + + // Access protected method for testing + const params = model.invocationParams({ + tools: tools, + }); + + expect(params.search_parameters).toEqual({ + mode: "auto", + max_search_results: 8, + sources: [ + { + type: "web", + allowed_websites: ["example.com"], + }, + ], + }); + }); + + test("explicit searchParameters override tool parameters", () => { + const model = new ChatXAI({ + apiKey: "foo", + searchParameters: { + mode: "on", + max_search_results: 5, // This should override the tool's 10 + }, + }); + + const tools: [XAILiveSearchTool] = [ + { + type: XAI_LIVE_SEARCH_TOOL_TYPE, + name: XAI_LIVE_SEARCH_TOOL_NAME, + max_search_results: 10, + from_date: "2024-01-01", + }, + ]; + + const params = model.invocationParams({ + tools: tools, + }); + + expect(params.search_parameters).toEqual({ + mode: "on", + max_search_results: 5, // Overridden by constructor/option + from_date: "2024-01-01", // Preserved from a tool + }); + }); +});