fix(xai): stop rejecting instructions on the Responses API

`XAIResponsesAPIConfig` removed `instructions` from its supported params
on the premise that xAI does not accept it. xAI does accept it — it is in
their API reference, and the live API obeys it and echoes it back, including
alongside a server-side `web_search` tool.

The exclusion was harmless until `web_search_options` began routing xAI
completions through the Responses API bridge, which hoists a system message
into `instructions`. Since then, any xAI web-search request carrying a system
message fails:

    litellm.UnsupportedParamsError: LlmProviders.XAI does not support
    parameters: {'instructions': 'Answer briefly.'}, for model=grok-4.3

`drop_params=True` is not a workaround: `instructions` is the caller's system
prompt, so the call succeeds having silently discarded it.

`map_openai_params` also popped `instructions`, but `_check_valid_arg` raises
first, so that branch was unreachable. Both go.

The regression test injects a mocked client via `client=` rather than
monkeypatching `HTTPHandler.post`, per the testing guidance in CLAUDE.md.

Fixes #37127
This commit is contained in:
MHammett 2026-08-16 20:21:43 -05:00
parent 168a0055a2
commit 26f601f968
3 changed files with 97 additions and 37 deletions

View file

@ -46,7 +46,6 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
Inherits from OpenAIResponsesAPIConfig since XAI's Responses API is largely
compatible with OpenAI's, with a few differences:
- Does not support the 'instructions' parameter
- Requires code_interpreter tools to have 'container' field removed
- Recommends store=false when sending images
@ -57,20 +56,6 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
def custom_llm_provider(self) -> LlmProviders:
return LlmProviders.XAI
def get_supported_openai_params(self, model: str) -> list:
"""
Get supported parameters for XAI Responses API.
XAI supports most OpenAI Responses API params except 'instructions'.
"""
supported_params: Final = super().get_supported_openai_params(model)
# Remove 'instructions' as it's not supported by XAI
if "instructions" in supported_params:
supported_params.remove("instructions")
return supported_params
def _transform_web_search_tool(self, tool: Mapping[str, object]) -> Mapping[str, object]:
"""
Transform web_search tool to XAI format.
@ -160,19 +145,13 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
Map parameters for XAI Responses API.
Handles XAI-specific transformations:
1. Drops 'instructions' parameter (not supported)
2. Transforms code_interpreter tools to remove 'container' field
3. Transforms web_search tools to XAI format (removes search_context_size, adds filters)
4. Transforms x_search tools to XAI format
5. Sets store=false when images are detected (recommended by XAI)
1. Transforms code_interpreter tools to remove 'container' field
2. Transforms web_search tools to XAI format (removes search_context_size, adds filters)
3. Transforms x_search tools to XAI format
4. Sets store=false when images are detected (recommended by XAI)
"""
params: Final = dict(response_api_optional_params)
# Drop instructions parameter (not supported by XAI)
if "instructions" in params:
verbose_logger.debug("XAI Responses API does not support 'instructions' parameter. Dropping it.")
params.pop("instructions")
if "metadata" in params:
verbose_logger.debug("XAI Responses API does not support 'metadata' parameter. Dropping it.")
params.pop("metadata")

View file

@ -7,12 +7,14 @@ transformations for the Responses API.
Source: litellm/llms/xai/responses/transformation.py
"""
from typing import Final
from unittest.mock import MagicMock, Mock
import httpx
import pytest
import litellm
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.llms.xai.cost_calculator import cost_per_token
from litellm.llms.xai.responses.transformation import XAIResponsesAPIConfig
from litellm.responses.utils import ResponseAPILoggingUtils
@ -53,23 +55,26 @@ class TestXAIResponsesAPITransformation:
assert result["tools"][0]["type"] == "code_interpreter"
assert "container" not in result["tools"][0], "Container field should be removed"
def test_instructions_parameter_dropped(self):
"""Test that instructions parameter is dropped for XAI"""
def test_instructions_parameter_preserved(self):
"""XAI accepts `instructions`, so it must survive param mapping.
Dropping it silently discards the caller's system prompt.
"""
config = XAIResponsesAPIConfig()
params = ResponsesAPIOptionalRequestParams(instructions="You are a helpful assistant.", temperature=0.7)
result = config.map_openai_params(response_api_optional_params=params, model="grok-4-fast", drop_params=False)
assert "instructions" not in result, "Instructions should be dropped"
assert result.get("instructions") == "You are a helpful assistant.", "Instructions should be preserved"
assert result.get("temperature") == 0.7, "Other params should be preserved"
def test_supported_params_excludes_instructions(self):
"""Test that get_supported_openai_params excludes instructions"""
def test_supported_params_includes_instructions(self):
"""Test that get_supported_openai_params includes instructions"""
config = XAIResponsesAPIConfig()
supported = config.get_supported_openai_params("grok-4-fast")
assert "instructions" not in supported, "instructions should not be supported"
assert "instructions" in supported, "instructions should be supported"
assert "tools" in supported, "tools should be supported"
assert "temperature" in supported, "temperature should be supported"
assert "model" in supported, "model should be supported"
@ -492,3 +497,74 @@ class TestXAIResponsesReportedCost:
)
assert usage.cost is None
class TestXAICompletionBridgeSystemPrompt:
"""`web_search_options` bridges xAI completions to the Responses API.
The bridge hoists a system message into `instructions`, so excluding
`instructions` from XAI's supported params made every web-search request
carrying a system prompt fail and made `drop_params=True` silently
discard the system prompt instead.
"""
MESSAGES = [
{"role": "system", "content": "Answer briefly."},
{"role": "user", "content": "Newest litellm version on PyPI?"},
]
@staticmethod
def _mock_response() -> MagicMock:
mock_resp: Final = MagicMock()
mock_resp.status_code = 200
mock_resp.headers = {}
mock_resp.text = "raw"
mock_resp.json.return_value = {
"id": "resp_1",
"object": "response",
"created_at": 1754900000,
"model": "grok-4.3",
"status": "completed",
"parallel_tool_calls": True,
"tool_choice": "auto",
"tools": [],
"top_p": 1.0,
"output": [
{
"type": "message",
"id": "msg_1",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "1.97.0", "annotations": []}],
}
],
"usage": {
"input_tokens": 100,
"output_tokens": 20,
"total_tokens": 120,
"input_tokens_details": {"cached_tokens": 0},
"output_tokens_details": {"reasoning_tokens": 0},
},
}
return mock_resp
def test_system_prompt_survives_web_search_bridge(self):
"""A system message + web_search_options must reach XAI as `instructions`."""
client: Final = HTTPHandler()
client.post = MagicMock(return_value=self._mock_response())
litellm.completion(
model="xai/grok-4.3",
messages=self.MESSAGES,
web_search_options={"search_context_size": "medium"},
api_key="fake-key",
client=client,
)
client.post.assert_called_once()
request_body: Final = client.post.call_args.kwargs["json"]
assert request_body["instructions"] == "Answer briefly.", "System prompt must not be dropped"
assert [tool["type"] for tool in request_body["tools"]] == ["web_search"]
# The system message is carried by `instructions`, not duplicated into input.
assert [item["role"] for item in request_body["input"]] == ["user"]

View file

@ -53,8 +53,11 @@ class TestXAIResponsesAPITransformation:
"container" not in result["tools"][0]
), "Container field should be removed"
def test_instructions_parameter_dropped(self):
"""Test that instructions parameter is dropped for XAI"""
def test_instructions_parameter_preserved(self):
"""XAI accepts `instructions`, so it must survive param mapping.
Dropping it silently discards the caller's system prompt.
"""
config = XAIResponsesAPIConfig()
params = ResponsesAPIOptionalRequestParams(
@ -65,15 +68,17 @@ class TestXAIResponsesAPITransformation:
response_api_optional_params=params, model="grok-4-fast", drop_params=False
)
assert "instructions" not in result, "Instructions should be dropped"
assert (
result.get("instructions") == "You are a helpful assistant."
), "Instructions should be preserved"
assert result.get("temperature") == 0.7, "Other params should be preserved"
def test_supported_params_excludes_instructions(self):
"""Test that get_supported_openai_params excludes instructions"""
def test_supported_params_includes_instructions(self):
"""Test that get_supported_openai_params includes instructions"""
config = XAIResponsesAPIConfig()
supported = config.get_supported_openai_params("grok-4-fast")
assert "instructions" not in supported, "instructions should not be supported"
assert "instructions" in supported, "instructions should be supported"
assert "tools" in supported, "tools should be supported"
assert "temperature" in supported, "temperature should be supported"
assert "model" in supported, "model should be supported"