litellm/tests/unit/test_xai_responses_auto_routing.py
yuneng-jiang f6882246d4
test: move tests/test_litellm root and small trees into tests/unit (#43186)
* ci: run the unit_selection.sh shard files on every event instead of only fork pull requests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci: rename fork-flag to unit-flag now that it applies on every event

* test: move tests/test_litellm root and small trees into tests/unit

Pure renames, no content changes. Follow-up commits in this PR fix
references, merge the three files that already existed in tests/unit,
keep live-provider tests in tests/test_litellm and wire CI.

* test: carry tests/test_litellm conftest isolation into tests/unit

Callback lists, routing fallbacks, cached HTTP clients, logger state, AWS,
proxy-URL and keychain env, and session-end client cleanup now reset for
unit tests too. The environment isolation owns its MonkeyPatch so a test's
own monkeypatch is undone before the model-cost teardown runs.

* test: merge, split and prune the moved root and small-tree tests

Merge batches/test_batch_utils.py and the chat_completions and messages
dispatch tests into the files that already existed in tests/unit. Keep
the live Gemini interactions tests, the async image-fetch format test and
the OpenAI embedding scorer test in tests/test_litellm since they need
real network or keys. Put test_router.py under tests/unit/test_router so
the existing package no longer shadows it. Delete eight tests the audit
found superseded by stronger ones kept in this move.

* ci: run the moved root and small-tree tests under their legacy flags

Add the misc and responses-caching-types flags to unit_selection.sh and
CircleCI, extend enterprise-routing and mcp-integration, and point the
legacy GHA shards, Makefile, redis-compat workflow, merge smoke manifest
and change classifier at the new paths.

* test: make the new tests/unit directories packages

tests/unit/test_package_layout.py requires every directory to carry an
__init__.py, and without one the moved and retained
test_litellm_responses_bridge.py modules collide on import.

* test: scope the unit socket block to tests/unit in shared sessions

The GHA shards collect the legacy test-path and the unit selection in one
pytest session. The unit conftest's loopback-only block leaked into legacy
modules that reach the network at import. The legacy conftest now lifts the
restriction at collect and setup time, and the unit conftest re-applies it
when collecting its own modules.

* test: give the shard-script tests their own GITHUB_OUTPUT

They only passed where the runner set it. The CircleCI unit job's env
allowlist drops it, so the script's redirect failed there.

* test: point the router and module-deletion checks at tests/unit

router_code_coverage and code_qa_check_tests only searched tests/test_litellm,
so the moved router tests no longer counted. The two silent-experiment tests
the audit deleted were the only direct callers of those methods; they are
replaced with tests that assert the forwarded shadow request and the
recursion guard.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 11:30:43 -07:00

236 lines
8.4 KiB
Python

"""
Test automatic routing to xAI Responses API when tools are present
"""
import json
from collections.abc import Mapping
from typing import Final
from unittest.mock import MagicMock, patch
import httpx
import pytest
import litellm
from litellm.llms.custom_httpx.http_handler import HTTPHandler
from litellm.main import responses_api_bridge_check
class _RecordingResponsesHandler:
"""MockTransport handler that serves a canned /responses reply and keeps the body xAI would have received"""
def __init__(self, reply: Mapping[str, object]) -> None:
self.reply: Final = reply
self.request_body: Mapping[str, object] | None = None
def __call__(self, request: httpx.Request) -> httpx.Response:
self.request_body = json.loads(request.content)
return httpx.Response(200, json=dict(self.reply), request=request)
class TestXAIResponsesAutoRouting:
"""Test that xAI requests with tools automatically route to Responses API"""
def test_responses_api_bridge_check_without_tools(self):
"""Test that without tools, xAI uses chat mode"""
model = "grok-3"
custom_llm_provider = "xai"
tools = None
web_search_options = None
model_info, updated_model = responses_api_bridge_check(
model=model,
custom_llm_provider=custom_llm_provider,
web_search_options=web_search_options,
)
# Should not auto-route to responses mode without tools
assert model_info.get("mode") != "responses"
assert updated_model == model
def test_responses_api_bridge_check_with_empty_tools(self):
"""Test that with empty tools list, xAI does not route to Responses API"""
model = "grok-3"
custom_llm_provider = "xai"
tools = []
web_search_options = None
model_info, updated_model = responses_api_bridge_check(
model=model,
custom_llm_provider=custom_llm_provider,
web_search_options=web_search_options,
)
# Should not auto-route with empty tools list
assert model_info.get("mode") != "responses"
assert updated_model == model
def test_responses_api_bridge_check_non_xai_provider_with_tools(self):
"""Test that non-xAI providers don't get auto-routed"""
model = "gpt-4"
custom_llm_provider = "openai"
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the weather",
},
}
]
web_search_options = None
model_info, updated_model = responses_api_bridge_check(
model=model,
custom_llm_provider=custom_llm_provider,
web_search_options=web_search_options,
)
# Should not auto-route non-xAI providers
assert model_info.get("mode") != "responses"
assert updated_model == model
def test_responses_api_bridge_check_with_responses_prefix(self):
"""Test that responses/ prefix still works"""
model = "responses/grok-3"
custom_llm_provider = "xai"
tools = None
web_search_options = None
model_info, updated_model = responses_api_bridge_check(
model=model,
custom_llm_provider=custom_llm_provider,
web_search_options=web_search_options,
)
# Should route to responses mode with prefix, even without tools
assert model_info.get("mode") == "responses"
assert updated_model == "grok-3" # prefix removed
def test_responses_api_bridge_check_with_web_search_options(self):
"""Test auto-routing with web_search_options"""
model = "grok-4-1-fast"
custom_llm_provider = "xai"
tools = None
web_search_options = {} # Empty dict should trigger routing
model_info, updated_model = responses_api_bridge_check(
model=model,
custom_llm_provider=custom_llm_provider,
web_search_options=web_search_options,
)
# Should auto-route with web_search_options
assert model_info.get("mode") == "responses"
assert updated_model == model
def test_responses_api_bridge_check_with_web_search_options_and_tools(self):
"""Test auto-routing with both web_search_options and tools"""
model = "grok-4"
custom_llm_provider = "xai"
tools = [{"type": "code_interpreter"}]
web_search_options = {"enabled": True}
model_info, updated_model = responses_api_bridge_check(
model=model,
custom_llm_provider=custom_llm_provider,
web_search_options=web_search_options,
)
# Should auto-route with both present
assert model_info.get("mode") == "responses"
assert updated_model == model
def test_responses_api_bridge_check_with_web_search_options_on_unmapped_model(self):
"""web search must reach /responses even for a model missing from the cost map, chat returns 410"""
model_info, updated_model = responses_api_bridge_check(
model="grok-not-in-cost-map",
custom_llm_provider="xai",
web_search_options={"search_context_size": "medium"},
)
assert model_info.get("mode") == "responses"
assert updated_model == "grok-not-in-cost-map"
@patch("litellm.completion_extras.responses_api_bridge.completion")
def test_completion_with_tools_routes_to_responses_api(
self, mock_responses_completion
):
"""Test that completion() with tools routes to Responses API"""
# Mock the responses_api_bridge.completion to avoid actual API calls
mock_responses_completion.return_value = MagicMock()
model = "xai/grok-3"
messages = [{"role": "user", "content": "What's the weather?"}]
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather info",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string"}},
},
},
}
]
try:
litellm.completion(
model=model,
messages=messages,
tools=tools,
mock_response="This is a test", # Use mock mode to avoid API calls
)
except Exception:
# It's ok if this fails, we just want to verify the routing logic
pass
# The mock should have been called, indicating responses API was used
# Note: This test may need adjustment based on actual mock_response behavior
# The key is that the responses_api_bridge_check logic routes correctly
def test_system_message_survives_web_search_bridge(self):
"""A system message becomes 'instructions' on the bridged /responses call, and xAI accepts it"""
handler: Final = _RecordingResponsesHandler(
reply={
"id": "resp_test",
"object": "response",
"created_at": 0,
"status": "completed",
"model": "grok-4.6",
"output": [
{
"type": "message",
"id": "msg_test",
"status": "completed",
"role": "assistant",
"content": [{"type": "output_text", "text": "1.0.0", "annotations": []}],
}
],
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
}
)
response: Final = litellm.completion(
model="xai/grok-4.6",
messages=[
{"role": "system", "content": "Answer briefly."},
{"role": "user", "content": "newest litellm version?"},
],
web_search_options={"search_context_size": "medium"},
api_key="fake-key",
client=HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(handler))),
)
assert response.choices[0].message.content == "1.0.0"
assert handler.request_body is not None
assert handler.request_body["instructions"] == "Answer briefly."
assert handler.request_body["tools"] == [{"type": "web_search"}]
if __name__ == "__main__":
pytest.main([__file__, "-v"])