mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-04 07:27:51 +00:00
refactor(chat): keep responses-mode models on chat when the provider has no Responses API
Decide the chat-to-Responses bridge from the provider's Responses config, so a responses-mode model on a provider without one stays on chat instead of bouncing through responses() and back. That bounce was the only loop _skip_responses_api_bridge guarded, so the flag goes away.
This commit is contained in:
parent
f9b0fb1c3a
commit
165a28ef7b
8 changed files with 133 additions and 93 deletions
|
|
@ -1600,7 +1600,8 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
else translated_finish_reason
|
||||
)
|
||||
# extract usage
|
||||
usage: Final[Usage] = getattr(response, "usage")
|
||||
# A provider reply with no usage block leaves the response without one; it reads as zero tokens.
|
||||
usage: Final[Usage] = getattr(response, "usage", None) or Usage()
|
||||
message_usage: Final = self.translate_openai_usage_to_anthropic_usage(usage)
|
||||
polyfill_iterations: Final = polyfill_result.iterations_usage if polyfill_result is not None else None
|
||||
anthropic_usage: Final[AnthropicUsage] = (
|
||||
|
|
|
|||
|
|
@ -5443,14 +5443,9 @@ def completion(
|
|||
######### unpacking kwargs #####################
|
||||
args: Final = _locals_snapshot(locals())
|
||||
|
||||
# Set by the responses->completion fallback so completion() does not bridge
|
||||
# back to the Responses API: that round-trip mutually recurses forever for a
|
||||
# model whose model_cost mode is "responses" but whose provider has no
|
||||
# Responses API config (get_provider_responses_api_config -> None).
|
||||
skip_responses_api_bridge: Final = kwargs.pop("_skip_responses_api_bridge", False)
|
||||
|
||||
control_options: Final = _resolve_control_options(kwargs, model)
|
||||
skip_mcp_handler: Final = kwargs.pop("_skip_mcp_handler", False)
|
||||
skip_responses_api_bridge: Final = kwargs.pop("_skip_responses_api_bridge", False) is True
|
||||
if not skip_mcp_handler and tools and may_name_litellm_mcp_gateway(tools): # pyright: ignore[reportUnknownArgumentType] # untyped tools list
|
||||
from litellm.responses.mcp.chat_completions_handler import acompletion_with_mcp
|
||||
|
||||
|
|
@ -5638,6 +5633,23 @@ def completion(
|
|||
)
|
||||
|
||||
|
||||
def _bridges_to_responses_api(
|
||||
responses_api_model_info: Mapping[str, object],
|
||||
model: str,
|
||||
custom_llm_provider: str,
|
||||
model_info: object,
|
||||
api_base: str | None,
|
||||
) -> bool:
|
||||
if responses_api_model_info.get("mode") != "responses":
|
||||
return False
|
||||
from litellm.responses.main import resolve_responses_api_provider_config
|
||||
|
||||
return isinstance(
|
||||
plan_chat_route(resolve_responses_api_provider_config(model, custom_llm_provider, model_info, api_base)),
|
||||
NativeResponses,
|
||||
)
|
||||
|
||||
|
||||
def execute_chat(
|
||||
*,
|
||||
model: str,
|
||||
|
|
@ -5645,7 +5657,6 @@ def execute_chat(
|
|||
kwargs: dict, # mutable-ok: provider steps pop request-scoped keys, as in completion()
|
||||
non_default_params: dict[str, object], # mutable-ok: seeded in place before the provider call
|
||||
control_options: ControlOptions,
|
||||
skip_responses_api_bridge: bool,
|
||||
prompt_id: str | None,
|
||||
prompt_variables: dict | None, # mutable-ok: forwarded to get_litellm_params as is
|
||||
timeout: float | str | httpx.Timeout | openai.Timeout | None = None,
|
||||
|
|
@ -5689,7 +5700,9 @@ def execute_chat(
|
|||
api_key: str | None = None,
|
||||
thinking: AnthropicThinkingParam | None = None,
|
||||
shared_session: Optional["ClientSession"] = None,
|
||||
skip_responses_api_bridge: bool = False,
|
||||
) -> ModelResponse | CustomStreamWrapper:
|
||||
"""``skip_responses_api_bridge`` is set by the Responses to chat bridge so a ``mode: responses`` deployment stays on chat."""
|
||||
api_base = kwargs.get("api_base", None)
|
||||
mock_response: Final[MOCK_RESPONSE_TYPE | None] = kwargs.get("mock_response", None)
|
||||
mock_tool_calls: Final = kwargs.get("mock_tool_calls", None)
|
||||
|
|
@ -5913,8 +5926,9 @@ def execute_chat(
|
|||
if dynamic_api_key is not None:
|
||||
api_key = dynamic_api_key
|
||||
# check if user passed in any of the OpenAI optional params
|
||||
bridges_to_responses_api: Final = (
|
||||
responses_api_model_info.get("mode") == "responses" and not skip_responses_api_bridge
|
||||
deployment_model_info: Final = cast(object, kwargs.get("model_info")) # cast-ok: untyped request kwargs
|
||||
bridges_to_responses_api: Final = not skip_responses_api_bridge and _bridges_to_responses_api(
|
||||
responses_api_model_info, model, custom_llm_provider, deployment_model_info, api_base
|
||||
)
|
||||
allowed_openai_params: Final[list[str] | None] = (
|
||||
[*(kwargs.get("allowed_openai_params") or []), "reasoning_effort"]
|
||||
|
|
@ -6188,9 +6202,8 @@ def execute_chat(
|
|||
# detection when the deployment name differs from the model name.
|
||||
_azure_detection_model: Final = base_model or model
|
||||
|
||||
if isinstance(
|
||||
plan_chat_route(responses_api_model_info.get("mode") == "responses", skip_responses_api_bridge is True),
|
||||
NativeResponses,
|
||||
if not skip_responses_api_bridge and _bridges_to_responses_api(
|
||||
responses_api_model_info, model, custom_llm_provider, deployment_model_info, api_base
|
||||
):
|
||||
from litellm.completion_extras import responses_api_bridge
|
||||
|
||||
|
|
@ -6500,12 +6513,13 @@ def execute_chat(
|
|||
|
||||
|
||||
_PLANNED_CHAT_ARGS: Final = frozenset(
|
||||
("kwargs", "non_default_params", "control_options", "skip_responses_api_bridge", "prompt_id", "prompt_variables")
|
||||
("kwargs", "non_default_params", "control_options", "prompt_id", "prompt_variables")
|
||||
)
|
||||
_EXECUTE_CHAT_CODE: Final = execute_chat.__code__
|
||||
_EXECUTE_CHAT_PARAMS: Final = (
|
||||
frozenset(_EXECUTE_CHAT_CODE.co_varnames[: _EXECUTE_CHAT_CODE.co_kwonlyargcount]) - _PLANNED_CHAT_ARGS
|
||||
)
|
||||
_SKIP_RESPONSES_API_BRIDGE_KWARG: Final = "_skip_responses_api_bridge"
|
||||
_CHAT_SURFACE_KWARGS: Final = (
|
||||
"fallbacks",
|
||||
"model_list",
|
||||
|
|
@ -6517,7 +6531,6 @@ _CHAT_SURFACE_KWARGS: Final = (
|
|||
"mock_delay",
|
||||
"mock_timeout",
|
||||
)
|
||||
_SKIP_RESPONSES_API_BRIDGE: Final = "_skip_responses_api_bridge"
|
||||
_TEXT_COMPLETION_PROVIDERS: Final = frozenset(
|
||||
("text-completion-openai", "text-completion-codestral", "text-completion-inception")
|
||||
)
|
||||
|
|
@ -6531,7 +6544,6 @@ class PlannedChat:
|
|||
kwargs: Mapping[str, object]
|
||||
non_default_params: Mapping[str, object]
|
||||
control_options: ControlOptions
|
||||
skip_responses_api_bridge: bool
|
||||
|
||||
|
||||
def _optional_str(value: object) -> str | None:
|
||||
|
|
@ -6604,7 +6616,7 @@ def plan_chat(request: Mapping[str, object], logging_obj: LiteLLMLoggingObj) ->
|
|||
kwargs: Final = {
|
||||
key: value
|
||||
for key, value in request.items()
|
||||
if key not in _EXECUTE_CHAT_PARAMS and key != _SKIP_RESPONSES_API_BRIDGE
|
||||
if key not in _EXECUTE_CHAT_PARAMS and key != _SKIP_RESPONSES_API_BRIDGE_KWARG
|
||||
}
|
||||
tools: Final = validate_and_fix_openai_tools(tools=_object_list(request.get("tools")))
|
||||
messages: Final = get_completion_messages(
|
||||
|
|
@ -6640,12 +6652,12 @@ def plan_chat(request: Mapping[str, object], logging_obj: LiteLLMLoggingObj) ->
|
|||
"tool_choice": tool_choice,
|
||||
"stop": validate_openai_optional_params(stop=_stop(request.get("stop"))),
|
||||
"thinking": validate_and_fix_thinking_param(thinking=_thinking(request.get("thinking"))),
|
||||
"skip_responses_api_bridge": request.get(_SKIP_RESPONSES_API_BRIDGE_KWARG) is True,
|
||||
}
|
||||
),
|
||||
kwargs=MappingProxyType(kwargs),
|
||||
non_default_params=MappingProxyType(non_default_params),
|
||||
control_options=_resolve_control_options(kwargs, model),
|
||||
skip_responses_api_bridge=request.get(_SKIP_RESPONSES_API_BRIDGE) is True,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -6655,7 +6667,6 @@ def _run_planned_chat(plan: PlannedChat, is_async: bool) -> ModelResponse | Cust
|
|||
kwargs={**plan.kwargs, "acompletion": is_async},
|
||||
non_default_params=dict(plan.non_default_params),
|
||||
control_options=plan.control_options,
|
||||
skip_responses_api_bridge=plan.skip_responses_api_bridge,
|
||||
prompt_id=None,
|
||||
prompt_variables=None,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -155,8 +155,6 @@ def plan_responses_route(
|
|||
return NativeResponses()
|
||||
|
||||
|
||||
def plan_chat_route(responses_mode_model: bool, skip_responses_api_bridge: bool) -> NativeChat | NativeResponses:
|
||||
"""``responses_mode_model`` is whether ``responses_api_bridge_check`` resolved the deployment to mode ``responses``."""
|
||||
if responses_mode_model and not skip_responses_api_bridge:
|
||||
return NativeResponses()
|
||||
return NativeChat()
|
||||
def plan_chat_route(responses_api_provider_config: BaseResponsesAPIConfig | None) -> NativeChat | NativeResponses:
|
||||
"""For a deployment ``responses_api_bridge_check`` resolved to mode ``responses``."""
|
||||
return NativeChat() if responses_api_provider_config is None else NativeResponses()
|
||||
|
|
|
|||
|
|
@ -66,6 +66,14 @@ def test_translate_openai_response_to_anthropic_empty_choices() -> None:
|
|||
assert result["usage"]["input_tokens"] == 10
|
||||
|
||||
|
||||
def test_translate_openai_response_to_anthropic_without_usage_reports_zero_tokens() -> None:
|
||||
response: Final = ModelResponse(choices=[Choices(message=Message(content="x"), finish_reason="stop")])
|
||||
|
||||
result: Final = LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic(response)
|
||||
|
||||
assert (result["usage"]["input_tokens"], result["usage"]["output_tokens"]) == (0, 0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text,count,expected_stop", [
|
||||
("", 1, "compaction"), (None, 1, "compaction"), ("Answer", 1, "max_tokens"),
|
||||
(" ", 1, "max_tokens"), ("", 2, "max_tokens"), ("", 0, "max_tokens"),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -93,3 +94,44 @@ async def test_a_provider_failure_on_the_messages_to_chat_bridge_is_mapped(respx
|
|||
await _router({"model": "groq/llama-3.1-8b-instant"}, {"mode": "responses"}).aanthropic_messages(
|
||||
model="m", max_tokens=32, messages=[{"role": "user", "content": "hi"}]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_responses_request_on_a_chat_api_deployment_stays_on_chat_despite_mode_responses(
|
||||
respx_mock: respx.MockRouter,
|
||||
):
|
||||
chat: Final = respx_mock.post(f"{API_BASE}/chat/completions").mock(
|
||||
return_value=httpx.Response(200, json=_CHAT_REPLY)
|
||||
)
|
||||
|
||||
await _router({"model": "openai/gpt-4.1", "use_chat_completions_api": True}, {"mode": "responses"}).aresponses(
|
||||
model="m", input="hi"
|
||||
)
|
||||
|
||||
assert chat.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_skip_bridge_flag_is_never_sent_to_the_provider(respx_mock: respx.MockRouter):
|
||||
chat: Final = respx_mock.post(f"{API_BASE}/chat/completions").mock(
|
||||
return_value=httpx.Response(200, json=_CHAT_REPLY)
|
||||
)
|
||||
|
||||
await _router({"model": "openai/gpt-4.1"}).acompletion(
|
||||
model="m", messages=[{"role": "user", "content": "hi"}], _skip_responses_api_bridge=True
|
||||
)
|
||||
|
||||
assert "_skip_responses_api_bridge" not in json.loads(chat.calls.last.request.content)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_skip_bridge_flag_keeps_a_mode_responses_deployment_on_chat(respx_mock: respx.MockRouter):
|
||||
chat: Final = respx_mock.post(f"{API_BASE}/chat/completions").mock(
|
||||
return_value=httpx.Response(200, json=_CHAT_REPLY)
|
||||
)
|
||||
|
||||
await _router({"model": "openai/gpt-4.1"}, {"mode": "responses"}).acompletion(
|
||||
model="m", messages=[{"role": "user", "content": "hi"}], _skip_responses_api_bridge=True
|
||||
)
|
||||
|
||||
assert chat.call_count == 1
|
||||
|
|
|
|||
|
|
@ -84,15 +84,11 @@ def test_responses_reach_chat_without_a_provider_config_or_when_asked(
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("responses_mode_model", "skip_responses_api_bridge", "expected"),
|
||||
("has_config", "expected"),
|
||||
[
|
||||
(True, False, NativeResponses()),
|
||||
(True, True, NativeChat()),
|
||||
(False, False, NativeChat()),
|
||||
(False, True, NativeChat()),
|
||||
(True, NativeResponses()),
|
||||
(False, NativeChat()),
|
||||
],
|
||||
)
|
||||
def test_chat_bridges_to_responses_only_for_responses_mode_models(
|
||||
responses_mode_model: bool, skip_responses_api_bridge: bool, expected: RoutePlan
|
||||
):
|
||||
assert plan_chat_route(responses_mode_model, skip_responses_api_bridge) == expected
|
||||
def test_chat_bridges_to_responses_only_when_the_provider_serves_it(has_config: bool, expected: RoutePlan):
|
||||
assert plan_chat_route(OpenAIResponsesAPIConfig() if has_config else None) == expected
|
||||
|
|
|
|||
|
|
@ -1,17 +1,3 @@
|
|||
"""Regression tests for the responses -> completion fallback bridge guard.
|
||||
|
||||
When the Responses API falls back to chat completions (no native responses
|
||||
config), it must tag the forwarded ``litellm.completion`` / ``litellm.acompletion``
|
||||
call with ``_skip_responses_api_bridge=True`` so ``completion()`` does not bridge
|
||||
the request straight back to the Responses API and mutually recurse forever.
|
||||
|
||||
Both fallback paths are covered: the sync ``response_api_handler`` (``_is_async``
|
||||
False) and the async ``async_response_api_handler`` (``_is_async`` True). The
|
||||
module-level ``litellm.completion`` / ``litellm.acompletion`` are patched to
|
||||
capture the forwarded kwargs; if the flag-setting line is removed the captured
|
||||
kwargs lack the flag and these tests fail.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from types import SimpleNamespace
|
||||
|
|
@ -35,50 +21,6 @@ class _StopForwarding(Exception):
|
|||
"""Raised by the mocked (a)completion once the forwarded kwargs are captured."""
|
||||
|
||||
|
||||
def test_sync_fallback_tags_skip_responses_api_bridge():
|
||||
handler = LiteLLMCompletionTransformationHandler()
|
||||
captured: dict = {}
|
||||
|
||||
def fake_completion(**kwargs):
|
||||
captured.update(kwargs)
|
||||
raise _StopForwarding()
|
||||
|
||||
with patch("litellm.completion", fake_completion):
|
||||
with pytest.raises(_StopForwarding):
|
||||
handler.response_api_handler(
|
||||
model="gpt-4o",
|
||||
input="hello",
|
||||
responses_api_request={},
|
||||
custom_llm_provider="openai",
|
||||
_is_async=False,
|
||||
)
|
||||
|
||||
assert captured.get("_skip_responses_api_bridge") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_fallback_tags_skip_responses_api_bridge():
|
||||
handler = LiteLLMCompletionTransformationHandler()
|
||||
captured: dict = {}
|
||||
|
||||
async def fake_acompletion(**kwargs):
|
||||
captured.update(kwargs)
|
||||
raise _StopForwarding()
|
||||
|
||||
with patch("litellm.acompletion", fake_acompletion):
|
||||
coro = handler.response_api_handler(
|
||||
model="gpt-4o",
|
||||
input="hello",
|
||||
responses_api_request={},
|
||||
custom_llm_provider="openai",
|
||||
_is_async=True,
|
||||
)
|
||||
with pytest.raises(_StopForwarding):
|
||||
await coro
|
||||
|
||||
assert captured.get("_skip_responses_api_bridge") is True
|
||||
|
||||
|
||||
_CODEX_ADDITIONAL_TOOLS_ITEM = {
|
||||
"type": "additional_tools",
|
||||
"id": "at_codex",
|
||||
|
|
|
|||
|
|
@ -49,7 +49,14 @@ from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
|||
from litellm.types.litellm_params import ControlOptions
|
||||
from litellm.types.llms.openai import AllMessageValues, HttpxBinaryResponseContent
|
||||
from litellm.types.prompts.init_prompts import PromptSpec
|
||||
from litellm.types.utils import Delta, ModelResponseStream, StandardCallbackDynamicParams, StreamingChoices, Usage
|
||||
from litellm.types.utils import (
|
||||
CallTypes,
|
||||
Delta,
|
||||
ModelResponseStream,
|
||||
StandardCallbackDynamicParams,
|
||||
StreamingChoices,
|
||||
Usage,
|
||||
)
|
||||
from tests.unit.llms.bedrock.slow_upstream import (
|
||||
STREAM_TIMEOUT_SECONDS,
|
||||
UPSTREAM_FIRST_BYTE_SECONDS,
|
||||
|
|
@ -446,6 +453,42 @@ def _settled_success_calls(counter: _SyncSuccessCounter, expected: int) -> int:
|
|||
return len(counter.calls)
|
||||
|
||||
|
||||
class _DeploymentHookCounter(CustomLogger):
|
||||
call_types: tuple[str, ...] = ()
|
||||
|
||||
async def async_pre_call_deployment_hook(self, kwargs: dict[str, object], call_type: CallTypes | None) -> None:
|
||||
self.call_types = (*self.call_types, str(call_type))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_responses_mode_model_without_a_responses_api_stays_on_chat(
|
||||
respx_mock: respx.MockRouter, openai_api_response: dict[str, object], monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
counter: Final = _DeploymentHookCounter()
|
||||
monkeypatch.setattr(litellm, "callbacks", [counter])
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
litellm.register_model(
|
||||
{"groq/responses-only-model": {"litellm_provider": "groq", "mode": "responses", "max_tokens": 100}}
|
||||
)
|
||||
api_base: Final = "https://example.invalid/openai/v1"
|
||||
chat_route: Final = respx_mock.post(f"{api_base}/chat/completions").mock(
|
||||
return_value=httpx.Response(status_code=200, json=openai_api_response)
|
||||
)
|
||||
|
||||
response: Final = await litellm.acompletion(
|
||||
model="groq/responses-only-model",
|
||||
api_base=api_base,
|
||||
api_key="sk-x",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
|
||||
assert isinstance(response, litellm.ModelResponse)
|
||||
assert response.id == openai_api_response["id"]
|
||||
assert chat_route.call_count == 1
|
||||
assert counter.call_types == ("CallTypes.acompletion",)
|
||||
|
||||
|
||||
def test_execute_chat_calls_the_provider_without_logging(
|
||||
respx_mock: respx.MockRouter, openai_api_response, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
|
|
@ -472,7 +515,6 @@ def test_execute_chat_calls_the_provider_without_logging(
|
|||
kwargs={"api_base": api_base, "api_key": "sk-x", "litellm_logging_obj": logging_obj},
|
||||
non_default_params={},
|
||||
control_options=ControlOptions(),
|
||||
skip_responses_api_bridge=False,
|
||||
prompt_id=None,
|
||||
prompt_variables=None,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue