Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -164,17 +164,19 @@ def convert_chat_completion_messages_to_responses_api(
instructions = f"{instructions} {content}"
else:
instructions = content
else:
input_items.append(
{
"type": "message",
"role": role,
"content": self._convert_content_to_responses_format(
content, # type: ignore[arg-type]
role, # type: ignore
),
}
)
elif isinstance(content, list):
# Extract text from content blocks (e.g. [{"type": "text", "text": "..."}])
text_parts = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text_parts.append(block.get("text", ""))
elif isinstance(block, str):
text_parts.append(block)
extracted = " ".join(text_parts)
if instructions:
instructions = f"{instructions} {extracted}"
else:
instructions = extracted
Comment on lines +175 to +179
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty extracted silently sets instructions to ""

When content is a list where no block is of type == "text" (e.g. a system message containing only image blocks), text_parts will be empty and extracted will be "". The subsequent assignment then overwrites instructions with an empty string rather than leaving it as None, which changes the downstream behaviour — callers that check if instructions: or if instructions is not None: will now get different results.

Additionally, if instructions already has content and this code path runs, the resulting value has a trailing space: f"{instructions} ".

Consider guarding the assignment so it only fires when there is actually extracted text:

Suggested change
extracted = " ".join(text_parts)
if instructions:
instructions = f"{instructions} {extracted}"
else:
instructions = extracted
extracted = " ".join(text_parts)
if extracted:
if instructions:
instructions = f"{instructions} {extracted}"
else:
instructions = extracted

Comment on lines +167 to +179
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-text content blocks in system messages are silently discarded

The previous else branch forwarded non-string content (including list content with non-text blocks like image_url) into input_items so they weren't lost. The new code for the isinstance(content, list) path only collects type == "text" blocks and silently discards all other block types (e.g. images). While the Responses API instructions field is text-only, the silent drop could be surprising. Adding a verbose_logger.warning when non-text blocks are encountered would help authors of clients like Claude Code diagnose unexpected behaviour without impacting production paths.

elif role == "tool":
# Convert tool message to function call output format
# The Responses API expects 'output' to be a list with input_text/input_image types
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1995,3 +1995,90 @@ def test_map_optional_params_preserves_reasoning_summary():
assert responses_api_request["reasoning"] == {"effort": "high", "summary": "detailed"}
assert responses_api_request["reasoning"]["effort"] == "high"
assert responses_api_request["reasoning"]["summary"] == "detailed"


def test_convert_system_message_string_to_instructions():
"""
Test that a system message with plain string content is extracted into instructions.
"""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
)

handler = LiteLLMResponsesTransformationHandler()

messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello"},
]

input_items, instructions = handler.convert_chat_completion_messages_to_responses_api(messages)

assert instructions == "You are a helpful assistant."
# System message should not appear in input_items
assert all(item.get("role") != "system" for item in input_items)


def test_convert_system_message_content_blocks_to_instructions():
"""
Test that a system message with list content blocks is extracted into instructions.

Clients like Claude Code send system prompts as structured content blocks:
[{"type": "text", "text": "..."}]

Without this fix, list content was passed through as a role=system input item,
which the ChatGPT Codex API rejects with "System messages are not allowed".
"""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
)

handler = LiteLLMResponsesTransformationHandler()

messages = [
{
"role": "system",
"content": [
{"type": "text", "text": "You are a coding assistant."},
{"type": "text", "text": "Be concise."},
],
},
{"role": "user", "content": "Hello"},
]

input_items, instructions = handler.convert_chat_completion_messages_to_responses_api(messages)

assert instructions is not None
assert "You are a coding assistant." in instructions
assert "Be concise." in instructions
# System message should not appear in input_items
assert all(item.get("role") != "system" for item in input_items)


def test_convert_multiple_system_messages_mixed_formats():
"""
Test that multiple system messages (string and list) are concatenated into instructions.
"""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
)

handler = LiteLLMResponsesTransformationHandler()

messages = [
{"role": "system", "content": "First instruction."},
{
"role": "system",
"content": [
{"type": "text", "text": "Second instruction."},
],
},
{"role": "user", "content": "Hello"},
]

input_items, instructions = handler.convert_chat_completion_messages_to_responses_api(messages)

assert instructions is not None
assert "First instruction." in instructions
assert "Second instruction." in instructions
assert all(item.get("role") != "system" for item in input_items)
Loading