mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
fix(github_copilot): surface streaming reasoning_text/reasoning_opaque on chat completions
This commit is contained in:
parent
711be72512
commit
f57aa9fc78
4 changed files with 228 additions and 5 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue