From f6b03a469e8ea00b1ed9cbba408cfca7f8374cb5 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 30 Mar 2026 16:38:52 +0530 Subject: [PATCH 1/5] feat(responses): add use_responses_api_bridge flag for openai/ models with custom api_base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows openai/-prefixed models with a custom api_base pointing to a third-party OpenAI-compatible provider to opt-in to the /responses → /chat/completions bridge, rather than forwarding requests natively to /v1/responses (which may not be supported by the provider). Co-Authored-By: Claude Sonnet 4.6 --- litellm/responses/main.py | 4 +- litellm/types/router.py | 3 + .../test_responses_api_bridge_flag.py | 107 ++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 tests/test_litellm/responses/test_responses_api_bridge_flag.py diff --git a/litellm/responses/main.py b/litellm/responses/main.py index c82574278b..1e97951c50 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -754,6 +754,7 @@ def responses( litellm_logging_obj: LiteLLMLoggingObj = kwargs.get("litellm_logging_obj") # type: ignore litellm_call_id: Optional[str] = kwargs.get("litellm_call_id", None) _is_async = kwargs.pop("aresponses", False) is True + use_responses_api_bridge = kwargs.pop("use_responses_api_bridge", None) # Convert text_format to text parameter if provided text = ResponsesAPIRequestUtils.convert_text_format_to_text_param( @@ -871,6 +872,7 @@ def responses( if _has_file_search_tool(tools) and ( responses_api_provider_config is None + or use_responses_api_bridge is True or not responses_api_provider_config.supports_native_file_search() ): from litellm.responses.file_search.emulated_handler import ( @@ -919,7 +921,7 @@ def responses( **emulated_kwargs, ) - if responses_api_provider_config is None: + if responses_api_provider_config is None or use_responses_api_bridge is True: return litellm_completion_transformation_handler.response_api_handler( model=model, input=input, diff --git a/litellm/types/router.py b/litellm/types/router.py index 4257628e7c..d608f30249 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -199,6 +199,7 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): budget_duration: Optional[str] = None use_in_pass_through: Optional[bool] = False use_litellm_proxy: Optional[bool] = False + use_responses_api_bridge: Optional[bool] = None model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) merge_reasoning_content_in_choices: Optional[bool] = False model_info: Optional[Dict] = None @@ -318,6 +319,8 @@ class LiteLLMParamsTypedDict(TypedDict, total=False): configurable_clientside_auth_params: CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS # for allowing api base switching on finetuned models ## DROP PARAMS ## drop_params: Optional[bool] + ## RESPONSES API BRIDGE ## + use_responses_api_bridge: Optional[bool] ## UNIFIED PROJECT/REGION ## region_name: Optional[str] ## VERTEX AI ## diff --git a/tests/test_litellm/responses/test_responses_api_bridge_flag.py b/tests/test_litellm/responses/test_responses_api_bridge_flag.py new file mode 100644 index 0000000000..51c6ea58f3 --- /dev/null +++ b/tests/test_litellm/responses/test_responses_api_bridge_flag.py @@ -0,0 +1,107 @@ +""" +Tests for the `use_responses_api_bridge` flag that allows openai/ models +with custom api_base to opt-in to the /responses → /chat/completions bridge. +""" + +import os +import sys +from unittest.mock import MagicMock, patch + +sys.path.insert( + 0, os.path.abspath("../../..") +) # Adds the parent directory to the system path + +import litellm + + +class TestUseResponsesApiBridgeFlag: + """Test that use_responses_api_bridge forces the chat completions bridge.""" + + @patch( + "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" + ) + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + def test_bridge_used_when_flag_is_true(self, mock_get_config, mock_bridge_handler): + """When use_responses_api_bridge=True, the bridge handler should be called + even though the provider (openai) has native responses API support.""" + # Setup: provider config returns a non-None config (native support exists) + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + + mock_bridge_handler.return_value = MagicMock() + + litellm.responses( + model="openai/my-custom-model", + input="Hello", + use_responses_api_bridge=True, + litellm_logging_obj=MagicMock(), + ) + + mock_bridge_handler.assert_called_once() + + @patch("litellm.responses.main.base_llm_http_handler.response_api_handler") + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + def test_native_forwarding_when_flag_absent( + self, mock_get_config, mock_native_handler + ): + """When use_responses_api_bridge is not set, openai/ models should use + native responses API forwarding (existing behavior).""" + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + mock_native_handler.return_value = MagicMock() + + litellm.responses( + model="openai/gpt-4o", + input="Hello", + litellm_logging_obj=MagicMock(), + ) + + mock_native_handler.assert_called_once() + + @patch( + "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" + ) + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + def test_flag_does_not_leak_into_kwargs(self, mock_get_config, mock_bridge_handler): + """The use_responses_api_bridge flag should be popped from kwargs and not + passed through to the bridge handler.""" + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + mock_bridge_handler.return_value = MagicMock() + + litellm.responses( + model="openai/my-custom-model", + input="Hello", + use_responses_api_bridge=True, + litellm_logging_obj=MagicMock(), + ) + + call_kwargs = mock_bridge_handler.call_args + # The flag should not appear in the kwargs passed to the bridge handler + all_kwargs = call_kwargs.kwargs if call_kwargs.kwargs else {} + assert "use_responses_api_bridge" not in all_kwargs + + @patch( + "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" + ) + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + def test_bridge_used_when_provider_config_none( + self, mock_get_config, mock_bridge_handler + ): + """When the provider has no native responses API config (returns None), + the bridge should be used regardless of the flag (existing behavior).""" + mock_get_config.return_value = None + mock_bridge_handler.return_value = MagicMock() + + litellm.responses( + model="anthropic/claude-3-haiku", + input="Hello", + litellm_logging_obj=MagicMock(), + ) + + mock_bridge_handler.assert_called_once() From de6fb5895f57bb82c231b33d7db3913056fd6a7a Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 30 Mar 2026 16:41:58 +0530 Subject: [PATCH 2/5] docs(responses): add use_responses_api_bridge opt-in bridge docs --- docs/my-website/docs/response_api.md | 58 ++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/docs/my-website/docs/response_api.md b/docs/my-website/docs/response_api.md index 0c428000c7..36c9ee1351 100644 --- a/docs/my-website/docs/response_api.md +++ b/docs/my-website/docs/response_api.md @@ -1505,6 +1505,64 @@ curl http://localhost:4000/v1/responses \ +### Opt-in bridge for `openai/` models with custom `api_base` + +If you're using an **OpenAI-compatible third-party provider** (e.g. llama.cpp, vLLM, LM Studio) via `openai/` prefix with a custom `api_base`, LiteLLM will normally forward `/responses` requests directly to that endpoint. If the provider only supports `/chat/completions`, the request will fail. + +Set `use_responses_api_bridge: true` to force the `/responses` → `/chat/completions` bridge for these models. + +#### Python SDK Usage + +```python showLineNumbers title="Force bridge for custom openai/ endpoint" +import litellm + +response = litellm.responses( + model="openai/my-custom-model", + input="Hello!", + api_base="http://localhost:8080", + api_key="fake-key", + use_responses_api_bridge=True, +) + +print(response) +``` + +#### LiteLLM Proxy Usage + +**Setup Config:** + +```yaml showLineNumbers title="config.yaml — bridge for custom openai/ endpoint" +model_list: +- model_name: my-local-model + litellm_params: + model: openai/my-custom-model + api_base: http://localhost:8080/v1 + api_key: fake-key + use_responses_api_bridge: true +``` + +**Start Proxy:** + +```bash showLineNumbers title="Start LiteLLM Proxy" +litellm --config /path/to/config.yaml + +# RUNNING on http://0.0.0.0:4000 +``` + +**Make Request:** + +```bash showLineNumbers title="Request via bridge" +curl http://localhost:4000/v1/responses \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer sk-1234" \ + -d '{ + "model": "my-local-model", + "input": "Hello!" + }' +``` + +This is particularly useful when connecting clients that hardcode the `/responses` endpoint (e.g. OpenAI Codex CLI with `wire_api = "responses"`) to local or third-party OpenAI-compatible providers that only expose `/chat/completions`. + ## Server-side compaction For long-running conversations, you can enable **server-side compaction** so that when the rendered context size crosses a threshold, the server automatically runs compaction in-stream and emits a compaction item—no separate `POST /v1/responses/compact` call is required. From 6b7629ec045e670fa5050aa2dc1381f607035bda Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Wed, 8 Apr 2026 22:00:30 +0530 Subject: [PATCH 3/5] Fix greptile review --- litellm/responses/main.py | 1 + .../test_responses_api_bridge_flag.py | 155 ++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 1e97951c50..80bd319569 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -907,6 +907,7 @@ def responses( "extra_body": extra_body, "timeout": timeout, "custom_llm_provider": custom_llm_provider, + **({"use_responses_api_bridge": True} if use_responses_api_bridge else {}), **{k: v for k, v in kwargs.items() if k not in _internal_skip}, } if _is_async: diff --git a/tests/test_litellm/responses/test_responses_api_bridge_flag.py b/tests/test_litellm/responses/test_responses_api_bridge_flag.py index 51c6ea58f3..727692d55b 100644 --- a/tests/test_litellm/responses/test_responses_api_bridge_flag.py +++ b/tests/test_litellm/responses/test_responses_api_bridge_flag.py @@ -1,6 +1,9 @@ """ Tests for the `use_responses_api_bridge` flag that allows openai/ models with custom api_base to opt-in to the /responses → /chat/completions bridge. + +Includes file_search emulation: the flag must be forwarded on inner aresponses +calls so routed requests do not hit a custom api_base /v1/responses endpoint. """ import os @@ -12,6 +15,7 @@ sys.path.insert( ) # Adds the parent directory to the system path import litellm +from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse class TestUseResponsesApiBridgeFlag: @@ -105,3 +109,154 @@ class TestUseResponsesApiBridgeFlag: ) mock_bridge_handler.assert_called_once() + + @patch("litellm.responses.file_search.emulated_handler._call_aresponses") + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + async def test_bridge_flag_forwarded_to_file_search_emulation( + self, mock_get_config, mock_call_aresponses + ): + """When use_responses_api_bridge=True and file_search tool is present, + the flag should be forwarded to the inner aresponses call in the + file_search emulation path.""" + # Setup: provider has native responses API support + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + + # Mock the inner aresponses call to return a valid response + mock_response = ResponsesAPIResponse( + id="resp_123", + model="openai/my-custom-model", + created_at=1234567890, + output=[ + {"type": "message", "content": [{"type": "text", "text": "Answer"}]} + ], + usage=ResponseAPIUsage( + input_tokens=10, output_tokens=5, total_tokens=15 + ), + ) + mock_call_aresponses.return_value = mock_response + + await litellm.aresponses( + model="openai/my-custom-model", + input="Search for information", + tools=[{"type": "file_search"}], + use_responses_api_bridge=True, + litellm_logging_obj=MagicMock(), + ) + + # Verify _call_aresponses was called with use_responses_api_bridge=True + mock_call_aresponses.assert_called_once() + call_kwargs = mock_call_aresponses.call_args.kwargs + assert ( + call_kwargs.get("use_responses_api_bridge") is True + ), "use_responses_api_bridge flag should be forwarded to inner aresponses call" + + @patch( + "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" + ) + @patch("litellm.vector_stores.main.asearch") + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + async def test_bridge_flag_prevents_native_responses_endpoint_call( + self, mock_get_config, mock_asearch, mock_bridge_handler + ): + """ + Concrete failing scenario: native OpenAI responses config + bridge flag + + file_search → emulation must still route inner calls through the bridge + (chat completions), not POST to api_base /v1/responses. + """ + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + mock_asearch.return_value = [] + + first_response = ResponsesAPIResponse( + id="resp_first", + model="openai/my-local-model", + created_at=1234567890, + output=[ + { + "type": "function_call", + "name": "litellm_file_search", + "call_id": "call_123", + "arguments": '{"queries": ["test query"]}', + } + ], + usage=ResponseAPIUsage( + input_tokens=10, output_tokens=5, total_tokens=15 + ), + ) + second_response = ResponsesAPIResponse( + id="resp_second", + model="openai/my-local-model", + created_at=1234567891, + output=[ + { + "type": "message", + "content": [{"type": "text", "text": "Final answer"}], + } + ], + usage=ResponseAPIUsage( + input_tokens=20, output_tokens=10, total_tokens=30 + ), + ) + mock_bridge_handler.side_effect = [first_response, second_response] + + result = await litellm.aresponses( + model="openai/my-local-model", + input="Search for information", + tools=[ + { + "type": "file_search", + "file_search": {"vector_store_ids": ["vs_123"]}, + } + ], + use_responses_api_bridge=True, + api_base="http://localhost:8080/v1", + litellm_logging_obj=MagicMock(), + ) + + assert mock_bridge_handler.call_count == 2, ( + "Bridge handler should be called twice: initial function-tool call " + "and follow-up with tool results" + ) + for call in mock_bridge_handler.call_args_list: + all_kwargs = call.kwargs if call.kwargs else {} + assert "use_responses_api_bridge" not in all_kwargs + assert result is not None + assert result.id is not None + + @patch("litellm.responses.main.base_llm_http_handler.response_api_handler") + @patch("litellm.vector_stores.main.asearch") + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + async def test_without_bridge_flag_uses_native_endpoint( + self, mock_get_config, mock_asearch, mock_native_handler + ): + """Without the bridge flag, openai/ with native config uses the native handler.""" + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + mock_asearch.return_value = [] + mock_native_handler.return_value = ResponsesAPIResponse( + id="resp_native", + model="openai/gpt-4o", + created_at=1234567890, + output=[ + { + "type": "message", + "content": [{"type": "text", "text": "Native response"}], + } + ], + usage=ResponseAPIUsage( + input_tokens=10, output_tokens=5, total_tokens=15 + ), + ) + + result = await litellm.aresponses( + model="openai/gpt-4o", + input="Hello", + litellm_logging_obj=MagicMock(), + ) + + mock_native_handler.assert_called_once() + assert result is not None From 0a8bf4ec9e7c98b70fffa770d54fe2d9035d9171 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 13 Apr 2026 18:03:27 +0530 Subject: [PATCH 4/5] feat(responses): rename bridge opt-in to use_chat_completions_api - Add use_chat_completions_api (keep use_responses_api_bridge as deprecated alias) - Support openai/chat_completions/ model prefix for the same behavior - Forward use_chat_completions_api in file_search emulation inner calls - Update response_api.md and extend unit tests Made-with: Cursor --- docs/my-website/docs/response_api.md | 29 +++++++-- litellm/responses/main.py | 44 +++++++++++-- litellm/types/router.py | 6 +- .../test_responses_api_bridge_flag.py | 65 ++++++++++++++++--- 4 files changed, 124 insertions(+), 20 deletions(-) diff --git a/docs/my-website/docs/response_api.md b/docs/my-website/docs/response_api.md index 36c9ee1351..94f5c3e52f 100644 --- a/docs/my-website/docs/response_api.md +++ b/docs/my-website/docs/response_api.md @@ -1509,11 +1509,15 @@ curl http://localhost:4000/v1/responses \ If you're using an **OpenAI-compatible third-party provider** (e.g. llama.cpp, vLLM, LM Studio) via `openai/` prefix with a custom `api_base`, LiteLLM will normally forward `/responses` requests directly to that endpoint. If the provider only supports `/chat/completions`, the request will fail. -Set `use_responses_api_bridge: true` to force the `/responses` → `/chat/completions` bridge for these models. +Use any of these to force the `/responses` → `/chat/completions` bridge: + +1. **`use_chat_completions_api: true`** (recommended) — makes it explicit that LiteLLM will call the provider’s chat-completions API. +2. **`openai/chat_completions/`** — same pattern as `responses/` on chat completions: the model id encodes the routing choice. +3. **`use_responses_api_bridge: true`** — deprecated alias for `use_chat_completions_api` (kept for backward compatibility). #### Python SDK Usage -```python showLineNumbers title="Force bridge for custom openai/ endpoint" +```python showLineNumbers title="Force bridge for custom openai/ endpoint (flag)" import litellm response = litellm.responses( @@ -1521,7 +1525,22 @@ response = litellm.responses( input="Hello!", api_base="http://localhost:8080", api_key="fake-key", - use_responses_api_bridge=True, + use_chat_completions_api=True, +) + +print(response) +``` + +Or encode it in the model id: + +```python showLineNumbers title="Force bridge via openai/chat_completions/ model prefix" +import litellm + +response = litellm.responses( + model="openai/chat_completions/my-custom-model", + input="Hello!", + api_base="http://localhost:8080", + api_key="fake-key", ) print(response) @@ -1538,9 +1557,11 @@ model_list: model: openai/my-custom-model api_base: http://localhost:8080/v1 api_key: fake-key - use_responses_api_bridge: true + use_chat_completions_api: true ``` +Alternatively set `model: openai/chat_completions/my-custom-model` instead of the flag. + **Start Proxy:** ```bash showLineNumbers title="Start LiteLLM Proxy" diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 80bd319569..91e173a7a8 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -643,6 +643,29 @@ def _apply_prompt_management_to_responses_call( return input, model, custom_llm_provider +# Opt-in via model id (mirrors the `responses/` prefix pattern on chat completions). +_OPENAI_CHAT_COMPLETIONS_RESPONSES_MODEL_PREFIX = "openai/chat_completions/" + + +def _normalize_openai_chat_completions_responses_model(model: str) -> tuple[str, bool]: + """ + Strip `openai/chat_completions/` → `openai/` and return True when the + prefix was applied (same effect as use_chat_completions_api=True). + """ + if not model.startswith(_OPENAI_CHAT_COMPLETIONS_RESPONSES_MODEL_PREFIX): + return model, False + remainder = model[len(_OPENAI_CHAT_COMPLETIONS_RESPONSES_MODEL_PREFIX) :] + if not remainder: + return model, False + return f"openai/{remainder}", True + + +def _pop_use_chat_completions_api_kw(kwargs: Dict[str, Any]) -> bool: + """Pop bridge flags; True if either requests the chat-completions path.""" + use_cc = kwargs.pop("use_chat_completions_api", None) + return bool(use_cc) + + def _resolve_model_provider_for_responses( model: str, custom_llm_provider: Optional[str], @@ -754,7 +777,7 @@ def responses( litellm_logging_obj: LiteLLMLoggingObj = kwargs.get("litellm_logging_obj") # type: ignore litellm_call_id: Optional[str] = kwargs.get("litellm_call_id", None) _is_async = kwargs.pop("aresponses", False) is True - use_responses_api_bridge = kwargs.pop("use_responses_api_bridge", None) + use_chat_completions_api = _pop_use_chat_completions_api_kw(kwargs) # Convert text_format to text parameter if provided text = ResponsesAPIRequestUtils.convert_text_format_to_text_param( @@ -777,6 +800,15 @@ def responses( mock_response=litellm_params.mock_response ) + _stripped_model, _from_chat_completions_prefix = ( + _normalize_openai_chat_completions_responses_model(model) + ) + model = _stripped_model + local_vars["model"] = model + use_chat_completions_api = ( + use_chat_completions_api or _from_chat_completions_prefix + ) + model, custom_llm_provider = _resolve_model_provider_for_responses( model=model, custom_llm_provider=custom_llm_provider, @@ -872,7 +904,7 @@ def responses( if _has_file_search_tool(tools) and ( responses_api_provider_config is None - or use_responses_api_bridge is True + or use_chat_completions_api is True or not responses_api_provider_config.supports_native_file_search() ): from litellm.responses.file_search.emulated_handler import ( @@ -907,7 +939,11 @@ def responses( "extra_body": extra_body, "timeout": timeout, "custom_llm_provider": custom_llm_provider, - **({"use_responses_api_bridge": True} if use_responses_api_bridge else {}), + **( + {"use_chat_completions_api": True} + if use_chat_completions_api + else {} + ), **{k: v for k, v in kwargs.items() if k not in _internal_skip}, } if _is_async: @@ -922,7 +958,7 @@ def responses( **emulated_kwargs, ) - if responses_api_provider_config is None or use_responses_api_bridge is True: + if responses_api_provider_config is None or use_chat_completions_api is True: return litellm_completion_transformation_handler.response_api_handler( model=model, input=input, diff --git a/litellm/types/router.py b/litellm/types/router.py index d608f30249..6f483c883d 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -199,7 +199,7 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): budget_duration: Optional[str] = None use_in_pass_through: Optional[bool] = False use_litellm_proxy: Optional[bool] = False - use_responses_api_bridge: Optional[bool] = None + use_chat_completions_api: Optional[bool] = None model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) merge_reasoning_content_in_choices: Optional[bool] = False model_info: Optional[Dict] = None @@ -319,8 +319,8 @@ class LiteLLMParamsTypedDict(TypedDict, total=False): configurable_clientside_auth_params: CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS # for allowing api base switching on finetuned models ## DROP PARAMS ## drop_params: Optional[bool] - ## RESPONSES API BRIDGE ## - use_responses_api_bridge: Optional[bool] + ## RESPONSES API → CHAT COMPLETIONS BRIDGE ## + use_chat_completions_api: Optional[bool] ## UNIFIED PROJECT/REGION ## region_name: Optional[str] ## VERTEX AI ## diff --git a/tests/test_litellm/responses/test_responses_api_bridge_flag.py b/tests/test_litellm/responses/test_responses_api_bridge_flag.py index 727692d55b..e635e12560 100644 --- a/tests/test_litellm/responses/test_responses_api_bridge_flag.py +++ b/tests/test_litellm/responses/test_responses_api_bridge_flag.py @@ -1,6 +1,7 @@ """ -Tests for the `use_responses_api_bridge` flag that allows openai/ models -with custom api_base to opt-in to the /responses → /chat/completions bridge. +Tests for forcing the /responses → /chat/completions bridge for `openai/` models +(via `use_chat_completions_api`, deprecated `use_responses_api_bridge`, or the +`openai/chat_completions/` model id). Includes file_search emulation: the flag must be forwarded on inner aresponses calls so routed requests do not hit a custom api_base /v1/responses endpoint. @@ -19,7 +20,7 @@ from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse class TestUseResponsesApiBridgeFlag: - """Test that use_responses_api_bridge forces the chat completions bridge.""" + """Test that bridge opt-in forces the chat completions path.""" @patch( "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" @@ -28,8 +29,7 @@ class TestUseResponsesApiBridgeFlag: "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" ) def test_bridge_used_when_flag_is_true(self, mock_get_config, mock_bridge_handler): - """When use_responses_api_bridge=True, the bridge handler should be called - even though the provider (openai) has native responses API support.""" + """When use_responses_api_bridge=True (deprecated alias), the bridge runs.""" # Setup: provider config returns a non-None config (native support exists) mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() @@ -44,6 +44,51 @@ class TestUseResponsesApiBridgeFlag: mock_bridge_handler.assert_called_once() + @patch( + "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" + ) + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + def test_bridge_used_when_use_chat_completions_api_true( + self, mock_get_config, mock_bridge_handler + ): + """When use_chat_completions_api=True, the bridge handler should be called.""" + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + mock_bridge_handler.return_value = MagicMock() + + litellm.responses( + model="openai/my-custom-model", + input="Hello", + use_chat_completions_api=True, + litellm_logging_obj=MagicMock(), + ) + + mock_bridge_handler.assert_called_once() + + @patch( + "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" + ) + @patch( + "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" + ) + def test_bridge_used_when_model_uses_chat_completions_prefix( + self, mock_get_config, mock_bridge_handler + ): + """`openai/chat_completions/` normalizes to `openai/` and uses the bridge.""" + mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() + mock_bridge_handler.return_value = MagicMock() + + litellm.responses( + model="openai/chat_completions/my-custom-model", + input="Hello", + litellm_logging_obj=MagicMock(), + ) + + mock_bridge_handler.assert_called_once() + # Model string is provider-normalized after resolution; prefix only forces the bridge. + assert mock_bridge_handler.call_args.kwargs["model"].endswith("my-custom-model") + @patch("litellm.responses.main.base_llm_http_handler.response_api_handler") @patch( "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" @@ -84,9 +129,10 @@ class TestUseResponsesApiBridgeFlag: ) call_kwargs = mock_bridge_handler.call_args - # The flag should not appear in the kwargs passed to the bridge handler + # Bridge flags should not appear in the kwargs passed to the bridge handler all_kwargs = call_kwargs.kwargs if call_kwargs.kwargs else {} assert "use_responses_api_bridge" not in all_kwargs + assert "use_chat_completions_api" not in all_kwargs @patch( "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" @@ -145,12 +191,12 @@ class TestUseResponsesApiBridgeFlag: litellm_logging_obj=MagicMock(), ) - # Verify _call_aresponses was called with use_responses_api_bridge=True + # Verify _call_aresponses was called with use_chat_completions_api=True mock_call_aresponses.assert_called_once() call_kwargs = mock_call_aresponses.call_args.kwargs assert ( - call_kwargs.get("use_responses_api_bridge") is True - ), "use_responses_api_bridge flag should be forwarded to inner aresponses call" + call_kwargs.get("use_chat_completions_api") is True + ), "use_chat_completions_api should be forwarded to inner aresponses call" @patch( "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" @@ -223,6 +269,7 @@ class TestUseResponsesApiBridgeFlag: for call in mock_bridge_handler.call_args_list: all_kwargs = call.kwargs if call.kwargs else {} assert "use_responses_api_bridge" not in all_kwargs + assert "use_chat_completions_api" not in all_kwargs assert result is not None assert result.id is not None From 2506ccb2bc5d0ab3b8a9c5752c61c12a960b74c1 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 13 Apr 2026 18:22:17 +0530 Subject: [PATCH 5/5] refactor(responses): drop use_responses_api_bridge; fix PLR0915 - Only use_chat_completions_api and openai/chat_completions/ opt into the bridge - Extract MCP gateway and file_search emulation dispatch to cut responses() size - Update docs and tests Made-with: Cursor --- docs/my-website/docs/response_api.md | 5 +- litellm/responses/main.py | 379 ++++++++++++------ .../test_responses_api_bridge_flag.py | 41 +- 3 files changed, 272 insertions(+), 153 deletions(-) diff --git a/docs/my-website/docs/response_api.md b/docs/my-website/docs/response_api.md index 94f5c3e52f..20bb6d50d9 100644 --- a/docs/my-website/docs/response_api.md +++ b/docs/my-website/docs/response_api.md @@ -1509,11 +1509,10 @@ curl http://localhost:4000/v1/responses \ If you're using an **OpenAI-compatible third-party provider** (e.g. llama.cpp, vLLM, LM Studio) via `openai/` prefix with a custom `api_base`, LiteLLM will normally forward `/responses` requests directly to that endpoint. If the provider only supports `/chat/completions`, the request will fail. -Use any of these to force the `/responses` → `/chat/completions` bridge: +Use either of these to force the `/responses` → `/chat/completions` bridge: -1. **`use_chat_completions_api: true`** (recommended) — makes it explicit that LiteLLM will call the provider’s chat-completions API. +1. **`use_chat_completions_api: true`** — makes it explicit that LiteLLM will call the provider’s chat-completions API. 2. **`openai/chat_completions/`** — same pattern as `responses/` on chat completions: the model id encodes the routing choice. -3. **`use_responses_api_bridge: true`** — deprecated alias for `use_chat_completions_api` (kept for backward compatibility). #### Python SDK Usage diff --git a/litellm/responses/main.py b/litellm/responses/main.py index 91e173a7a8..edd936e734 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -661,7 +661,7 @@ def _normalize_openai_chat_completions_responses_model(model: str) -> tuple[str, def _pop_use_chat_completions_api_kw(kwargs: Dict[str, Any]) -> bool: - """Pop bridge flags; True if either requests the chat-completions path.""" + """Pop use_chat_completions_api; True when the chat-completions bridge is requested.""" use_cc = kwargs.pop("use_chat_completions_api", None) return bool(use_cc) @@ -728,6 +728,175 @@ def _apply_managed_file_id_mapping( return input, tools +def _responses_try_dispatch_mcp_gateway( + *, + tools: Optional[Iterable[ToolParam]], + input: Union[str, ResponseInputParam], + model: str, + include: Optional[List[ResponseIncludable]], + instructions: Optional[str], + max_output_tokens: Optional[int], + prompt: Optional[PromptObject], + metadata: Optional[Dict[str, Any]], + parallel_tool_calls: Optional[bool], + previous_response_id: Optional[str], + reasoning: Optional[Reasoning], + store: Optional[bool], + background: Optional[bool], + stream: Optional[bool], + temperature: Optional[float], + text: Any, + tool_choice: Optional[ToolChoice], + top_p: Optional[float], + truncation: Optional[Literal["auto", "disabled"]], + user: Optional[str], + extra_headers: Optional[Dict[str, Any]], + extra_query: Optional[Dict[str, Any]], + extra_body: Optional[Dict[str, Any]], + timeout: Optional[Union[float, httpx.Timeout]], + custom_llm_provider: Optional[str], + kwargs: Dict[str, Any], + _is_async: bool, +) -> Optional[Any]: + """Return a response when MCP gateway handles the call; otherwise None.""" + from litellm.responses.mcp.litellm_proxy_mcp_handler import ( + LiteLLM_Proxy_MCP_Handler, + ) + + if not LiteLLM_Proxy_MCP_Handler._should_use_litellm_mcp_gateway(tools=tools): + return None + mcp_call_kwargs = { + "input": input, + "model": model, + "include": include, + "instructions": instructions, + "max_output_tokens": max_output_tokens, + "prompt": prompt, + "metadata": metadata, + "parallel_tool_calls": parallel_tool_calls, + "previous_response_id": previous_response_id, + "reasoning": reasoning, + "store": store, + "background": background, + "stream": stream, + "temperature": temperature, + "text": text, + "tool_choice": tool_choice, + "tools": tools, + "top_p": top_p, + "truncation": truncation, + "user": user, + "extra_headers": extra_headers, + "extra_query": extra_query, + "extra_body": extra_body, + "timeout": timeout, + "custom_llm_provider": custom_llm_provider, + **kwargs, + } + if _is_async: + return aresponses_api_with_mcp(**mcp_call_kwargs) + return run_async_function(aresponses_api_with_mcp, **mcp_call_kwargs) + + +def _responses_try_dispatch_emulated_file_search( + *, + tools: Optional[Iterable[ToolParam]], + input: Union[str, ResponseInputParam], + model: str, + responses_api_provider_config: Optional[BaseResponsesAPIConfig], + use_chat_completions_api: bool, + include: Optional[List[ResponseIncludable]], + instructions: Optional[str], + max_output_tokens: Optional[int], + prompt: Optional[PromptObject], + metadata: Optional[Dict[str, Any]], + parallel_tool_calls: Optional[bool], + previous_response_id: Optional[str], + reasoning: Optional[Reasoning], + store: Optional[bool], + background: Optional[bool], + stream: Optional[bool], + temperature: Optional[float], + text: Any, + tool_choice: Optional[ToolChoice], + top_p: Optional[float], + truncation: Optional[Literal["auto", "disabled"]], + user: Optional[str], + service_tier: Optional[str], + safety_identifier: Optional[str], + text_format: Optional[Union[Type[BaseModel], dict]], + allowed_openai_params: Optional[List[str]], + extra_headers: Optional[Dict[str, Any]], + extra_query: Optional[Dict[str, Any]], + extra_body: Optional[Dict[str, Any]], + timeout: Optional[Union[float, httpx.Timeout]], + custom_llm_provider: Optional[str], + kwargs: Dict[str, Any], + _is_async: bool, +) -> Optional[Any]: + """Return a response when emulated file_search handles the call; otherwise None.""" + if not _has_file_search_tool(tools) or not ( + responses_api_provider_config is None + or use_chat_completions_api is True + or not responses_api_provider_config.supports_native_file_search() + ): + return None + from litellm.responses.file_search.emulated_handler import ( + aresponses_with_emulated_file_search, + ) + + _internal_skip = {"litellm_call_id", "aresponses"} + emulated_kwargs = { + "include": include, + "instructions": instructions, + "max_output_tokens": max_output_tokens, + "prompt": prompt, + "metadata": metadata, + "parallel_tool_calls": parallel_tool_calls, + "previous_response_id": previous_response_id, + "reasoning": reasoning, + "store": store, + "background": background, + "stream": stream, + "temperature": temperature, + "text": text, + "tool_choice": tool_choice, + "top_p": top_p, + "truncation": truncation, + "user": user, + "service_tier": service_tier, + "safety_identifier": safety_identifier, + "text_format": text_format, + "allowed_openai_params": allowed_openai_params, + "extra_headers": extra_headers, + "extra_query": extra_query, + "extra_body": extra_body, + "timeout": timeout, + "custom_llm_provider": custom_llm_provider, + **( + { + **( + {"use_chat_completions_api": True} + if use_chat_completions_api + else {} + ), + **{k: v for k, v in kwargs.items() if k not in _internal_skip}, + } + ), + } + if _is_async: + return aresponses_with_emulated_file_search( + input=input, model=model, tools=tools, **emulated_kwargs + ) + return run_async_function( + aresponses_with_emulated_file_search, + input=input, + model=model, + tools=tools, + **emulated_kwargs, + ) + + @client def responses( input: Union[str, ResponseInputParam], @@ -769,9 +938,6 @@ def responses( Uses the synchronous HTTP handler to make requests. """ local_vars = locals() - from litellm.responses.mcp.litellm_proxy_mcp_handler import ( - LiteLLM_Proxy_MCP_Handler, - ) try: litellm_logging_obj: LiteLLMLoggingObj = kwargs.get("litellm_logging_obj") # type: ignore @@ -841,38 +1007,37 @@ def responses( ######################################################### # Native MCP Responses API ######################################################### - if LiteLLM_Proxy_MCP_Handler._should_use_litellm_mcp_gateway(tools=tools): - mcp_call_kwargs = { - "input": input, - "model": model, - "include": include, - "instructions": instructions, - "max_output_tokens": max_output_tokens, - "prompt": prompt, - "metadata": metadata, - "parallel_tool_calls": parallel_tool_calls, - "previous_response_id": previous_response_id, - "reasoning": reasoning, - "store": store, - "background": background, - "stream": stream, - "temperature": temperature, - "text": text, - "tool_choice": tool_choice, - "tools": tools, - "top_p": top_p, - "truncation": truncation, - "user": user, - "extra_headers": extra_headers, - "extra_query": extra_query, - "extra_body": extra_body, - "timeout": timeout, - "custom_llm_provider": custom_llm_provider, - **kwargs, - } - if _is_async: - return aresponses_api_with_mcp(**mcp_call_kwargs) - return run_async_function(aresponses_api_with_mcp, **mcp_call_kwargs) + _mcp_dispatch = _responses_try_dispatch_mcp_gateway( + tools=tools, + input=input, + model=model, + include=include, + instructions=instructions, + max_output_tokens=max_output_tokens, + prompt=prompt, + metadata=metadata, + parallel_tool_calls=parallel_tool_calls, + previous_response_id=previous_response_id, + reasoning=reasoning, + store=store, + background=background, + stream=stream, + temperature=temperature, + text=text, + tool_choice=tool_choice, + top_p=top_p, + truncation=truncation, + user=user, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + custom_llm_provider=custom_llm_provider, + kwargs=kwargs, + _is_async=_is_async, + ) + if _mcp_dispatch is not None: + return _mcp_dispatch # get provider config responses_api_provider_config: Optional[BaseResponsesAPIConfig] @@ -902,61 +1067,43 @@ def responses( ) ) - if _has_file_search_tool(tools) and ( - responses_api_provider_config is None - or use_chat_completions_api is True - or not responses_api_provider_config.supports_native_file_search() - ): - from litellm.responses.file_search.emulated_handler import ( - aresponses_with_emulated_file_search, - ) - - _internal_skip = {"litellm_call_id", "aresponses"} - emulated_kwargs = { - "include": include, - "instructions": instructions, - "max_output_tokens": max_output_tokens, - "prompt": prompt, - "metadata": metadata, - "parallel_tool_calls": parallel_tool_calls, - "previous_response_id": previous_response_id, - "reasoning": reasoning, - "store": store, - "background": background, - "stream": stream, - "temperature": temperature, - "text": text, - "tool_choice": tool_choice, - "top_p": top_p, - "truncation": truncation, - "user": user, - "service_tier": service_tier, - "safety_identifier": safety_identifier, - "text_format": text_format, - "allowed_openai_params": allowed_openai_params, - "extra_headers": extra_headers, - "extra_query": extra_query, - "extra_body": extra_body, - "timeout": timeout, - "custom_llm_provider": custom_llm_provider, - **( - {"use_chat_completions_api": True} - if use_chat_completions_api - else {} - ), - **{k: v for k, v in kwargs.items() if k not in _internal_skip}, - } - if _is_async: - return aresponses_with_emulated_file_search( - input=input, model=model, tools=tools, **emulated_kwargs - ) - return run_async_function( - aresponses_with_emulated_file_search, - input=input, - model=model, - tools=tools, - **emulated_kwargs, - ) + _file_search_dispatch = _responses_try_dispatch_emulated_file_search( + tools=tools, + input=input, + model=model, + responses_api_provider_config=responses_api_provider_config, + use_chat_completions_api=use_chat_completions_api, + include=include, + instructions=instructions, + max_output_tokens=max_output_tokens, + prompt=prompt, + metadata=metadata, + parallel_tool_calls=parallel_tool_calls, + previous_response_id=previous_response_id, + reasoning=reasoning, + store=store, + background=background, + stream=stream, + temperature=temperature, + text=text, + tool_choice=tool_choice, + top_p=top_p, + truncation=truncation, + user=user, + service_tier=service_tier, + safety_identifier=safety_identifier, + text_format=text_format, + allowed_openai_params=allowed_openai_params, + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + custom_llm_provider=custom_llm_provider, + kwargs=kwargs, + _is_async=_is_async, + ) + if _file_search_dispatch is not None: + return _file_search_dispatch if responses_api_provider_config is None or use_chat_completions_api is True: return litellm_completion_transformation_handler.response_api_handler( @@ -1154,11 +1301,11 @@ def delete_responses( raise ValueError("custom_llm_provider is required but passed as None") # get provider config - responses_api_provider_config: Optional[ - BaseResponsesAPIConfig - ] = ProviderConfigManager.get_provider_responses_api_config( - model=None, - provider=custom_llm_provider, + responses_api_provider_config: Optional[BaseResponsesAPIConfig] = ( + ProviderConfigManager.get_provider_responses_api_config( + model=None, + provider=custom_llm_provider, + ) ) if responses_api_provider_config is None: @@ -1335,11 +1482,11 @@ def get_responses( raise ValueError("custom_llm_provider is required but passed as None") # get provider config - responses_api_provider_config: Optional[ - BaseResponsesAPIConfig - ] = ProviderConfigManager.get_provider_responses_api_config( - model=None, - provider=custom_llm_provider, + responses_api_provider_config: Optional[BaseResponsesAPIConfig] = ( + ProviderConfigManager.get_provider_responses_api_config( + model=None, + provider=custom_llm_provider, + ) ) if responses_api_provider_config is None: @@ -1493,11 +1640,11 @@ def list_input_items( if custom_llm_provider is None: raise ValueError("custom_llm_provider is required but passed as None") - responses_api_provider_config: Optional[ - BaseResponsesAPIConfig - ] = ProviderConfigManager.get_provider_responses_api_config( - model=None, - provider=custom_llm_provider, + responses_api_provider_config: Optional[BaseResponsesAPIConfig] = ( + ProviderConfigManager.get_provider_responses_api_config( + model=None, + provider=custom_llm_provider, + ) ) if responses_api_provider_config is None: @@ -1652,11 +1799,11 @@ def cancel_responses( raise ValueError("custom_llm_provider is required but passed as None") # get provider config - responses_api_provider_config: Optional[ - BaseResponsesAPIConfig - ] = ProviderConfigManager.get_provider_responses_api_config( - model=None, - provider=custom_llm_provider, + responses_api_provider_config: Optional[BaseResponsesAPIConfig] = ( + ProviderConfigManager.get_provider_responses_api_config( + model=None, + provider=custom_llm_provider, + ) ) if responses_api_provider_config is None: @@ -1840,11 +1987,11 @@ def compact_responses( raise ValueError("custom_llm_provider is required but passed as None") # get provider config - responses_api_provider_config: Optional[ - BaseResponsesAPIConfig - ] = ProviderConfigManager.get_provider_responses_api_config( - model=model, - provider=custom_llm_provider, + responses_api_provider_config: Optional[BaseResponsesAPIConfig] = ( + ProviderConfigManager.get_provider_responses_api_config( + model=model, + provider=custom_llm_provider, + ) ) if responses_api_provider_config is None: diff --git a/tests/test_litellm/responses/test_responses_api_bridge_flag.py b/tests/test_litellm/responses/test_responses_api_bridge_flag.py index e635e12560..463af6562f 100644 --- a/tests/test_litellm/responses/test_responses_api_bridge_flag.py +++ b/tests/test_litellm/responses/test_responses_api_bridge_flag.py @@ -1,7 +1,6 @@ """ Tests for forcing the /responses → /chat/completions bridge for `openai/` models -(via `use_chat_completions_api`, deprecated `use_responses_api_bridge`, or the -`openai/chat_completions/` model id). +(via `use_chat_completions_api` or the `openai/chat_completions/` model id). Includes file_search emulation: the flag must be forwarded on inner aresponses calls so routed requests do not hit a custom api_base /v1/responses endpoint. @@ -22,28 +21,6 @@ from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse class TestUseResponsesApiBridgeFlag: """Test that bridge opt-in forces the chat completions path.""" - @patch( - "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" - ) - @patch( - "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" - ) - def test_bridge_used_when_flag_is_true(self, mock_get_config, mock_bridge_handler): - """When use_responses_api_bridge=True (deprecated alias), the bridge runs.""" - # Setup: provider config returns a non-None config (native support exists) - mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() - - mock_bridge_handler.return_value = MagicMock() - - litellm.responses( - model="openai/my-custom-model", - input="Hello", - use_responses_api_bridge=True, - litellm_logging_obj=MagicMock(), - ) - - mock_bridge_handler.assert_called_once() - @patch( "litellm.responses.main.litellm_completion_transformation_handler.response_api_handler" ) @@ -96,7 +73,7 @@ class TestUseResponsesApiBridgeFlag: def test_native_forwarding_when_flag_absent( self, mock_get_config, mock_native_handler ): - """When use_responses_api_bridge is not set, openai/ models should use + """When use_chat_completions_api is not set, openai/ models should use native responses API forwarding (existing behavior).""" mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() mock_native_handler.return_value = MagicMock() @@ -116,22 +93,19 @@ class TestUseResponsesApiBridgeFlag: "litellm.responses.main.ProviderConfigManager.get_provider_responses_api_config" ) def test_flag_does_not_leak_into_kwargs(self, mock_get_config, mock_bridge_handler): - """The use_responses_api_bridge flag should be popped from kwargs and not - passed through to the bridge handler.""" + """use_chat_completions_api should be popped and not passed to the bridge handler.""" mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig() mock_bridge_handler.return_value = MagicMock() litellm.responses( model="openai/my-custom-model", input="Hello", - use_responses_api_bridge=True, + use_chat_completions_api=True, litellm_logging_obj=MagicMock(), ) call_kwargs = mock_bridge_handler.call_args - # Bridge flags should not appear in the kwargs passed to the bridge handler all_kwargs = call_kwargs.kwargs if call_kwargs.kwargs else {} - assert "use_responses_api_bridge" not in all_kwargs assert "use_chat_completions_api" not in all_kwargs @patch( @@ -163,7 +137,7 @@ class TestUseResponsesApiBridgeFlag: async def test_bridge_flag_forwarded_to_file_search_emulation( self, mock_get_config, mock_call_aresponses ): - """When use_responses_api_bridge=True and file_search tool is present, + """When use_chat_completions_api=True and file_search tool is present, the flag should be forwarded to the inner aresponses call in the file_search emulation path.""" # Setup: provider has native responses API support @@ -187,7 +161,7 @@ class TestUseResponsesApiBridgeFlag: model="openai/my-custom-model", input="Search for information", tools=[{"type": "file_search"}], - use_responses_api_bridge=True, + use_chat_completions_api=True, litellm_logging_obj=MagicMock(), ) @@ -257,7 +231,7 @@ class TestUseResponsesApiBridgeFlag: "file_search": {"vector_store_ids": ["vs_123"]}, } ], - use_responses_api_bridge=True, + use_chat_completions_api=True, api_base="http://localhost:8080/v1", litellm_logging_obj=MagicMock(), ) @@ -268,7 +242,6 @@ class TestUseResponsesApiBridgeFlag: ) for call in mock_bridge_handler.call_args_list: all_kwargs = call.kwargs if call.kwargs else {} - assert "use_responses_api_bridge" not in all_kwargs assert "use_chat_completions_api" not in all_kwargs assert result is not None assert result.id is not None