From 0bae2ef75586ee8d8d4013443cfd749876b77172 Mon Sep 17 00:00:00 2001 From: gauravatwork117 Date: Wed, 28 Oct 2026 23:05:37 +0530 Subject: [PATCH] fix(github_copilot): flatten list-form content for system messages (fixes #48568) Signed-off-by: gauravatwork117 --- .../github_copilot/chat/transformation.py | 70 +++++++++++++++---- .../test_github_copilot_transformation.py | 68 ++++++++++++++++++ 2 files changed, 126 insertions(+), 12 deletions(-) diff --git a/litellm/llms/github_copilot/chat/transformation.py b/litellm/llms/github_copilot/chat/transformation.py index 83bc8df23a6..1db43291d88 100644 --- a/litellm/llms/github_copilot/chat/transformation.py +++ b/litellm/llms/github_copilot/chat/transformation.py @@ -73,6 +73,38 @@ class GithubCopilotConfig(OpenAIConfig): ) -> tuple[str | None, str | None, str]: return self._get_openai_compatible_provider_info(model, api_base, api_key, custom_llm_provider) + @staticmethod + def _flatten_text_content_blocks( + content: object, + ) -> tuple[str, object | None] | None: + """ + If content is a list of text-only blocks, returns (flattened_text, cache_control). + Leaves multimodal or non-text content (e.g. image_url) untouched by returning None. + """ + if not isinstance(content, list): + return None + + extracted_texts: list[str] = [] + cache_control: object | None = None + + for item in content: + if isinstance(item, str): + extracted_texts.append(item) + elif isinstance(item, dict): + item_type = item.get("type") + if item_type == "text" or (item_type is None and "text" in item): + text_val = item.get("text") + if isinstance(text_val, str): + extracted_texts.append(text_val) + if cache_control is None and "cache_control" in item: + cache_control = item.get("cache_control") + else: + return None + else: + return None + + return "".join(extracted_texts), cache_control + def _transform_messages( self, messages, @@ -80,20 +112,34 @@ class GithubCopilotConfig(OpenAIConfig): ): import litellm - # Check if system-to-assistant conversion is disabled - if litellm.disable_copilot_system_to_assistant: - # GitHub Copilot API now supports system prompts for all models (Claude, GPT, etc.) - # No conversion needed - just return messages as-is - return messages - - # Default behavior: convert system messages to assistant for compatibility + disable_conversion: Final = bool(litellm.disable_copilot_system_to_assistant) transformed_messages: Final = [] for message in messages: - if message.get("role") == "system": - # Convert system message to assistant message - transformed_messages.append( - cast(AllMessageValues, {**message, "role": "assistant"}) # cast-ok: dict matches the union - ) + role = message.get("role") + if role == "system": + target_role = "system" if disable_conversion else "assistant" + flattened = self._flatten_text_content_blocks(message.get("content")) + if flattened is not None: + flattened_content, cache_control = flattened + transformed_message = cast( + AllMessageValues, + { + **message, + "role": target_role, + "content": flattened_content, + **( + {"cache_control": cache_control} + if cache_control is not None and "cache_control" not in message + else {} + ), + }, + ) + else: + transformed_message = cast( + AllMessageValues, + {**message, "role": target_role}, + ) + transformed_messages.append(transformed_message) else: transformed_messages.append(message) diff --git a/tests/unit/llms/github_copilot/test_github_copilot_transformation.py b/tests/unit/llms/github_copilot/test_github_copilot_transformation.py index 2dbfae07526..83e59945df0 100644 --- a/tests/unit/llms/github_copilot/test_github_copilot_transformation.py +++ b/tests/unit/llms/github_copilot/test_github_copilot_transformation.py @@ -178,6 +178,74 @@ def test_transform_messages_disable_copilot_system_to_assistant(monkeypatch): litellm.disable_copilot_system_to_assistant = original_flag +def test_transform_messages_flattens_text_only_system_content_list(): + """Test that text-only content lists on system messages are flattened to a single string (fixes #48568).""" + import litellm + from litellm.llms.github_copilot.chat.transformation import GithubCopilotConfig + + config = GithubCopilotConfig() + original_flag = litellm.disable_copilot_system_to_assistant + try: + # Case 1: disable_copilot_system_to_assistant = True (role remains system) + litellm.disable_copilot_system_to_assistant = True + messages = [ + {"role": "system", "content": [{"type": "text", "text": "You are a helpful assistant."}]}, + {"role": "user", "content": "What is 2+2?"}, + ] + out = config.transform_messages(messages, model="github_copilot/claude-sonnet-5") + assert out[0]["role"] == "system" + assert out[0]["content"] == "You are a helpful assistant." + assert out[1]["role"] == "user" + + # Case 2: disable_copilot_system_to_assistant = False (role converted to assistant) + litellm.disable_copilot_system_to_assistant = False + out = config.transform_messages(messages, model="github_copilot/claude-sonnet-5") + assert out[0]["role"] == "assistant" + assert out[0]["content"] == "You are a helpful assistant." + assert out[1]["role"] == "user" + finally: + litellm.disable_copilot_system_to_assistant = original_flag + + +def test_transform_messages_flattens_multiple_text_blocks_and_preserves_cache_control(): + """Test multiple text blocks are joined and explicit cache_control is promoted to message level.""" + from litellm.llms.github_copilot.chat.transformation import GithubCopilotConfig + + config = GithubCopilotConfig() + messages = [ + { + "role": "system", + "content": [ + {"type": "text", "text": "Part 1. "}, + {"type": "text", "text": "Part 2.", "cache_control": {"type": "ephemeral"}}, + ], + }, + {"role": "user", "content": "Hello"}, + ] + out = config.transform_messages(messages, model="github_copilot/claude-sonnet-5") + assert out[0]["content"] == "Part 1. Part 2." + assert out[0]["cache_control"] == {"type": "ephemeral"} + + +def test_transform_messages_leaves_non_text_content_blocks_intact(): + """Test non-text blocks (such as image_url) are left untouched as a list.""" + from litellm.llms.github_copilot.chat.transformation import GithubCopilotConfig + + config = GithubCopilotConfig() + messages = [ + { + "role": "system", + "content": [ + {"type": "text", "text": "Describe image:"}, + {"type": "image_url", "image_url": {"url": "https://example.com/test.png"}}, + ], + }, + ] + out = config.transform_messages(messages, model="github_copilot/claude-sonnet-5") + assert isinstance(out[0]["content"], list) + assert len(out[0]["content"]) == 2 + + def test_x_initiator_header_user_request(): """Test that user-only messages result in X-Initiator: user header""" config = GithubCopilotConfig()