From f57aa9fc788b2695e9070fe4e967973441e65525 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 02:19:04 +0000 Subject: [PATCH] fix(github_copilot): surface streaming reasoning_text/reasoning_opaque on chat completions --- litellm/llms/base_llm/chat/transformation.py | 14 +++ .../github_copilot/chat/transformation.py | 86 +++++++++++++- litellm/llms/openai/openai.py | 26 ++++- .../test_github_copilot_transformation.py | 107 ++++++++++++++++++ 4 files changed, 228 insertions(+), 5 deletions(-) diff --git a/litellm/llms/base_llm/chat/transformation.py b/litellm/llms/base_llm/chat/transformation.py index ab901a467e8..ab5fba18708 100644 --- a/litellm/llms/base_llm/chat/transformation.py +++ b/litellm/llms/base_llm/chat/transformation.py @@ -10,6 +10,7 @@ from typing import ( AsyncIterator, Iterator, List, + Mapping, Optional, Tuple, Type, @@ -366,6 +367,19 @@ class BaseConfig(ABC): """ return parsed_response + def transform_parsed_streaming_chunk_dict( + self, + parsed_chunk: dict, # mutable-ok: mirrors transform_parsed_response_dict + ) -> Mapping[str, Any] | None: + """ + Repair a parsed OpenAI-format streaming chunk dict before generic conversion. + + Same rationale as transform_parsed_response_dict, for the streaming path: providers + routed through the OpenAI SDK handler never reach get_model_response_iterator, so + provider-specific delta keys are silently dropped. Return None to leave the chunk as is. + """ + return None + @abstractmethod def get_error_class( self, error_message: str, status_code: int, headers: Union[dict, httpx.Headers] diff --git a/litellm/llms/github_copilot/chat/transformation.py b/litellm/llms/github_copilot/chat/transformation.py index 2cc05227948..0624a5f152e 100644 --- a/litellm/llms/github_copilot/chat/transformation.py +++ b/litellm/llms/github_copilot/chat/transformation.py @@ -1,5 +1,5 @@ import json -from typing import Any, List, Tuple +from typing import Any, AsyncIterator, Iterator, List, Mapping, Tuple, Union import os @@ -7,9 +7,16 @@ import httpx from litellm.exceptions import AuthenticationError from litellm.llms.anthropic.chat.transformation import AnthropicConfig +from litellm.llms.openai.chat.gpt_transformation import ( + OpenAIChatCompletionStreamingHandler, +) from litellm.llms.openai.openai import OpenAIConfig -from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolCallChunk -from litellm.types.utils import ModelResponse +from litellm.types.llms.openai import ( + AllMessageValues, + ChatCompletionThinkingBlock, + ChatCompletionToolCallChunk, +) +from litellm.types.utils import ModelResponse, ModelResponseStream from ..authenticator import Authenticator from ..common_utils import ( @@ -320,3 +327,76 @@ class GithubCopilotConfig(OpenAIConfig): api_key=api_key, json_mode=json_mode, ) + + def get_model_response_iterator( + self, + streaming_response: Union[Iterator[str], AsyncIterator[str], ModelResponse], + sync_stream: bool, + json_mode: bool | None = False, + ) -> "GithubCopilotChatCompletionStreamingHandler": + return GithubCopilotChatCompletionStreamingHandler( + streaming_response=streaming_response, + sync_stream=sync_stream, + json_mode=json_mode, + ) + + def transform_parsed_streaming_chunk_dict( + self, + parsed_chunk: dict, # mutable-ok: base signature takes a parsed chunk dict + ) -> Mapping[str, Any] | None: + return _remap_copilot_reasoning_chunk(parsed_chunk) + + +def _remap_copilot_reasoning_delta(delta: Mapping[str, Any]) -> Mapping[str, Any] | None: + reasoning_text = delta.get("reasoning_text") + reasoning_opaque = delta.get("reasoning_opaque") + if reasoning_text is None and reasoning_opaque is None: + return None + + thinking_block = ChatCompletionThinkingBlock(type="thinking", thinking=reasoning_text or "") + if reasoning_opaque is not None: + thinking_block["signature"] = reasoning_opaque + + return { + **{k: v for k, v in delta.items() if k not in ("reasoning_text", "reasoning_opaque")}, + **({"reasoning_content": reasoning_text} if reasoning_text is not None else {}), + "thinking_blocks": [thinking_block], + "provider_specific_fields": { + **(delta.get("provider_specific_fields") or {}), + **({"reasoning_text": reasoning_text} if reasoning_text is not None else {}), + **({"reasoning_opaque": reasoning_opaque} if reasoning_opaque is not None else {}), + }, + } + + +def _remap_copilot_reasoning_chunk(chunk: Mapping[str, Any]) -> Mapping[str, Any] | None: + """ + GitHub Copilot streams extended thinking as `delta.reasoning_text` deltas followed by a final + `delta.reasoning_opaque` signature chunk; neither key is understood by the OpenAI schema, so + they get dropped. Returns None when the chunk carries no Copilot reasoning keys. + + See: https://github.com/BerriAI/litellm/issues/35021 + """ + choices = chunk.get("choices") or [] + remapped_deltas = tuple( + _remap_copilot_reasoning_delta(choice["delta"]) if isinstance(choice.get("delta"), dict) else None + for choice in choices + ) + if not any(delta is not None for delta in remapped_deltas): + return None + + return { + **chunk, + "choices": [ + choice if delta is None else {**choice, "delta": delta} + for choice, delta in zip(choices, remapped_deltas) + ], + } + + +class GithubCopilotChatCompletionStreamingHandler(OpenAIChatCompletionStreamingHandler): + def chunk_parser( + self, + chunk: dict, # mutable-ok: base signature takes a chunk dict + ) -> ModelResponseStream: + return super().chunk_parser(dict(_remap_copilot_reasoning_chunk(chunk) or chunk)) diff --git a/litellm/llms/openai/openai.py b/litellm/llms/openai/openai.py index 6b191144a11..0f4fd2973a0 100644 --- a/litellm/llms/openai/openai.py +++ b/litellm/llms/openai/openai.py @@ -3,6 +3,7 @@ import types from typing import ( TYPE_CHECKING, Any, + AsyncIterable, AsyncIterator, Callable, Coroutine, @@ -706,6 +707,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): logging_obj=logging_obj, headers=headers, data=data, + provider_config=provider_config, model=model, api_base=api_base, api_key=api_key, @@ -950,11 +952,31 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): body=exception_body, ) + @staticmethod + def _apply_provider_streaming_chunk_transform( + response: Union[Iterable[BaseModel], AsyncIterable[BaseModel]], + provider_config: BaseConfig, + ) -> Union[Iterator[Union[BaseModel, ModelResponseStream]], AsyncIterator[Union[BaseModel, ModelResponseStream]]]: + def transform(chunk: BaseModel) -> Union[BaseModel, ModelResponseStream]: + parsed_chunk = provider_config.transform_parsed_streaming_chunk_dict(chunk.model_dump()) + return chunk if parsed_chunk is None else ModelResponseStream(**dict(parsed_chunk)) + + if isinstance(response, AsyncIterable): + + async def aiterator() -> AsyncIterator[Union[BaseModel, ModelResponseStream]]: + async for chunk in response: + yield transform(chunk) + + return aiterator() + + return (transform(chunk) for chunk in response) + def streaming( self, logging_obj, timeout: Union[float, httpx.Timeout], data: dict, + provider_config: BaseConfig, model: str, api_key: Optional[str] = None, api_base: Optional[str] = None, @@ -998,7 +1020,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): logging_obj.model_call_details["response_headers"] = headers streamwrapper = CustomStreamWrapper( - completion_stream=response, + completion_stream=self._apply_provider_streaming_chunk_transform(response, provider_config), model=model, custom_llm_provider="openai", logging_obj=logging_obj, @@ -1070,7 +1092,7 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): ) logging_obj.model_call_details["response_headers"] = headers streamwrapper = CustomStreamWrapper( - completion_stream=response, + completion_stream=self._apply_provider_streaming_chunk_transform(response, provider_config), model=model, custom_llm_provider="openai", logging_obj=logging_obj, diff --git a/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py b/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py index f69ba7df938..f1a6193ce5f 100644 --- a/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py +++ b/tests/test_litellm/llms/github_copilot/test_github_copilot_transformation.py @@ -982,3 +982,110 @@ def test_openai_handler_repairs_github_copilot_empty_choices( assert result.choices[0].message.content == "Hi there" assert result.choices[0].finish_reason == "stop" mock_request.assert_called_once() + + +COPILOT_REASONING_SSE = ( + 'data: {"id":"c1","created":1,"model":"claude-sonnet-5","object":"chat.completion.chunk",' + '"choices":[{"index":0,"delta":{"role":"assistant","reasoning_text":"let me think"},"finish_reason":null}]}\n\n' + 'data: {"id":"c1","created":1,"model":"claude-sonnet-5","object":"chat.completion.chunk",' + '"choices":[{"index":0,"delta":{"reasoning_text":" harder"},"finish_reason":null}]}\n\n' + 'data: {"id":"c1","created":1,"model":"claude-sonnet-5","object":"chat.completion.chunk",' + '"choices":[{"index":0,"delta":{"reasoning_opaque":"opaque-sig"},"finish_reason":null}]}\n\n' + 'data: {"id":"c1","created":1,"model":"claude-sonnet-5","object":"chat.completion.chunk",' + '"choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":null}]}\n\n' + 'data: {"id":"c1","created":1,"model":"claude-sonnet-5","object":"chat.completion.chunk",' + '"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n' + "data: [DONE]\n\n" +) + + +def _patch_copilot_authenticator(): + authenticator = MagicMock() + authenticator.get_api_key.return_value = "gh.test-key-123456789" + authenticator.get_api_base.return_value = "https://api.githubcopilot.com" + return patch( + "litellm.llms.github_copilot.chat.transformation.Authenticator", + return_value=authenticator, + ), patch( + "litellm.llms.github_copilot.authenticator.Authenticator", + return_value=authenticator, + ) + + +def _assert_reasoning_stream(deltas): + assert [getattr(delta, "reasoning_content", None) for delta in deltas[:2]] == [ + "let me think", + " harder", + ] + assert deltas[0].thinking_blocks == [{"type": "thinking", "thinking": "let me think"}] + assert deltas[2].thinking_blocks == [{"type": "thinking", "thinking": "", "signature": "opaque-sig"}] + assert deltas[2].provider_specific_fields["reasoning_opaque"] == "opaque-sig" + assert deltas[3].content == "hello" + + +@pytest.mark.respx +def test_github_copilot_streaming_surfaces_reasoning_text_and_opaque(respx_mock: MockRouter): + """ + Regression test for https://github.com/BerriAI/litellm/issues/35021 + + Copilot streams thinking as `delta.reasoning_text` plus a final `delta.reasoning_opaque` + signature; both were silently dropped on the chat completions streaming path + """ + respx_mock.post("https://api.githubcopilot.com/chat/completions").mock( + return_value=httpx.Response( + 200, text=COPILOT_REASONING_SSE, headers={"content-type": "text/event-stream"} + ) + ) + transformation_patch, authenticator_patch = _patch_copilot_authenticator() + with transformation_patch, authenticator_patch: + response = litellm.completion( + model="github_copilot/claude-sonnet-5", + messages=[{"role": "user", "content": "hi"}], + stream=True, + ) + deltas = [chunk.choices[0].delta for chunk in response] + + _assert_reasoning_stream(deltas) + + +@pytest.mark.respx +@pytest.mark.asyncio +async def test_github_copilot_async_streaming_surfaces_reasoning_text_and_opaque( + respx_mock: MockRouter, monkeypatch +): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + respx_mock.post("https://api.githubcopilot.com/chat/completions").mock( + return_value=httpx.Response( + 200, text=COPILOT_REASONING_SSE, headers={"content-type": "text/event-stream"} + ) + ) + litellm.in_memory_llm_clients_cache.flush_cache() + transformation_patch, authenticator_patch = _patch_copilot_authenticator() + with transformation_patch, authenticator_patch: + response = await litellm.acompletion( + model="github_copilot/claude-sonnet-5", + messages=[{"role": "user", "content": "hi"}], + stream=True, + ) + deltas = [chunk.choices[0].delta async for chunk in response] + + _assert_reasoning_stream(deltas) + + +def test_github_copilot_streaming_chunk_without_reasoning_is_untouched(): + from litellm.llms.github_copilot.chat.transformation import GithubCopilotConfig + + with patch.object(litellm.llms.github_copilot.chat.transformation, "Authenticator", MagicMock()): + config = GithubCopilotConfig() + + assert ( + config.transform_parsed_streaming_chunk_dict( + { + "id": "c1", + "created": 1, + "model": "claude-sonnet-5", + "choices": [{"index": 0, "delta": {"content": "hi"}}], + } + ) + is None + )