YuMeow's picture
Deploy SRHarness v1.0.7 (181adf1a1bc5)
ed58f07 verified
Raw History Blame Contribute Delete
4.51 kB
# Copyright (c) 2026-present, Yumeow. Licensed under the MIT License.
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Iterator
from .tool import ToolCall
@dataclass(init=False)
class APICallSample:
"""One complete assistant sample returned by a model provider.
``message_extra`` contains provider message fields not represented by the
normalized content and tool calls. The :attr:`message`
property reconstructs a provider-compatible assistant message without
retaining a duplicate raw message object.
"""
content: str | None
finish_reason: str | None
_tool_calls: list[ToolCall] = field(default_factory=list, repr=False)
message_extra: dict[str, Any] = field(default_factory=dict)
def __init__(
self,
content: str | None,
tool_calls: list[ToolCall] | None = None,
message_extra: dict[str, Any] | None = None,
finish_reason: str | None = None,
) -> None:
self.content = content
self.finish_reason = finish_reason
self._tool_calls = tool_calls if tool_calls is not None else []
self.message_extra = dict(message_extra or {})
@property
def tool_calls(self) -> list[ToolCall]:
"""Return the normalized tool calls used to construct this response."""
return self._tool_calls
@property
def reasoning(self) -> Any:
"""Return provider reasoning without imposing one provider field name."""
if self.message_extra.get("reasoning"):
return self.message_extra["reasoning"]
if self.message_extra.get("reasoning_content"):
return self.message_extra["reasoning_content"]
return "".join(
item.get("text") or ""
for item in (self.message_extra.get("reasoning_details") or [])
if isinstance(item, dict)
) or None
@property
def truncated(self) -> bool:
"""Whether this sample ended at the provider's output limit."""
return self.finish_reason in {"length", "max_tokens", "max_output_tokens", "MAX_TOKENS"}
@property
def message(self) -> dict[str, Any]:
"""Reconstruct the assistant message represented by this response."""
message = {**self.message_extra, "content": self.content}
raw_tool_calls = [call.raw for call in self.tool_calls if isinstance(call.raw, dict)]
if raw_tool_calls and "tool_calls" not in message:
message["tool_calls"] = raw_tool_calls
message.setdefault("role", "assistant")
return message
@dataclass
class APICallSummary:
"""
Wrapper for LLM generator that captures the return value.
Example:
>>> api = OpenAIAPI(model='gpt-4o-mini')
>>> result = api("Hello", n=3) # Returns APICallSummary
>>> for response in result:
... print(response.content, response.tool_calls)
>>> print(result.ok, result.error)
>>> print(result.token_usage, result.money_usage)
"""
gen: Iterator[APICallSample]
yielded: list[APICallSample] = field(default_factory=list, init=False)
ok: bool | None = field(default=None, init=False)
error: str | None = field(default=None, init=False)
token_usage: dict[str, float] = field(default_factory=dict, init=False)
money_usage: dict[str, float] = field(default_factory=dict, init=False)
extra: dict[str, Any] = field(default_factory=dict, init=False)
@property
def truncated(self) -> bool:
"""Whether any returned sample reached its output limit."""
return any(sample.truncated for sample in self.yielded)
def __iter__(self):
"""Iterate over the generated contents."""
try:
while True:
response = next(self.gen)
if not isinstance(response, APICallSample):
raise TypeError(
"API providers must yield APICallSample instances, "
f"not {type(response).__name__}"
)
self.yielded.append(response)
yield response
except StopIteration as e:
result = e.value or {}
self.ok = bool(result.get("ok", True))
self.error = result.get("error")
self.token_usage = dict(result.get("token_usage") or {})
self.money_usage = dict(result.get("money_usage") or {})
self.extra = dict(result.get("extra") or {})