fix(messages): route to Responses API based on model_info.mode

When a /v1/messages request arrives for a non-native model (no
Anthropic messages config), the handler decides between Responses API
and chat/completions adapter. Previously this was provider-only:
only models under the "openai" provider went to Responses API.

Now also check model_info["mode"] == "responses", so models like
github_copilot GPT (configured with mode: responses in config.yaml)
go directly through messages → responses translation instead of
the two-layer messages → chat/completions → responses bridge.

Also fixes tool translation for web search:
- tool_choice: web_search name maps to {"type": "web_search"}
  instead of {"type": "function", "name": "web_search"}
- tools: Anthropic allowed_domains on web_search tools are translated
  to Responses API filters.allowed_domains (blocked_domains dropped
  as Responses API does not support them)
- Use "web_search" tool type instead of deprecated "web_search_preview"
  (only "web_search" supports filters.allowed_domains)
This commit is contained in:
Vigilans 2026-04-15 23:50:29 +08:00
parent bd753aecf3
commit 0b135c0657
3 changed files with 27 additions and 10 deletions

View file

@ -51,14 +51,23 @@ from .utils import AnthropicMessagesRequestUtils, mock_response
_RESPONSES_API_PROVIDERS = frozenset({"openai"})
def _should_route_to_responses_api(custom_llm_provider: Optional[str]) -> bool:
def _should_route_to_responses_api(
custom_llm_provider: Optional[str],
model_info: Optional[Dict] = None,
) -> bool:
"""Return True when the provider should use the Responses API path.
Routes to the Responses API when:
- The provider is in _RESPONSES_API_PROVIDERS (e.g. openai), OR
- The model has mode=responses in model_info (e.g. github_copilot GPT models)
Set ``litellm.use_chat_completions_url_for_anthropic_messages = True`` to
opt out and route OpenAI/Azure requests through chat/completions instead.
opt out and route all requests through chat/completions instead.
"""
if litellm.use_chat_completions_url_for_anthropic_messages:
return False
if model_info and model_info.get("mode") == "responses":
return True
return custom_llm_provider in _RESPONSES_API_PROVIDERS
@ -558,7 +567,7 @@ def anthropic_messages_handler(
custom_llm_provider=custom_llm_provider,
**kwargs,
)
if _should_route_to_responses_api(custom_llm_provider):
if _should_route_to_responses_api(custom_llm_provider, kwargs.get("model_info")):
return LiteLLMMessagesToResponsesAPIHandler.anthropic_messages_handler(**_shared_kwargs)
# The in-gateway context_management polyfill runs inside

View file

@ -185,7 +185,11 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
tool_name = tool_dict.get("name", "")
# web_search tool
if (isinstance(tool_type, str) and tool_type.startswith("web_search")) or tool_name == "web_search":
result.append({"type": "web_search_preview"})
web_tool: Dict[str, Any] = {"type": "web_search"}
allowed = tool_dict.get("allowed_domains")
if allowed:
web_tool["filters"] = {"allowed_domains": allowed}
result.append(web_tool)
continue
func_tool: Dict[str, Any] = {"type": "function", "name": tool_name}
if "description" in tool_dict:
@ -204,7 +208,11 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
if tc_type == "any":
return "required"
elif tc_type == "tool":
return {"type": "function", "name": tool_choice.get("name", "")}
name = tool_choice.get("name", "")
# web_search is a hosted tool, not a function
if name == "web_search":
return {"type": "web_search"}
return {"type": "function", "name": name}
elif tc_type == "none":
return "none"
return "auto"

View file

@ -527,16 +527,16 @@ class TestTranslateToolsToResponsesAPI:
assert "parameters" not in result[0]
def test_web_search_tool_by_name(self):
"""Tool named 'web_search' maps to web_search_preview."""
"""Tool named 'web_search' maps to web_search."""
tools = [{"name": "web_search", "type": "custom"}]
result = _ADAPTER.translate_tools_to_responses_api(tools) # type: ignore[arg-type]
assert result == [{"type": "web_search_preview"}]
assert result == [{"type": "web_search"}]
def test_web_search_tool_by_type_prefix(self):
"""Tool with type starting with 'web_search' maps to web_search_preview."""
"""Tool with type starting with 'web_search' maps to web_search."""
tools = [{"name": "search", "type": "web_search_20250305"}]
result = _ADAPTER.translate_tools_to_responses_api(tools) # type: ignore[arg-type]
assert result == [{"type": "web_search_preview"}]
assert result == [{"type": "web_search"}]
def test_multiple_tools_order_preserved(self):
"""Multiple tools are converted in order."""
@ -548,7 +548,7 @@ class TestTranslateToolsToResponsesAPI:
result = _ADAPTER.translate_tools_to_responses_api(tools) # type: ignore[arg-type]
assert len(result) == 3
assert result[0]["name"] == "tool_a"
assert result[1] == {"type": "web_search_preview"}
assert result[1] == {"type": "web_search"}
assert result[2]["name"] == "tool_b"
def test_empty_tools_list(self):