This commit is contained in:
GGbond 2026-09-13 00:12:05 +08:00 committed by GitHub
commit 52cfc79fdb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 421 additions and 27 deletions

View file

@ -20,6 +20,7 @@ from openai.types.responses.tool_choice_custom_param import ToolChoiceCustomPara
from openai.types.responses.tool_choice_function_param import ToolChoiceFunctionParam
from openai.types.responses.tool_param import FunctionToolParam
from pydantic import BaseModel
from typing_extensions import ReadOnly
import litellm
from litellm import ModelResponse
@ -105,6 +106,7 @@ class _BuiltReasoningItem(TypedDict):
id: str
encrypted_content: str | None
summary: Sequence[_ReasoningSummaryText]
content: ReadOnly[Sequence[_ReasoningSummaryText]]
def _get_reasoning_items(
@ -133,34 +135,64 @@ def _reasoning_input_items(msg: "AllMessageValues") -> list[dict[str, object]]:
return [dict(item) for item in replayed] # mutable-ok: API message payload
def _normalize_reasoning_parts(parts: Iterable[object] | None, default_type: str) -> Sequence[_ReasoningSummaryText]:
normalized: Final[list[_ReasoningSummaryText]] = [] # mutable-ok: local accumulator, returned once
for part in parts or ():
if isinstance(part, dict):
normalized.append(
{ # mutable-ok: freshly built per part
"type": part.get("type", default_type),
"text": part.get("text", ""),
}
)
else:
normalized.append(
{ # mutable-ok: freshly built per part
"type": getattr(part, "type", default_type),
"text": getattr(part, "text", ""),
}
)
return normalized
def _build_reasoning_item(
item_id: str,
encrypted_content: str | None,
summary_raw: Iterable[object] | None,
content_raw: Iterable[object] | None = None,
) -> _BuiltReasoningItem:
"""Build a ChatCompletionReasoningItem-shaped dict from raw response data.
Handles both pydantic objects (attribute access) and plain dicts.
"""
summary: Final[list[_ReasoningSummaryText]] = []
for s in summary_raw or []:
if isinstance(s, dict):
summary.append({"type": s.get("type", "summary_text"), "text": s.get("text", "")})
else:
summary.append(
{
"type": getattr(s, "type", "summary_text"),
"text": getattr(s, "text", ""),
}
)
summary: Final[Sequence[_ReasoningSummaryText]] = _normalize_reasoning_parts(
summary_raw, default_type="summary_text"
)
content: Final[Sequence[_ReasoningSummaryText]] = _normalize_reasoning_parts(
content_raw, default_type="reasoning_text"
)
return {
"id": item_id,
"type": "reasoning",
"encrypted_content": encrypted_content,
"summary": summary,
"content": content,
}
def _reasoning_content_from_built_item(reasoning_item: _BuiltReasoningItem) -> str:
"""Chat ``reasoning_content`` for a reasoning item.
Raw reasoning content (e.g. vLLM ``reasoning_text`` parts) is the full chain of
thought and wins when present; the summary is the fallback so the two are never
concatenated into duplicate text.
"""
content_text: Final = " ".join(part["text"] for part in reasoning_item["content"] if part.get("text"))
if content_text:
return content_text
return " ".join(s["text"] for s in reasoning_item["summary"] if s.get("text"))
def _reasoning_item_from_output_item(item: object) -> _BuiltReasoningItem | None:
from openai.types.responses import ResponseReasoningItem
@ -169,13 +201,24 @@ def _reasoning_item_from_output_item(item: object) -> _BuiltReasoningItem | None
item_id=item.id,
encrypted_content=getattr(item, "encrypted_content", None),
summary_raw=item.summary,
content_raw=getattr(item, "content", None),
)
if isinstance(item, dict) and item.get("type") == "reasoning":
return _build_reasoning_item(
item_id=item.get("id", ""),
encrypted_content=item.get("encrypted_content"),
summary_raw=item.get("summary"),
content_raw=item.get("content"),
)
if isinstance(item, BaseModel):
dumped: Final = item.model_dump()
if dumped.get("type") == "reasoning":
return _build_reasoning_item(
item_id=dumped.get("id", ""),
encrypted_content=dumped.get("encrypted_content"),
summary_raw=dumped.get("summary"),
content_raw=dumped.get("content"),
)
return None
@ -325,9 +368,8 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
item_type: Final = item.get("type")
# Ignore reasoning items for now
if item_type == "reasoning":
return None, index
# Reasoning dicts are intercepted by _convert_response_output_to_choices
# before this callback runs.
# Handle message items with output_text content
if item_type == "message":
@ -656,7 +698,6 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
from openai.types.responses import (
ResponseFunctionToolCall,
ResponseOutputMessage,
ResponseReasoningItem,
)
try:
@ -679,15 +720,15 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
tool_call_index = 0
for item in output_items:
if isinstance(item, ResponseReasoningItem):
pending_reasoning_item = _build_reasoning_item(
item_id=item.id,
encrypted_content=getattr(item, "encrypted_content", None),
summary_raw=item.summary,
)
reasoning_content = " ".join(s["text"] for s in pending_reasoning_item["summary"] if s.get("text"))
# Typed ResponseReasoningItem and dict-form reasoning items (from providers
# whose output skips SDK parsing, e.g. GPT-5 Codex raw items) both land here
built_reasoning_item = _reasoning_item_from_output_item(item) # rebind-ok: reassessed per loop item
if built_reasoning_item is not None:
pending_reasoning_item = built_reasoning_item
reasoning_content = _reasoning_content_from_built_item(built_reasoning_item)
continue
elif isinstance(item, ResponseOutputMessage):
if isinstance(item, ResponseOutputMessage):
for content in item.content:
response_text = getattr(content, "text", "")
# Extract annotations from content if present
@ -760,6 +801,11 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
elif handle_raw_dict_callback is not None:
choice, index = handle_raw_dict_callback(item=raw_item, index=index)
if choice is not None:
if pending_reasoning_item is not None:
choice.message.reasoning_content = reasoning_content
choice.message.reasoning_items = [pending_reasoning_item] # mutable-ok: response payload
reasoning_content = None # flush
pending_reasoning_item = None # flush
choices.append(choice)
else:
pass # don't fail request if item in list is not supported
@ -790,10 +836,9 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
reasoning_items: Final = _reasoning_items_from_output_items(output_items)
reasoning_content: Final = " ".join(
summary_block["text"]
for reasoning_item in reasoning_items
for summary_block in reasoning_item["summary"]
if summary_block.get("text")
text
for text in (_reasoning_content_from_built_item(reasoning_item) for reasoning_item in reasoning_items)
if text
)
message: Final = Message(
content="",
@ -1549,6 +1594,22 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator):
)
]
)
elif event_type == "response.reasoning_text.delta":
# Raw (non-summary) reasoning deltas, e.g. from vLLM and other
# Responses-compatible backends that expose the full reasoning content.
content_part = parsed_chunk.get("delta", None)
if content_part:
# One Responses generation maps to one Chat choice, so reasoning
# deltas stream on choice 0 like output_text deltas do; content_index
# is a part index within the reasoning item, not a choice index.
return ModelResponseStream(
choices=[ # mutable-ok: API streaming payload
StreamingChoices(
index=0,
delta=Delta(reasoning_content=content_part),
)
]
)
elif event_type in ("response.completed", "response.incomplete"):
response_data: Final = parsed_chunk.get("response", {})
output_items: Final = response_data.get("output", []) if response_data else []

View file

@ -96,6 +96,21 @@ def _redact_function_call(function_call) -> None:
function_call.arguments = REDACTED_BY_LITELLM
def _redact_reasoning_items_dict(reasoning_items: object, redacted_str: str) -> None:
"""Redact summary and raw content text inside reasoning items (dict form)."""
if not isinstance(reasoning_items, list):
return
for reasoning_item in reasoning_items:
if not isinstance(reasoning_item, dict):
continue
for key in ("summary", "content"):
parts = reasoning_item.get(key) # rebind-ok: reassessed per key
if isinstance(parts, list):
for part in parts:
if isinstance(part, dict) and part.get("text") is not None:
part["text"] = redacted_str
def _redact_choice_content(choice):
"""Helper to redact content in a choice (message or delta)."""
if isinstance(choice, litellm.Choices):
@ -105,6 +120,7 @@ def _redact_choice_content(choice):
choice.message.reasoning_content = REDACTED_BY_LITELLM
if hasattr(choice.message, "thinking_blocks"):
choice.message.thinking_blocks = None
_redact_reasoning_items_dict(getattr(choice.message, "reasoning_items", None), REDACTED_BY_LITELLM)
_redact_tool_calls(getattr(choice.message, "tool_calls", None))
_redact_function_call(getattr(choice.message, "function_call", None))
elif isinstance(choice, litellm.utils.StreamingChoices):
@ -114,6 +130,7 @@ def _redact_choice_content(choice):
choice.delta.reasoning_content = REDACTED_BY_LITELLM
if hasattr(choice.delta, "thinking_blocks"):
choice.delta.thinking_blocks = None
_redact_reasoning_items_dict(getattr(choice.delta, "reasoning_items", None), REDACTED_BY_LITELLM)
_redact_tool_calls(getattr(choice.delta, "tool_calls", None))
_redact_function_call(getattr(choice.delta, "function_call", None))
@ -226,6 +243,7 @@ def _redact_model_response_dict_choices(choices, redacted_str: str):
choice["message"]["thinking_blocks"] = None
if "audio" in choice["message"]:
choice["message"]["audio"] = None
_redact_reasoning_items_dict(choice["message"].get("reasoning_items"), redacted_str)
_redact_tool_calls_dict(choice["message"])
elif "delta" in choice and isinstance(choice["delta"], dict):
if choice["delta"].get("content") is not None:
@ -236,6 +254,7 @@ def _redact_model_response_dict_choices(choices, redacted_str: str):
choice["delta"]["thinking_blocks"] = None
if "audio" in choice["delta"]:
choice["delta"]["audio"] = None
_redact_reasoning_items_dict(choice["delta"].get("reasoning_items"), redacted_str)
_redact_tool_calls_dict(choice["delta"])
else:
_redact_choice_content(choice)

View file

@ -31639,6 +31639,13 @@
"ChatCompletionReasoningItem": {
"description": "Represents an OpenAI Responses API reasoning item for round-tripping in conversation history.",
"properties": {
"content": {
"items": {
"$ref": "#/components/schemas/ChatCompletionReasoningTextBlock"
},
"title": "Content",
"type": "array"
},
"encrypted_content": {
"anyOf": [
{
@ -31691,6 +31698,24 @@
"title": "ChatCompletionReasoningSummaryTextBlock",
"type": "object"
},
"ChatCompletionReasoningTextBlock": {
"properties": {
"text": {
"title": "Text",
"type": "string"
},
"type": {
"const": "reasoning_text",
"title": "Type",
"type": "string"
}
},
"required": [
"type"
],
"title": "ChatCompletionReasoningTextBlock",
"type": "object"
},
"ChatCompletionRedactedThinkingBlock": {
"properties": {
"cache_control": {

View file

@ -626,6 +626,11 @@ class ChatCompletionReasoningSummaryTextBlock(TypedDict, total=False):
text: str
class ChatCompletionReasoningTextBlock(TypedDict, total=False):
type: Required[Literal["reasoning_text"]] # writable-ok: Pydantic warns on ReadOnly TypedDict fields
text: str # writable-ok: Pydantic warns on ReadOnly TypedDict fields
class ChatCompletionReasoningItem(TypedDict, total=False):
"""Represents an OpenAI Responses API reasoning item for round-tripping in conversation history."""
@ -633,6 +638,7 @@ class ChatCompletionReasoningItem(TypedDict, total=False):
id: str
encrypted_content: str | None
summary: ReadOnly[list[ChatCompletionReasoningSummaryTextBlock]]
content: ReadOnly[list[ChatCompletionReasoningTextBlock]]
class WebSearchOptionsUserLocationApproximate(TypedDict, total=False):

View file

@ -4287,3 +4287,189 @@ def test_system_string_after_a_developer_message_stays_in_input_in_client_order(
assert instructions is None
assert [item["role"] for item in input_items] == ["developer", "system", "user"]
assert input_items[1] == _system_input_item("Be brief.")
def test_openai_responses_chunk_parser_reasoning_text_delta():
"""Raw reasoning_text deltas map to delta.reasoning_content (issue #40654)."""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
OpenAiResponsesToChatCompletionStreamIterator,
)
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
iterator = OpenAiResponsesToChatCompletionStreamIterator(
streaming_response=None, sync_stream=True
)
chunk = {
"content_index": 0,
"delta": "First, count the bolts.",
"item_id": "rs_a1892a8a9de47516",
"output_index": 0,
"sequence_number": 3,
"type": "response.reasoning_text.delta",
}
result = iterator.chunk_parser(chunk)
assert isinstance(result, ModelResponseStream)
assert len(result.choices) == 1
choice = result.choices[0]
assert isinstance(choice, StreamingChoices)
assert choice.index == 0
delta = choice.delta
assert isinstance(delta, Delta)
assert delta.content is None
assert delta.reasoning_content == "First, count the bolts."
assert delta.tool_calls is None
def _make_raw_reasoning_output(content_text: str | None, summary_text: str | None) -> list:
from openai.types.responses import ResponseOutputMessage, ResponseOutputText
from openai.types.responses.response_reasoning_item import (
Content,
ResponseReasoningItem,
Summary,
)
reasoning_item = ResponseReasoningItem(
id="rs_a1892a8a9de47516",
summary=[Summary(text=summary_text, type="summary_text")]
if summary_text is not None
else [],
type="reasoning",
content=[Content(text=content_text, type="reasoning_text")]
if content_text is not None
else None,
encrypted_content=None,
status=None,
)
output_message = ResponseOutputMessage(
id="msg_01",
content=[
ResponseOutputText(
annotations=[], text="303 bolts remain.", type="output_text", logprobs=[]
)
],
role="assistant",
status="completed",
type="message",
)
return [reasoning_item, output_message]
def test_convert_response_output_raw_reasoning_content_without_summary():
"""Reasoning items with raw content and an empty summary surface the raw text."""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
)
output_items = _make_raw_reasoning_output(
content_text="raw thinking trace", summary_text=None
)
choices = LiteLLMResponsesTransformationHandler._convert_response_output_to_choices(
output_items
)
assert len(choices) == 1
message = choices[0].message
assert message.reasoning_content == "raw thinking trace"
assert message.reasoning_items is not None
assert message.reasoning_items[0]["content"][0]["text"] == "raw thinking trace"
assert message.reasoning_items[0]["content"][0]["type"] == "reasoning_text"
def test_convert_response_output_mixed_reasoning_content_and_summary_no_duplication():
"""With both raw content and a summary, the raw content wins; nothing is concatenated."""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
)
output_items = _make_raw_reasoning_output(
content_text="full raw trace", summary_text="short summary"
)
choices = LiteLLMResponsesTransformationHandler._convert_response_output_to_choices(
output_items
)
assert len(choices) == 1
assert choices[0].message.reasoning_content == "full raw trace"
def test_convert_response_output_summary_only_reasoning_unchanged():
"""Summary-only reasoning items keep the pre-existing summary behavior."""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
)
output_items = _make_raw_reasoning_output(
content_text=None, summary_text="short summary"
)
choices = LiteLLMResponsesTransformationHandler._convert_response_output_to_choices(
output_items
)
assert len(choices) == 1
assert choices[0].message.reasoning_content == "short summary"
assert choices[0].message.reasoning_items[0]["content"] == []
def test_convert_response_output_dict_reasoning_carried_to_message_choice():
"""Dict-form reasoning items (SDK parsing skipped) land on the following message choice."""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
)
handler = LiteLLMResponsesTransformationHandler()
items = [
{
"type": "reasoning",
"id": "rs_dict1",
"summary": [],
"content": [{"type": "reasoning_text", "text": "dict raw trace"}],
},
{
"type": "message",
"id": "msg_dict1",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "303 bolts remain.", "annotations": []}],
},
]
choices = LiteLLMResponsesTransformationHandler._convert_response_output_to_choices(
items,
handle_raw_dict_callback=handler._handle_raw_dict_response_item,
)
assert len(choices) == 1
message = choices[0].message
assert message.content == "303 bolts remain."
assert message.reasoning_content == "dict raw trace"
assert message.reasoning_items is not None
assert message.reasoning_items[0]["content"][0]["text"] == "dict raw trace"
def test_convert_response_output_dict_reasoning_item_with_content():
"""Raw dict reasoning items (non-SDK-parsed payloads) also surface their content."""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
_reasoning_content_from_built_item,
_reasoning_items_from_output_items,
)
reasoning_items = _reasoning_items_from_output_items(
[
{
"type": "reasoning",
"id": "rs_dict",
"summary": [],
"content": [{"type": "reasoning_text", "text": "dict raw trace"}],
}
]
)
assert len(reasoning_items) == 1
assert reasoning_items[0]["content"][0]["text"] == "dict raw trace"
assert _reasoning_content_from_built_item(reasoning_items[0]) == "dict raw trace"

View file

@ -263,6 +263,91 @@ class TestPerformRedaction:
assert delta["thinking_blocks"] is None
assert delta["audio"] is None
def test_redacts_reasoning_items_in_model_response_dict_choices(self):
result = {
"choices": [
{
"message": {
"content": "message content",
"reasoning_items": [
{
"type": "reasoning",
"id": "rs_1",
"summary": [{"type": "summary_text", "text": "summary text"}],
"content": [{"type": "reasoning_text", "text": "raw reasoning"}],
}
],
}
},
{
"delta": {
"content": "delta content",
"reasoning_items": [
{
"type": "reasoning",
"id": "rs_2",
"summary": [{"type": "summary_text", "text": "delta summary"}],
"content": [{"type": "reasoning_text", "text": "delta raw reasoning"}],
}
],
}
},
]
}
redacted = perform_redaction({}, result)
message_item = redacted["choices"][0]["message"]["reasoning_items"][0]
assert message_item["summary"][0]["text"] == "redacted-by-litellm"
assert message_item["content"][0]["text"] == "redacted-by-litellm"
delta_item = redacted["choices"][1]["delta"]["reasoning_items"][0]
assert delta_item["summary"][0]["text"] == "redacted-by-litellm"
assert delta_item["content"][0]["text"] == "redacted-by-litellm"
def test_redacts_reasoning_items_on_choice_objects(self):
from litellm.litellm_core_utils.redact_messages import _redact_choice_content
from litellm.types.utils import Choices, Delta, Message, StreamingChoices
reasoning_items = [
{
"type": "reasoning",
"id": "rs_1",
"summary": [{"type": "summary_text", "text": "summary text"}],
"content": [{"type": "reasoning_text", "text": "raw reasoning"}],
}
]
message_choice = Choices(
message=Message(role="assistant", content="answer", reasoning_items=reasoning_items),
finish_reason="stop",
index=0,
)
_redact_choice_content(message_choice)
item = message_choice.message.reasoning_items[0]
assert item["summary"][0]["text"] == "redacted-by-litellm"
assert item["content"][0]["text"] == "redacted-by-litellm"
stream_choice = StreamingChoices(
delta=Delta(
content="chunk",
reasoning_items=[
{
"type": "reasoning",
"id": "rs_2",
"summary": [{"type": "summary_text", "text": "delta summary"}],
"content": [{"type": "reasoning_text", "text": "delta raw"}],
}
],
),
finish_reason=None,
index=0,
)
_redact_choice_content(stream_choice)
delta_item = stream_choice.delta.reasoning_items[0]
assert delta_item["summary"][0]["text"] == "redacted-by-litellm"
assert delta_item["content"][0]["text"] == "redacted-by-litellm"
def test_redacts_standard_logging_model_response_dict_choices(self):
details = {
"standard_logging_object": {

View file

@ -25028,6 +25028,8 @@ export interface components {
* @description Represents an OpenAI Responses API reasoning item for round-tripping in conversation history.
*/
ChatCompletionReasoningItem: {
/** Content */
content?: components["schemas"]["ChatCompletionReasoningTextBlock"][];
/** Encrypted Content */
encrypted_content?: string | null;
/** Id */
@ -25050,6 +25052,16 @@ export interface components {
*/
type: "summary_text";
};
/** ChatCompletionReasoningTextBlock */
ChatCompletionReasoningTextBlock: {
/** Text */
text?: string;
/**
* Type
* @constant
*/
type: "reasoning_text";
};
/** ChatCompletionRedactedThinkingBlock */
ChatCompletionRedactedThinkingBlock: {
/** Cache Control */