feat(openai): add GrokAI search grounding via xAI Responses API

xAI's Responses API accepts web_search and x_search tool types, but
fabric hardcoded OpenAI's web_search_preview tool name in
buildResponseParams, causing GrokAI plus --search to fail with HTTP 422.

This adds two new fields to openai_compatible.ProviderConfig:
- WebSearchToolName: override the default web_search tool name string
- EnableXSearch: append xAI's x_search tool when search is enabled

Both fields are empty/false by default, preserving backwards
compatibility for all existing providers. GrokAI now sets
WebSearchToolName to "web_search" and EnableXSearch to true.

Tests added in openai_test.go cover the new override paths and
confirm the default provider behavior is unchanged.

Verified with live xAI API key: fabric -V GrokAI --search "query"
now returns grounded results with real source URLs.
This commit is contained in:
Kenneth G. Hartman 2026-04-11 12:27:16 -04:00
parent 42311fe04b
commit 7ced82f782
4 changed files with 148 additions and 4 deletions

View file

@ -88,7 +88,7 @@ type Flags struct {
ListStrategies bool `long:"liststrategies" description:"List all strategies"`
ListVendors bool `long:"listvendors" description:"List all vendors"`
ShellCompleteOutput bool `long:"shell-complete-list" description:"Output raw list without headers/formatting (for shell completion)"`
Search bool `long:"search" description:"Enable web search tool for supported models (Anthropic, OpenAI, Gemini)"`
Search bool `long:"search" description:"Enable web search tool for supported models (Anthropic, OpenAI, Gemini, Grok)"`
SearchLocation string `long:"search-location" description:"Set location for web search results (e.g., 'America/Los_Angeles')"`
ImageFile string `long:"image-file" description:"Save generated image to specified file path (e.g., 'output.png')"`
ImageSize string `long:"image-size" description:"Image dimensions: 1024x1024, 1536x1024, 1024x1536, auto (default: auto)"`

View file

@ -66,6 +66,15 @@ type Client struct {
ApiClient *openai.Client
ImplementsResponses bool // Whether this provider supports the Responses API
httpClient *http.Client
// webSearchToolName, when non-empty, overrides the default
// "web_search_preview" tool name emitted on the Responses API.
// Used by OpenAI-compatible providers whose upstream API expects a
// different tool type string (xAI expects "web_search").
webSearchToolName string
// enableXSearch, when true, appends an additional "x_search" tool
// entry alongside the web search tool when Search is enabled.
// This is an xAI-specific live search grounding tool.
enableXSearch bool
}
// SetResponsesAPIEnabled configures whether to use the Responses API
@ -73,6 +82,21 @@ func (o *Client) SetResponsesAPIEnabled(enabled bool) {
o.ImplementsResponses = enabled
}
// SetWebSearchToolName overrides the default "web_search_preview" tool
// name emitted on the Responses API when Search is enabled. Pass an empty
// string to keep the OpenAI default. Non-OpenAI providers (for example,
// xAI) may require "web_search" instead.
func (o *Client) SetWebSearchToolName(name string) {
o.webSearchToolName = name
}
// SetEnableXSearch toggles whether an additional xAI "x_search" tool
// entry is appended when Search is enabled. Non-xAI providers should
// leave this false.
func (o *Client) SetEnableXSearch(enabled bool) {
o.enableXSearch = enabled
}
// checkImageGenerationCompatibility warns if the model doesn't support image generation
func checkImageGenerationCompatibility(model string) {
if !supportsImageGeneration(model) {
@ -253,11 +277,18 @@ func (o *Client) buildResponseParams(
// Add tools if enabled
var tools []responses.ToolUnionParam
// Add web search tool if enabled
// Add web search tool if enabled. The default tool name is OpenAI's
// "web_search_preview", but providers may override it (for example,
// xAI's Responses API requires "web_search").
if opts.Search {
webSearchTool := responses.ToolParamOfWebSearchPreview("web_search_preview")
searchToolName := responses.WebSearchToolType("web_search_preview")
if o.webSearchToolName != "" {
searchToolName = responses.WebSearchToolType(o.webSearchToolName)
}
webSearchTool := responses.ToolParamOfWebSearchPreview(searchToolName)
// Add user location if provided
// Add user location if provided. Only attach when the caller
// asked for it; xAI rejects unexpected location payloads.
if opts.SearchLocation != "" {
webSearchTool.OfWebSearchPreview.UserLocation = responses.WebSearchToolUserLocationParam{
Type: "approximate",
@ -266,6 +297,20 @@ func (o *Client) buildResponseParams(
}
tools = append(tools, webSearchTool)
// Append xAI's live "x_search" tool when the provider opts in.
// The xAI Responses API accepts a bare {"type":"x_search"}
// entry with no other required fields. We reuse the SDK's
// WebSearchToolParam as a minimal container since every other
// field is omitzero and will be elided during JSON marshalling.
if o.enableXSearch {
xSearchTool := responses.ToolUnionParam{
OfWebSearchPreview: &responses.WebSearchToolParam{
Type: responses.WebSearchToolType("x_search"),
},
}
tools = append(tools, xSearchTool)
}
}
// Add image generation tool if needed

View file

@ -129,6 +129,88 @@ func TestBuildResponseParams_WithSearchAndLocation(t *testing.T) {
assert.Equal(t, opts.SearchLocation, userLocation.Timezone.Value)
}
// TestBuildResponseParams_GrokAI_WithSearch verifies that a client
// configured with a custom web search tool name and x_search enabled
// emits both tool entries with the xAI-expected type strings.
func TestBuildResponseParams_GrokAI_WithSearch(t *testing.T) {
client := NewClient()
client.SetWebSearchToolName("web_search")
client.SetEnableXSearch(true)
opts := &domain.ChatOptions{
Model: "grok-4-fast-reasoning",
Temperature: 0.7,
Search: true,
}
msgs := []*chat.ChatCompletionMessage{
{Role: "user", Content: "What happened in the news today?"},
}
params := client.buildResponseParams(msgs, opts)
assert.NotNil(t, params.Tools, "Expected tools when search is enabled")
assert.Len(t, params.Tools, 2, "Expected web_search plus x_search tools")
webSearchTool := params.Tools[0]
assert.NotNil(t, webSearchTool.OfWebSearchPreview, "Expected web search tool slot")
assert.Equal(t, responses.WebSearchToolType("web_search"), webSearchTool.OfWebSearchPreview.Type)
xSearchTool := params.Tools[1]
assert.NotNil(t, xSearchTool.OfWebSearchPreview, "Expected x_search tool slot")
assert.Equal(t, responses.WebSearchToolType("x_search"), xSearchTool.OfWebSearchPreview.Type)
}
// TestBuildResponseParams_DefaultProvider_Unchanged guards backwards
// compatibility. A client that does not set the new override fields
// must continue emitting a single web_search_preview tool entry.
func TestBuildResponseParams_DefaultProvider_Unchanged(t *testing.T) {
client := NewClient()
opts := &domain.ChatOptions{
Model: "gpt-4o",
Temperature: 0.7,
Search: true,
}
msgs := []*chat.ChatCompletionMessage{
{Role: "user", Content: "What is the capital of France?"},
}
params := client.buildResponseParams(msgs, opts)
assert.NotNil(t, params.Tools, "Expected tools when search is enabled")
assert.Len(t, params.Tools, 1, "Expected exactly one tool for default provider")
tool := params.Tools[0]
assert.NotNil(t, tool.OfWebSearchPreview, "Expected web search tool slot")
assert.Equal(t, responses.WebSearchToolType("web_search_preview"), tool.OfWebSearchPreview.Type)
}
// TestBuildResponseParams_GrokAI_WithoutSearch confirms that a GrokAI
// style client without Search enabled does not append any tools.
// This protects the no-search path from regressions introduced by the
// new override logic.
func TestBuildResponseParams_GrokAI_WithoutSearch(t *testing.T) {
client := NewClient()
client.SetWebSearchToolName("web_search")
client.SetEnableXSearch(true)
opts := &domain.ChatOptions{
Model: "grok-4-fast-reasoning",
Temperature: 0.7,
Search: false,
}
msgs := []*chat.ChatCompletionMessage{
{Role: "user", Content: "Hello"},
}
params := client.buildResponseParams(msgs, opts)
assert.Nil(t, params.Tools, "Expected no tools when search is disabled")
}
func TestCitationFormatting(t *testing.T) {
// Test the citation formatting logic by simulating the citation extraction
var textParts []string

View file

@ -21,6 +21,14 @@ type ProviderConfig struct {
BaseURL string
ModelsURL string // Optional: Custom endpoint for listing models (if different from BaseURL/models)
ImplementsResponses bool // Whether the provider supports OpenAI's new Responses API
// WebSearchToolName overrides the default "web_search_preview" tool name
// emitted on the Responses API when Search is enabled. Leave empty to keep
// the OpenAI default. xAI, for example, requires "web_search".
WebSearchToolName string
// EnableXSearch, when true, also appends an xAI "x_search" tool entry
// alongside the web search tool when Search is enabled. Non-xAI
// providers should leave this false.
EnableXSearch bool
}
// Client is the common structure for all OpenAI-compatible providers
@ -40,6 +48,10 @@ func NewClient(providerConfig ProviderConfig) *Client {
providerConfig.ImplementsResponses,
nil,
)
// Apply optional Responses API tool overrides. Zero values preserve
// existing behavior for providers that do not set these fields.
client.Client.SetWebSearchToolName(providerConfig.WebSearchToolName)
client.Client.SetEnableXSearch(providerConfig.EnableXSearch)
return client
}
@ -240,6 +252,11 @@ var ProviderMap = map[string]ProviderConfig{
Name: "GrokAI",
BaseURL: "https://api.x.ai/v1",
ImplementsResponses: true,
// xAI's Responses API expects the "web_search" tool type, not
// OpenAI's "web_search_preview", and additionally accepts an
// "x_search" tool entry for live search grounding.
WebSearchToolName: "web_search",
EnableXSearch: true,
},
"Groq": {
Name: "Groq",