363 lines
12 KiB
Python
363 lines
12 KiB
Python
"""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",
|
|
]
|