From 6f9b89313b4140b1d44023109b2cff4dbd964a94 Mon Sep 17 00:00:00 2001 From: Jonathan Wrede Date: Thu, 7 May 2026 21:56:43 +0000 Subject: [PATCH] fix(responses): generate Responses-compatible IDs in Chat Completions bridge The Chat Completions -> Responses API bridge reused chatcmpl-* IDs for response and message output items. When bridged output from a non-OpenAI provider (e.g. Claude via LiteLLM) is later sent as input to an OpenAI Responses model, OpenAI rejects the request because message item IDs must start with msg_. Generate proper Responses-compatible IDs: - resp_ for the top-level ResponsesAPIResponse - msg_ for message output items - img_ for image generation output items Fixes #27333 --- .../transformation.py | 7 +- .../test_image_generation_output.py | 4 +- .../test_response_id_prefixes.py | 115 ++++++++++++++++++ 3 files changed, 121 insertions(+), 5 deletions(-) create mode 100644 tests/test_litellm/responses/litellm_completion_transformation/test_response_id_prefixes.py diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 48b12a5fba9..145dd381b49 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -2,6 +2,7 @@ Handles transforming from Responses API -> LiteLLM completion (Chat Completion API) """ +import uuid from collections.abc import Sequence from typing import Any, Dict, List, Literal, Optional, Set, Tuple, Union, cast @@ -1657,7 +1658,7 @@ class LiteLLMCompletionResponsesConfig: finish_reason = choices[0].finish_reason responses_api_response: ResponsesAPIResponse = ResponsesAPIResponse( - id=chat_completion_response.id, + id=f"resp_{uuid.uuid4()}", created_at=chat_completion_response.created, model=chat_completion_response.model, object="response", @@ -1874,7 +1875,7 @@ class LiteLLMCompletionResponsesConfig: image_generation_items.append( OutputImageGenerationCall( type="image_generation_call", - id=f"{chat_completion_response.id}_img_{idx}", + id=f"img_{uuid.uuid4()}", status=LiteLLMCompletionResponsesConfig._map_finish_reason_to_image_generation_status( choice.finish_reason ), @@ -1950,7 +1951,7 @@ class LiteLLMCompletionResponsesConfig: message_output_items.append( GenericResponseOutputItem( type="message", - id=chat_completion_response.id, + id=f"msg_{uuid.uuid4()}", status=LiteLLMCompletionResponsesConfig._map_chat_completion_finish_reason_to_responses_status( choice.finish_reason ), diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_image_generation_output.py b/tests/test_litellm/responses/litellm_completion_transformation/test_image_generation_output.py index ed7a3f63a8e..e12a1cd452a 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_image_generation_output.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_image_generation_output.py @@ -89,8 +89,8 @@ class TestExtractImageGenerationOutputItems: assert result[0].type == "image_generation_call" assert result[0].result == "IMG1" assert result[1].result == "IMG2" - assert result[0].id == "test_123_img_0" - assert result[1].id == "test_123_img_1" + assert result[0].id.startswith("img_") + assert result[1].id.startswith("img_") assert result[0].status == "completed" def test_returns_empty_for_no_images(self): diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_response_id_prefixes.py b/tests/test_litellm/responses/litellm_completion_transformation/test_response_id_prefixes.py new file mode 100644 index 00000000000..0883d0d8767 --- /dev/null +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_response_id_prefixes.py @@ -0,0 +1,115 @@ +""" +Regression tests for Responses API bridge ID prefixes. + +The Chat Completions -> Responses bridge must generate Responses-compatible +IDs (resp_*, msg_*) instead of reusing Chat Completions IDs (chatcmpl-*). +Reusing chatcmpl-* IDs causes OpenAI to reject the request when bridged +output is later sent back as Responses input. + +Regression test for https://github.com/BerriAI/litellm/issues/27333 +""" + +import os +import sys + +sys.path.insert(0, os.path.abspath("../../..")) + +from litellm.responses.litellm_completion_transformation.transformation import ( + LiteLLMCompletionResponsesConfig, +) +from litellm.types.utils import ( + Choices, + Message, + ModelResponse, + Usage, +) + + +def _make_chat_completion_response(**overrides) -> ModelResponse: + defaults = dict( + id="chatcmpl-dfa2da3a-1586-4ff7-b64e-f59c692a5d11", + created=1717000000, + model="claude-3-5-sonnet-20241022", + object="chat.completion", + choices=[ + Choices( + index=0, + finish_reason="stop", + message=Message(role="assistant", content="Hello"), + ) + ], + usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15), + ) + defaults.update(overrides) + return ModelResponse(**defaults) + + +class TestResponseIdPrefixes: + """Bridged Responses output must use resp_*/msg_* ID prefixes.""" + + def test_response_id_uses_resp_prefix(self): + """Top-level response ID must start with resp_, not chatcmpl-.""" + chat_response = _make_chat_completion_response() + + result = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="test", + responses_api_request={}, + chat_completion_response=chat_response, + ) + + assert result.id.startswith( + "resp_" + ), f"Expected resp_* prefix, got: {result.id}" + assert not result.id.startswith("chatcmpl-") + + def test_message_output_id_uses_msg_prefix(self): + """Message output item ID must start with msg_, not chatcmpl-.""" + chat_response = _make_chat_completion_response() + + result = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="test", + responses_api_request={}, + chat_completion_response=chat_response, + ) + + message_items = [ + item for item in result.output if getattr(item, "type", None) == "message" + ] + assert len(message_items) > 0 + + for item in message_items: + assert item.id.startswith("msg_"), f"Expected msg_* prefix, got: {item.id}" + assert not item.id.startswith("chatcmpl-") + + def test_response_and_message_ids_are_distinct(self): + """Response ID and message item ID must not be the same value.""" + chat_response = _make_chat_completion_response() + + result = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="test", + responses_api_request={}, + chat_completion_response=chat_response, + ) + + message_items = [ + item for item in result.output if getattr(item, "type", None) == "message" + ] + for item in message_items: + assert result.id != item.id + + def test_dict_input_also_gets_correct_prefixes(self): + """When chat_completion_response is passed as a dict, IDs still get correct prefixes.""" + chat_response = _make_chat_completion_response() + + result = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="test", + responses_api_request={}, + chat_completion_response=dict(chat_response), + ) + + assert result.id.startswith("resp_") + message_items = [ + item for item in result.output if getattr(item, "type", None) == "message" + ] + for item in message_items: + assert item.id.startswith("msg_")