simonw--llm
a2547d8183
ReasoningPart.token_count duplicated info already on response.token_details (reasoning_tokens), and the side-channel `response._reasoning_token_count` attribute with its set_usage ordering footgun was the wrong shape. Replaced with a clean StreamEvent.redacted=True marker that plugins yield like any other event. The framework hoists redacted reasoning Parts to the start of the assembled message so UIs render them before content, even though the opaque count typically arrives at the end of the stream. Also fix parallel tool calls emitted without tool_call_id (e.g. Gemini): a fresh tool_call_name now always allocates a new index instead of falling through to the prior tool-call group, so N parallel calls produce N distinct ToolCallParts instead of one with concatenated names and args. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
353 行
12 KiB
Python
353 行
12 KiB
Python
"""Part, Message, and StreamEvent value types.
|
|
|
|
Parts represent the structured content of model interactions: text,
|
|
reasoning, tool calls, tool results, and attachments. A Message wraps a
|
|
list of Parts with a role. StreamEvent wraps a streaming chunk with type
|
|
information so consumers can distinguish text from reasoning from tool
|
|
call fragments as they arrive.
|
|
|
|
These types are pure values — identity (ids, parent links, storage keys)
|
|
is a storage concern that lives elsewhere. Two Messages with identical
|
|
content are equal.
|
|
"""
|
|
|
|
import base64
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from .models import Attachment
|
|
from .serialization import (
|
|
AttachmentDict,
|
|
AttachmentPartDict,
|
|
MessageDict,
|
|
PartDict,
|
|
ReasoningPartDict,
|
|
TextPartDict,
|
|
ToolCallPartDict,
|
|
ToolResultPartDict,
|
|
)
|
|
|
|
|
|
def _attachment_to_dict(att: Attachment) -> AttachmentDict:
|
|
d: Dict[str, Any] = {}
|
|
if att.type:
|
|
d["type"] = att.type
|
|
if att.url:
|
|
d["url"] = att.url
|
|
if att.path:
|
|
d["path"] = att.path
|
|
if att.content:
|
|
d["content"] = base64.b64encode(att.content).decode("ascii")
|
|
return d # type: ignore[return-value]
|
|
|
|
|
|
def _attachment_from_dict(d: AttachmentDict) -> Attachment:
|
|
raw_content = d.get("content")
|
|
content_bytes: Optional[bytes] = None
|
|
if isinstance(raw_content, str):
|
|
content_bytes = base64.b64decode(raw_content)
|
|
return Attachment(
|
|
type=d.get("type"),
|
|
path=d.get("path"),
|
|
url=d.get("url"),
|
|
content=content_bytes,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Part:
|
|
"""Base class for all parts. Role lives on the enclosing Message."""
|
|
|
|
def to_dict(self) -> PartDict:
|
|
raise NotImplementedError
|
|
|
|
@staticmethod
|
|
def from_dict(d: PartDict) -> "Part":
|
|
if d["type"] == "text":
|
|
return TextPart(
|
|
text=d["text"],
|
|
provider_metadata=d.get("provider_metadata"),
|
|
)
|
|
if d["type"] == "reasoning":
|
|
return ReasoningPart(
|
|
text=d["text"],
|
|
redacted=d.get("redacted", False),
|
|
provider_metadata=d.get("provider_metadata"),
|
|
)
|
|
if d["type"] == "tool_call":
|
|
return ToolCallPart(
|
|
name=d["name"],
|
|
arguments=d["arguments"],
|
|
tool_call_id=d.get("tool_call_id"),
|
|
server_executed=d.get("server_executed", False),
|
|
provider_metadata=d.get("provider_metadata"),
|
|
)
|
|
if d["type"] == "tool_result":
|
|
return ToolResultPart(
|
|
name=d["name"],
|
|
output=d["output"],
|
|
tool_call_id=d.get("tool_call_id"),
|
|
server_executed=d.get("server_executed", False),
|
|
exception=d.get("exception"),
|
|
attachments=[
|
|
_attachment_from_dict(a) for a in d.get("attachments", [])
|
|
],
|
|
provider_metadata=d.get("provider_metadata"),
|
|
)
|
|
if d["type"] == "attachment":
|
|
att_dict = d.get("attachment")
|
|
attachment = _attachment_from_dict(att_dict) if att_dict else None
|
|
return AttachmentPart(
|
|
attachment=attachment,
|
|
provider_metadata=d.get("provider_metadata"),
|
|
)
|
|
raise ValueError(f"Unknown part type: {d['type']!r}")
|
|
|
|
|
|
@dataclass
|
|
class TextPart(Part):
|
|
text: str = ""
|
|
provider_metadata: Optional[Dict[str, Any]] = None
|
|
|
|
def to_dict(self) -> TextPartDict:
|
|
d: Dict[str, Any] = {"type": "text", "text": self.text}
|
|
if self.provider_metadata:
|
|
d["provider_metadata"] = self.provider_metadata
|
|
return d # type: ignore[return-value]
|
|
|
|
|
|
@dataclass
|
|
class ReasoningPart(Part):
|
|
"""Reasoning/thinking tokens from the model.
|
|
|
|
`redacted=True, text=""` is the marker for the opaque-reasoning
|
|
case (OpenAI GPT-5 series, Gemini without `includeThoughts`) where
|
|
the provider reports that reasoning happened but withholds the
|
|
content. The actual token total lives on response.token_details
|
|
(e.g. `reasoning_tokens`); the Part only records the structural
|
|
fact that reasoning occurred.
|
|
"""
|
|
|
|
text: str = ""
|
|
redacted: bool = False
|
|
provider_metadata: Optional[Dict[str, Any]] = None
|
|
|
|
def to_dict(self) -> ReasoningPartDict:
|
|
d: Dict[str, Any] = {"type": "reasoning", "text": self.text}
|
|
if self.redacted:
|
|
d["redacted"] = True
|
|
if self.provider_metadata:
|
|
d["provider_metadata"] = self.provider_metadata
|
|
return d # type: ignore[return-value]
|
|
|
|
|
|
@dataclass
|
|
class ToolCallPart(Part):
|
|
"""A request by the model to call a tool.
|
|
|
|
`server_executed=True` marks calls the provider executed on the
|
|
server (Anthropic web search, Gemini code execution) rather than
|
|
the LLM tool framework.
|
|
"""
|
|
|
|
name: str = ""
|
|
arguments: Dict[str, Any] = field(default_factory=dict)
|
|
tool_call_id: Optional[str] = None
|
|
server_executed: bool = False
|
|
provider_metadata: Optional[Dict[str, Any]] = None
|
|
|
|
def to_dict(self) -> ToolCallPartDict:
|
|
d: Dict[str, Any] = {
|
|
"type": "tool_call",
|
|
"name": self.name,
|
|
"arguments": self.arguments,
|
|
}
|
|
if self.tool_call_id is not None:
|
|
d["tool_call_id"] = self.tool_call_id
|
|
if self.server_executed:
|
|
d["server_executed"] = True
|
|
if self.provider_metadata:
|
|
d["provider_metadata"] = self.provider_metadata
|
|
return d # type: ignore[return-value]
|
|
|
|
|
|
@dataclass
|
|
class ToolResultPart(Part):
|
|
"""The result of a tool call."""
|
|
|
|
name: str = ""
|
|
output: str = ""
|
|
tool_call_id: Optional[str] = None
|
|
server_executed: bool = False
|
|
attachments: List[Any] = field(default_factory=list)
|
|
exception: Optional[str] = None
|
|
provider_metadata: Optional[Dict[str, Any]] = None
|
|
|
|
def to_dict(self) -> ToolResultPartDict:
|
|
d: Dict[str, Any] = {
|
|
"type": "tool_result",
|
|
"name": self.name,
|
|
"output": self.output,
|
|
}
|
|
if self.tool_call_id is not None:
|
|
d["tool_call_id"] = self.tool_call_id
|
|
if self.server_executed:
|
|
d["server_executed"] = True
|
|
if self.exception is not None:
|
|
d["exception"] = self.exception
|
|
if self.attachments:
|
|
d["attachments"] = [_attachment_to_dict(a) for a in self.attachments]
|
|
if self.provider_metadata:
|
|
d["provider_metadata"] = self.provider_metadata
|
|
return d # type: ignore[return-value]
|
|
|
|
|
|
@dataclass
|
|
class AttachmentPart(Part):
|
|
"""An inline attachment (image, audio, file)."""
|
|
|
|
attachment: Optional[Attachment] = None
|
|
provider_metadata: Optional[Dict[str, Any]] = None
|
|
|
|
def to_dict(self) -> AttachmentPartDict:
|
|
d: Dict[str, Any] = {"type": "attachment"}
|
|
if self.attachment:
|
|
d["attachment"] = _attachment_to_dict(self.attachment)
|
|
if self.provider_metadata:
|
|
d["provider_metadata"] = self.provider_metadata
|
|
return d # type: ignore[return-value]
|
|
|
|
|
|
@dataclass
|
|
class Message:
|
|
"""A single turn in a conversation: role + list of parts.
|
|
|
|
`parts` contains one or more Part objects. `provider_metadata`
|
|
carries opaque provider-specific data attached to the message as a
|
|
whole; part-level data lives on the individual Part's
|
|
`provider_metadata`.
|
|
"""
|
|
|
|
role: str
|
|
parts: List[Part] = field(default_factory=list)
|
|
provider_metadata: Optional[Dict[str, Any]] = None
|
|
|
|
def to_dict(self) -> MessageDict:
|
|
d: Dict[str, Any] = {
|
|
"role": self.role,
|
|
"parts": [p.to_dict() for p in self.parts],
|
|
}
|
|
if self.provider_metadata:
|
|
d["provider_metadata"] = self.provider_metadata
|
|
return d # type: ignore[return-value]
|
|
|
|
@staticmethod
|
|
def from_dict(d: MessageDict) -> "Message":
|
|
return Message(
|
|
role=d["role"],
|
|
parts=[Part.from_dict(p) for p in d.get("parts", [])],
|
|
provider_metadata=d.get("provider_metadata"),
|
|
)
|
|
|
|
|
|
def normalize_parts(items: Any) -> List[Part]:
|
|
"""Normalize helper inputs to a list of Part objects.
|
|
|
|
Accepts str (→ TextPart), Attachment (→ AttachmentPart), Part
|
|
(passed through), or a list/tuple of those (flattened one level).
|
|
"""
|
|
out: List[Part] = []
|
|
for item in items:
|
|
if isinstance(item, Part):
|
|
out.append(item)
|
|
elif isinstance(item, str):
|
|
out.append(TextPart(text=item))
|
|
elif isinstance(item, Attachment):
|
|
out.append(AttachmentPart(attachment=item))
|
|
elif isinstance(item, (list, tuple)):
|
|
out.extend(normalize_parts(item))
|
|
else:
|
|
raise TypeError(f"Cannot convert {item!r} to an llm Part")
|
|
return out
|
|
|
|
|
|
def system(*items: Any, provider_metadata: Optional[Dict[str, Any]] = None) -> Message:
|
|
"Build a Message with role='system'."
|
|
return Message(
|
|
role="system",
|
|
parts=normalize_parts(items),
|
|
provider_metadata=provider_metadata,
|
|
)
|
|
|
|
|
|
def user(*items: Any, provider_metadata: Optional[Dict[str, Any]] = None) -> Message:
|
|
"Build a Message with role='user'."
|
|
return Message(
|
|
role="user",
|
|
parts=normalize_parts(items),
|
|
provider_metadata=provider_metadata,
|
|
)
|
|
|
|
|
|
def assistant(
|
|
*items: Any, provider_metadata: Optional[Dict[str, Any]] = None
|
|
) -> Message:
|
|
"Build a Message with role='assistant'."
|
|
return Message(
|
|
role="assistant",
|
|
parts=normalize_parts(items),
|
|
provider_metadata=provider_metadata,
|
|
)
|
|
|
|
|
|
def tool_message(
|
|
*items: Any, provider_metadata: Optional[Dict[str, Any]] = None
|
|
) -> Message:
|
|
"Build a Message with role='tool' (typically wrapping ToolResultParts)."
|
|
return Message(
|
|
role="tool",
|
|
parts=normalize_parts(items),
|
|
provider_metadata=provider_metadata,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class StreamEvent:
|
|
"""A streaming event from a model response.
|
|
|
|
`part_index` groups events into parts. When left at its default of
|
|
`None`, the framework allocates an index automatically: consecutive
|
|
same-family text/reasoning events concatenate, tool-call events
|
|
group by `tool_call_id`, and `tool_result` always starts its own
|
|
part. Pass an explicit integer only to override the default
|
|
grouping (e.g. forcing a single TextPart across non-adjacent text
|
|
bursts).
|
|
|
|
`redacted=True` (only meaningful on `type="reasoning"` events with
|
|
an empty `chunk`) signals that opaque reasoning happened — content
|
|
withheld by the provider, token total on response.token_details.
|
|
The framework hoists redacted reasoning Parts to the start of the
|
|
assembled message regardless of when they were emitted in the
|
|
stream, so UIs can render them before the visible content.
|
|
|
|
`provider_metadata` carries opaque provider data (Anthropic
|
|
`signature`, Gemini `thoughtSignature`, OpenAI `encrypted_content`)
|
|
that must be echoed back on the next request; the framework merges
|
|
it onto the finalized Part (last non-None wins per top-level key).
|
|
|
|
`message_index` is for providers that emit multiple assistant
|
|
messages in a single response (Anthropic server-side tool
|
|
execution); most plugins leave it at 0.
|
|
"""
|
|
|
|
type: str # "text" / "reasoning" / "tool_call_name" /
|
|
# "tool_call_args" / "tool_result"
|
|
chunk: str
|
|
part_index: Optional[int] = None
|
|
tool_call_id: Optional[str] = None
|
|
server_executed: bool = False
|
|
tool_name: Optional[str] = None
|
|
redacted: bool = False
|
|
provider_metadata: Optional[Dict[str, Any]] = None
|
|
message_index: int = 0
|