fix(github_copilot): surface streaming reasoning_text/reasoning_opaque on chat completions

This commit is contained in:
Devin AI 2026-07-29 02:19:04 +00:00
parent 711be72512
commit f57aa9fc78
4 changed files with 228 additions and 5 deletions

View file

@ -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]

View file

@ -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))

View file

@ -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,

View file

@ -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
)