From c711540edf79a8e01f2b6c504510c1d80c22a7d4 Mon Sep 17 00:00:00 2001 From: Filippo Mattia Menghi Date: Fri, 22 May 2026 12:19:57 +0200 Subject: [PATCH 1/4] Forward custom_llm_provider through the Responses API bridge (Fixes #28505) When a Chat Completions request to a GPT-5.4+ model contains both `tools` and `reasoning_effort`, `completion()` auto-routes through `responses_api_bridge`. The bridge handler called `litellm.responses()` / `litellm.aresponses()` without forwarding the already-resolved `custom_llm_provider`, so the downstream call re-invoked `get_llm_provider()` with `custom_llm_provider=None` and stripped a second provider prefix from a `provider/provider/model` deployment string. For a deployment configured as `openai/openai/openai/gpt-5.5`, the bridge flow sent `openai/gpt-5.5` to the upstream API instead of the correct `openai/openai/gpt-5.5`. Upstream APIs that enforce model-name allow-lists rejected this as `key_model_access_denied`. Fix: pass the locally-resolved `custom_llm_provider` into both the sync `responses()` and async `aresponses()` calls so the downstream `_resolve_model_provider_for_responses` sees an explicit provider and skips the second prefix-strip. New regression test `tests/test_litellm/completion_extras/test_responses_bridge_provider_propagation.py` pins both call sites: each must forward `custom_llm_provider`. --- .../handler.py | 8 ++ .../completion_extras/__init__.py | 0 ...t_responses_bridge_provider_propagation.py | 106 ++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 tests/test_litellm/completion_extras/__init__.py create mode 100644 tests/test_litellm/completion_extras/test_responses_bridge_provider_propagation.py diff --git a/litellm/completion_extras/litellm_responses_transformation/handler.py b/litellm/completion_extras/litellm_responses_transformation/handler.py index 2de7bda6467..62891f2abdb 100644 --- a/litellm/completion_extras/litellm_responses_transformation/handler.py +++ b/litellm/completion_extras/litellm_responses_transformation/handler.py @@ -182,8 +182,12 @@ def completion(self, *args, **kwargs) -> Union[ client=kwargs.get("client"), ) + # Pass the resolved provider through to `responses()` so it doesn't + # re-run `get_llm_provider()` on the model string and strip a + # second provider prefix (see GitHub issue #28505). result = responses( **request_data, + custom_llm_provider=custom_llm_provider, ) from litellm.types.utils import ModelResponse @@ -268,9 +272,13 @@ async def acompletion( except Exception as e: raise e + # Pass the resolved provider through to `aresponses()` so it doesn't + # re-run `get_llm_provider()` on the model string and strip a + # second provider prefix (see GitHub issue #28505). result = await aresponses( **request_data, aresponses=True, + custom_llm_provider=custom_llm_provider, ) from litellm.types.utils import ModelResponse diff --git a/tests/test_litellm/completion_extras/__init__.py b/tests/test_litellm/completion_extras/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/completion_extras/test_responses_bridge_provider_propagation.py b/tests/test_litellm/completion_extras/test_responses_bridge_provider_propagation.py new file mode 100644 index 00000000000..b6cc30ad377 --- /dev/null +++ b/tests/test_litellm/completion_extras/test_responses_bridge_provider_propagation.py @@ -0,0 +1,106 @@ +""" +Regression test for https://github.com/BerriAI/litellm/issues/28505 - +the Responses API bridge double-strips the provider prefix from the +model name when a Chat Completions request has both `tools` and +`reasoning_effort`. + +Root cause: the bridge handler called `litellm.responses()` / +`litellm.aresponses()` without passing the already-resolved +`custom_llm_provider`. The downstream call then re-invoked +`get_llm_provider()` with `custom_llm_provider=None`, which stripped +a second provider prefix from a `provider/provider/model` deployment +string. + +This test pins both the sync and async bridge handler call sites: +the resolved `custom_llm_provider` must be forwarded to the underlying +`responses` / `aresponses` call so the provider isn't re-detected. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from litellm.completion_extras.litellm_responses_transformation.handler import ( + ResponsesToCompletionBridgeHandler, +) + + +def _validated_kwargs(): + return { + "model": "openai/openai/openai/gpt-5.5", + "messages": [{"role": "user", "content": "hi"}], + "optional_params": {}, + "litellm_params": {}, + "headers": {}, + "model_response": MagicMock(), + "logging_obj": MagicMock(), + "custom_llm_provider": "openai", + } + + +def test_sync_completion_forwards_custom_llm_provider(): + handler = ResponsesToCompletionBridgeHandler() + handler.transformation_handler = MagicMock() + handler.transformation_handler.transform_request.return_value = { + "model": "openai/openai/openai/gpt-5.5", + "input": [], + } + handler.transformation_handler.transform_response.return_value = ( + _validated_kwargs()["model_response"] + ) + with ( + patch.object( + handler, "validate_input_kwargs", return_value=_validated_kwargs() + ), + patch( + "litellm.responses", + return_value=MagicMock(spec=[]), + ) as mock_responses, + ): + # The handler routes ResponsesAPIResponse through transform_response. + # We just want to verify the kwargs going INTO responses(). + try: + handler.completion(acompletion=False) + except Exception: + # Downstream handling (transform_response, type checks) is not + # the subject of this test. + pass + assert mock_responses.called + kwargs = mock_responses.call_args.kwargs + assert kwargs.get("custom_llm_provider") == "openai", ( + "sync bridge must forward custom_llm_provider to litellm.responses() " + "so the downstream get_llm_provider() call does not re-strip the " + "provider prefix on a provider/provider/model deployment string" + ) + + +@pytest.mark.asyncio +async def test_async_completion_forwards_custom_llm_provider(): + handler = ResponsesToCompletionBridgeHandler() + handler.transformation_handler = MagicMock() + handler.transformation_handler.transform_request.return_value = { + "model": "openai/openai/openai/gpt-5.5", + "input": [], + } + + async def _fake_aresponses(**kwargs): + _fake_aresponses.kwargs = kwargs + return MagicMock(spec=[]) + + _fake_aresponses.kwargs = {} + + with ( + patch.object( + handler, "validate_input_kwargs", return_value=_validated_kwargs() + ), + patch("litellm.aresponses", _fake_aresponses), + ): + try: + await handler.acompletion() + except Exception: + pass + assert _fake_aresponses.kwargs.get("custom_llm_provider") == "openai", ( + "async bridge must forward custom_llm_provider to litellm.aresponses() " + "so the downstream get_llm_provider() call does not re-strip the " + "provider prefix on a provider/provider/model deployment string" + ) From b638892e32ebc673f0d0513d3305ccecae7d30c3 Mon Sep 17 00:00:00 2001 From: Filippo Mattia Menghi Date: Mon, 25 May 2026 09:22:09 +0200 Subject: [PATCH 2/4] fix(28505): set custom_llm_provider on request_data instead of as duplicate kwarg Greptile flagged that the previous patch passed custom_llm_provider as an explicit kwarg to responses()/aresponses() while request_data already carried it via the spread of sanitized_litellm_params, which would raise TypeError: got multiple values for keyword argument on every real bridge call. Switches to assigning request_data['custom_llm_provider'] before the call so the resolved provider wins over whatever sanitized_litellm_params spread in, without duplicating the kwarg. Updates the regression test to seed request_data with a sentinel custom_llm_provider so it actually exercises the overwrite path (the previous test mocked transform_request with a minimal dict and never hit the conflict). --- .../handler.py | 23 ++++++++++++------- ...t_responses_bridge_provider_propagation.py | 10 ++++++++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/handler.py b/litellm/completion_extras/litellm_responses_transformation/handler.py index 62891f2abdb..87c26b776e8 100644 --- a/litellm/completion_extras/litellm_responses_transformation/handler.py +++ b/litellm/completion_extras/litellm_responses_transformation/handler.py @@ -182,12 +182,16 @@ def completion(self, *args, **kwargs) -> Union[ client=kwargs.get("client"), ) - # Pass the resolved provider through to `responses()` so it doesn't - # re-run `get_llm_provider()` on the model string and strip a - # second provider prefix (see GitHub issue #28505). + # Pin the resolved provider so `responses()` doesn't re-run + # `get_llm_provider()` on the model string and strip a second + # provider prefix (see GitHub issue #28505). request_data already + # carries `custom_llm_provider` via the spread of + # `sanitized_litellm_params`; overwriting it on the dict (rather + # than adding an explicit kwarg) avoids the duplicate-keyword + # TypeError that would otherwise fire on the real bridge path. + request_data["custom_llm_provider"] = custom_llm_provider result = responses( **request_data, - custom_llm_provider=custom_llm_provider, ) from litellm.types.utils import ModelResponse @@ -272,13 +276,16 @@ async def acompletion( except Exception as e: raise e - # Pass the resolved provider through to `aresponses()` so it doesn't - # re-run `get_llm_provider()` on the model string and strip a - # second provider prefix (see GitHub issue #28505). + # Pin the resolved provider so `aresponses()` doesn't re-run + # `get_llm_provider()` on the model string and strip a second + # provider prefix (see GitHub issue #28505). Set on request_data + # rather than passed as a separate kwarg to avoid the duplicate- + # keyword TypeError when `sanitized_litellm_params` already + # carries `custom_llm_provider`. + request_data["custom_llm_provider"] = custom_llm_provider result = await aresponses( **request_data, aresponses=True, - custom_llm_provider=custom_llm_provider, ) from litellm.types.utils import ModelResponse diff --git a/tests/test_litellm/completion_extras/test_responses_bridge_provider_propagation.py b/tests/test_litellm/completion_extras/test_responses_bridge_provider_propagation.py index b6cc30ad377..b41dbd54b85 100644 --- a/tests/test_litellm/completion_extras/test_responses_bridge_provider_propagation.py +++ b/tests/test_litellm/completion_extras/test_responses_bridge_provider_propagation.py @@ -44,6 +44,11 @@ def test_sync_completion_forwards_custom_llm_provider(): handler.transformation_handler.transform_request.return_value = { "model": "openai/openai/openai/gpt-5.5", "input": [], + # `_build_sanitized_litellm_params` spreads `custom_llm_provider` from + # `litellm_params` into request_data on the real bridge path. Seed + # it here so the test exercises the overwrite (not an explicit kwarg + # that would TypeError against an already-present key). + "custom_llm_provider": "should-be-overwritten", } handler.transformation_handler.transform_response.return_value = ( _validated_kwargs()["model_response"] @@ -81,6 +86,11 @@ async def test_async_completion_forwards_custom_llm_provider(): handler.transformation_handler.transform_request.return_value = { "model": "openai/openai/openai/gpt-5.5", "input": [], + # `_build_sanitized_litellm_params` spreads `custom_llm_provider` from + # `litellm_params` into request_data on the real bridge path. Seed + # it here so the test exercises the overwrite (not an explicit kwarg + # that would TypeError against an already-present key). + "custom_llm_provider": "should-be-overwritten", } async def _fake_aresponses(**kwargs): From 24732c418adbbf171a0f0cde53a449abe629f1f0 Mon Sep 17 00:00:00 2001 From: Filippo Mattia Menghi Date: Mon, 25 May 2026 13:29:13 +0200 Subject: [PATCH 3/4] chore: trigger shin-agent re-eval on retargeted staging base From 384a10a1e518f74a2cf5a66fd0280b955ed075f9 Mon Sep 17 00:00:00 2001 From: Filippo Mattia Menghi Date: Mon, 25 May 2026 13:34:40 +0200 Subject: [PATCH 4/4] chore: trigger shin-agent re-eval against updated Greptile state