first commit
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
"""Utilities for working with SimpleLLMFunc React event streams."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import is_dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
|
||||
from SimpleLLMFunc.base.post_process import extract_content_from_stream_response
|
||||
from SimpleLLMFunc.hooks.events import CustomEvent
|
||||
from SimpleLLMFunc.hooks.stream import (
|
||||
EventYield,
|
||||
ReactOutput,
|
||||
ResponseYield,
|
||||
is_event_yield,
|
||||
is_response_yield,
|
||||
)
|
||||
from SimpleLLMFunc.utils.tui.formatters import extract_reasoning_delta
|
||||
|
||||
|
||||
def normalize_for_json(value: Any) -> Any:
|
||||
"""Recursively normalize complex event payloads into JSON-safe values."""
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, Enum):
|
||||
return value.value
|
||||
if isinstance(value, BaseException):
|
||||
return {
|
||||
"type": value.__class__.__name__,
|
||||
"message": str(value),
|
||||
}
|
||||
if isinstance(value, dict):
|
||||
return {str(key): normalize_for_json(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [normalize_for_json(item) for item in value]
|
||||
if is_dataclass(value):
|
||||
dataclass_fields = getattr(value, "__dataclass_fields__", {})
|
||||
return normalize_for_json(
|
||||
{name: getattr(value, name) for name in dataclass_fields}
|
||||
)
|
||||
if hasattr(value, "model_dump"):
|
||||
try:
|
||||
return normalize_for_json(value.model_dump())
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(value, "dict"):
|
||||
try:
|
||||
return normalize_for_json(value.dict())
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(value, "as_dict"):
|
||||
try:
|
||||
return normalize_for_json(value.as_dict())
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(value, "__dict__") and not isinstance(value, type):
|
||||
try:
|
||||
return normalize_for_json(vars(value))
|
||||
except Exception:
|
||||
pass
|
||||
return str(value)
|
||||
|
||||
|
||||
def extract_response_text(response: Any, consumer: str = "agent_stream") -> str:
|
||||
"""Extract text from a ResponseYield payload or raw provider chunk."""
|
||||
if isinstance(response, str):
|
||||
return response
|
||||
|
||||
if hasattr(response, "model_dump"):
|
||||
try:
|
||||
dumped = response.model_dump()
|
||||
text = extract_response_text(dumped, consumer)
|
||||
if text:
|
||||
return text
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
return extract_content_from_stream_response(response, consumer) or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
choices = None
|
||||
if isinstance(response, dict):
|
||||
choices = response.get("choices")
|
||||
elif hasattr(response, "choices"):
|
||||
choices = response.choices
|
||||
|
||||
if isinstance(choices, list) and choices:
|
||||
first = choices[0]
|
||||
if isinstance(first, dict):
|
||||
delta = first.get("delta")
|
||||
if isinstance(delta, dict):
|
||||
content = delta.get("content")
|
||||
return content if isinstance(content, str) else ""
|
||||
message = first.get("message")
|
||||
if isinstance(message, dict):
|
||||
content = message.get("content")
|
||||
return content if isinstance(content, str) else ""
|
||||
else:
|
||||
delta = getattr(first, "delta", None)
|
||||
content = getattr(delta, "content", None) if delta is not None else None
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
message = getattr(first, "message", None)
|
||||
content = (
|
||||
getattr(message, "content", None) if message is not None else None
|
||||
)
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
return ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def extract_output_text(output: ReactOutput, consumer: str = "agent_stream") -> str:
|
||||
"""Extract plain text from a ReactOutput if available."""
|
||||
if is_response_yield(output):
|
||||
return extract_response_text(output.response, consumer)
|
||||
return ""
|
||||
|
||||
|
||||
def extract_response_reasoning(response: Any) -> str:
|
||||
"""Extract provider reasoning text from a response chunk or full response."""
|
||||
if isinstance(response, str):
|
||||
return ""
|
||||
|
||||
if hasattr(response, "model_dump"):
|
||||
try:
|
||||
dumped = response.model_dump()
|
||||
reasoning = extract_response_reasoning(dumped)
|
||||
if reasoning:
|
||||
return reasoning
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if isinstance(response, dict):
|
||||
choices = response.get("choices")
|
||||
if isinstance(choices, list) and choices:
|
||||
first = choices[0]
|
||||
if isinstance(first, dict):
|
||||
delta = first.get("delta")
|
||||
if isinstance(delta, dict):
|
||||
for key in ("reasoning", "reasoning_content", "reasoning_text"):
|
||||
value = delta.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
details = delta.get("reasoning_details")
|
||||
if isinstance(details, list):
|
||||
collected: list[str] = []
|
||||
for detail in details:
|
||||
if not isinstance(detail, dict):
|
||||
continue
|
||||
detail_type = str(detail.get("type", ""))
|
||||
if detail_type.endswith("encrypted"):
|
||||
continue
|
||||
text = detail.get("text") or detail.get("data")
|
||||
if isinstance(text, str) and text:
|
||||
collected.append(text)
|
||||
if collected:
|
||||
return "".join(collected)
|
||||
|
||||
reasoning = extract_reasoning_delta(response)
|
||||
if reasoning:
|
||||
return reasoning
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
choices = (
|
||||
response.get("choices") if isinstance(response, dict) else response.choices
|
||||
)
|
||||
if not isinstance(choices, list) or not choices:
|
||||
return ""
|
||||
first = choices[0]
|
||||
message = (
|
||||
first.get("message")
|
||||
if isinstance(first, dict)
|
||||
else getattr(first, "message", None)
|
||||
)
|
||||
if message is None:
|
||||
return ""
|
||||
|
||||
for attr in ("reasoning", "reasoning_content", "reasoning_text"):
|
||||
value = (
|
||||
message.get(attr)
|
||||
if isinstance(message, dict)
|
||||
else getattr(message, attr, None)
|
||||
)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
|
||||
details = (
|
||||
message.get("reasoning_details")
|
||||
if isinstance(message, dict)
|
||||
else getattr(message, "reasoning_details", None)
|
||||
)
|
||||
if isinstance(details, list):
|
||||
collected: list[str] = []
|
||||
for detail in details:
|
||||
if isinstance(detail, dict):
|
||||
detail_type = str(detail.get("type", ""))
|
||||
if detail_type.endswith("encrypted"):
|
||||
continue
|
||||
text = detail.get("text") or detail.get("data")
|
||||
else:
|
||||
detail_type = str(getattr(detail, "type", ""))
|
||||
if detail_type.endswith("encrypted"):
|
||||
continue
|
||||
text = getattr(detail, "text", None) or getattr(
|
||||
detail, "data", None
|
||||
)
|
||||
if isinstance(text, str) and text:
|
||||
collected.append(text)
|
||||
return "".join(collected)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def serialize_react_output(
|
||||
output: ReactOutput,
|
||||
delta_consumer: str = "agent_stream",
|
||||
) -> Dict[str, Any]:
|
||||
"""Serialize ResponseYield/EventYield into a stable JSON payload."""
|
||||
if is_response_yield(output):
|
||||
return {
|
||||
"type": "response",
|
||||
"delta_text": extract_response_text(output.response, delta_consumer),
|
||||
"delta_reasoning": extract_response_reasoning(output.response),
|
||||
"response": normalize_for_json(output.response),
|
||||
"messages": normalize_for_json(output.messages),
|
||||
}
|
||||
|
||||
assert is_event_yield(output)
|
||||
event_type = getattr(output.event.event_type, "value", output.event.event_type)
|
||||
if isinstance(output.event, CustomEvent) and output.event.event_name:
|
||||
event_type = output.event.event_name
|
||||
return {
|
||||
"type": "event",
|
||||
"event_type": str(event_type),
|
||||
"event": normalize_for_json(output.event),
|
||||
"origin": normalize_for_json(output.origin),
|
||||
}
|
||||
|
||||
|
||||
def event_name_for_output(output: ReactOutput) -> str:
|
||||
if is_response_yield(output):
|
||||
return "response"
|
||||
assert is_event_yield(output)
|
||||
if isinstance(output.event, CustomEvent) and output.event.event_name:
|
||||
return output.event.event_name
|
||||
event_type = getattr(output.event.event_type, "value", output.event.event_type)
|
||||
return str(event_type)
|
||||
|
||||
|
||||
def format_sse(event: str, data: Dict[str, Any]) -> str:
|
||||
payload = json.dumps(data, ensure_ascii=False)
|
||||
return f"event: {event}\ndata: {payload}\n\n"
|
||||
|
||||
|
||||
def project_response_to_oai_chunk(
|
||||
output: ResponseYield,
|
||||
request_id: str,
|
||||
model: str,
|
||||
created_time: int,
|
||||
sent_role: bool,
|
||||
) -> Dict[str, Any] | None:
|
||||
"""Project a response yield into an OpenAI-compatible streaming chunk."""
|
||||
raw_response = normalize_for_json(output.response)
|
||||
if isinstance(raw_response, dict):
|
||||
if raw_response.get("object") == "chat.completion.chunk":
|
||||
return raw_response
|
||||
if raw_response.get("object") == "chat.completion":
|
||||
choices = raw_response.get("choices")
|
||||
if isinstance(choices, list) and choices:
|
||||
first = choices[0] if isinstance(choices[0], dict) else {}
|
||||
message = first.get("message") if isinstance(first, dict) else {}
|
||||
if isinstance(message, dict):
|
||||
delta: Dict[str, Any] = {
|
||||
"role": message.get("role", "assistant"),
|
||||
"content": message.get("content"),
|
||||
}
|
||||
tool_calls = message.get("tool_calls")
|
||||
if tool_calls is not None:
|
||||
delta["tool_calls"] = tool_calls
|
||||
return {
|
||||
"id": request_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created_time,
|
||||
"model": model,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": delta,
|
||||
"finish_reason": first.get("finish_reason"),
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
delta_text = extract_response_text(output.response, "agent_stream")
|
||||
tool_calls = None
|
||||
try:
|
||||
if isinstance(raw_response, dict):
|
||||
choices = raw_response.get("choices")
|
||||
if isinstance(choices, list) and choices:
|
||||
first_item = choices[0]
|
||||
delta_value: Any = {}
|
||||
if isinstance(first_item, dict):
|
||||
delta_value = first_item.get("delta")
|
||||
if isinstance(delta_value, dict):
|
||||
tool_calls = delta_value.get("tool_calls")
|
||||
except Exception:
|
||||
tool_calls = None
|
||||
|
||||
if not delta_text and not tool_calls:
|
||||
return None
|
||||
|
||||
delta: Dict[str, Any] = {}
|
||||
if not sent_role:
|
||||
delta["role"] = "assistant"
|
||||
if delta_text:
|
||||
delta["content"] = delta_text
|
||||
if tool_calls is not None:
|
||||
delta["tool_calls"] = tool_calls
|
||||
|
||||
return {
|
||||
"id": request_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created_time,
|
||||
"model": model,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": delta,
|
||||
"finish_reason": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EventYield",
|
||||
"ResponseYield",
|
||||
"event_name_for_output",
|
||||
"extract_output_text",
|
||||
"extract_response_reasoning",
|
||||
"extract_response_text",
|
||||
"format_sse",
|
||||
"is_event_yield",
|
||||
"is_response_yield",
|
||||
"normalize_for_json",
|
||||
"project_response_to_oai_chunk",
|
||||
"serialize_react_output",
|
||||
]
|
||||
Reference in New Issue
Block a user