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>
183 行
5.1 KiB
Python
183 行
5.1 KiB
Python
"""TypedDict spec for the JSON-safe wire form of Part, Message, and Response.
|
|
|
|
These are the exact shapes returned by ``Part.to_dict()``,
|
|
``Message.to_dict()``, and ``Response.to_dict()`` — and accepted by the
|
|
matching ``from_dict`` classmethods. They are the canonical wire format;
|
|
use them to annotate any code that reads or writes serialized llm data.
|
|
|
|
Example::
|
|
|
|
from llm.serialization import MessageDict
|
|
|
|
def save_messages(conn, messages: list[MessageDict]) -> None:
|
|
for m in messages:
|
|
conn.execute(
|
|
"INSERT INTO messages(role, parts_json) VALUES (?, ?)",
|
|
(m["role"], json.dumps(m["parts"])),
|
|
)
|
|
|
|
Or pair with Pydantic's TypeAdapter for runtime validation::
|
|
|
|
from pydantic import TypeAdapter
|
|
from llm.serialization import MessageDict
|
|
|
|
msg = TypeAdapter(MessageDict).validate_python(incoming_dict)
|
|
|
|
Or export JSON Schema for cross-language consumers::
|
|
|
|
schema = TypeAdapter(MessageDict).json_schema()
|
|
|
|
The TypedDicts are erased at runtime — zero overhead. ``NotRequired``
|
|
keys may be absent from a serialized payload; required keys must
|
|
always be present.
|
|
"""
|
|
|
|
from typing import Any, Dict, List, Literal, Union
|
|
|
|
# NotRequired moved to typing in 3.11; use typing_extensions for 3.10
|
|
# support. typing_extensions is a transitive dep via pydantic.
|
|
from typing_extensions import NotRequired, TypedDict
|
|
|
|
__all__ = [
|
|
"AttachmentDict",
|
|
"AttachmentPartDict",
|
|
"MessageDict",
|
|
"PartDict",
|
|
"PromptDict",
|
|
"ReasoningPartDict",
|
|
"ResponseDict",
|
|
"TextPartDict",
|
|
"ToolCallPartDict",
|
|
"ToolResultPartDict",
|
|
"UsageDict",
|
|
]
|
|
|
|
|
|
# ---- Attachment payload (nested inside AttachmentPartDict + tool results) ----
|
|
|
|
|
|
class AttachmentDict(TypedDict, total=False):
|
|
"""Nested attachment payload. All fields optional — an Attachment
|
|
may carry a type, a url, a path, and/or base64-encoded content.
|
|
"""
|
|
|
|
type: str
|
|
url: str
|
|
path: str
|
|
# base64-encoded bytes when the attachment was constructed with raw
|
|
# content= bytes.
|
|
content: str
|
|
|
|
|
|
# ---- Per-Part TypedDicts (discriminated by the `type` field) -----------------
|
|
|
|
|
|
class TextPartDict(TypedDict):
|
|
type: Literal["text"]
|
|
text: str
|
|
provider_metadata: NotRequired[Dict[str, Any]]
|
|
|
|
|
|
class ReasoningPartDict(TypedDict):
|
|
type: Literal["reasoning"]
|
|
text: str
|
|
# `redacted=True` with `text=""` is the marker for opaque
|
|
# reasoning (OpenAI GPT-5, Gemini without thoughts). The token
|
|
# total lives on response usage, not on the Part.
|
|
redacted: NotRequired[bool]
|
|
provider_metadata: NotRequired[Dict[str, Any]]
|
|
|
|
|
|
class ToolCallPartDict(TypedDict):
|
|
type: Literal["tool_call"]
|
|
name: str
|
|
arguments: Dict[str, Any]
|
|
tool_call_id: NotRequired[str]
|
|
# True for provider-executed calls (Anthropic web search, Gemini code
|
|
# execution). Adapters use this to restore provider-side blocks on
|
|
# the next turn.
|
|
server_executed: NotRequired[bool]
|
|
provider_metadata: NotRequired[Dict[str, Any]]
|
|
|
|
|
|
class ToolResultPartDict(TypedDict):
|
|
type: Literal["tool_result"]
|
|
name: str
|
|
output: str
|
|
tool_call_id: NotRequired[str]
|
|
server_executed: NotRequired[bool]
|
|
exception: NotRequired[str]
|
|
attachments: NotRequired[List[AttachmentDict]]
|
|
provider_metadata: NotRequired[Dict[str, Any]]
|
|
|
|
|
|
class AttachmentPartDict(TypedDict):
|
|
type: Literal["attachment"]
|
|
attachment: NotRequired[AttachmentDict]
|
|
provider_metadata: NotRequired[Dict[str, Any]]
|
|
|
|
|
|
PartDict = Union[
|
|
TextPartDict,
|
|
ReasoningPartDict,
|
|
ToolCallPartDict,
|
|
ToolResultPartDict,
|
|
AttachmentPartDict,
|
|
]
|
|
"""Discriminated union of Part dict shapes. Use with
|
|
``pydantic.TypeAdapter(PartDict)`` to validate / dispatch by ``type``.
|
|
"""
|
|
|
|
|
|
# ---- Message ----------------------------------------------------------------
|
|
|
|
|
|
class MessageDict(TypedDict):
|
|
"""JSON-safe form of ``llm.Message``.
|
|
|
|
``role`` is one of "user", "assistant", "system", "tool" in practice
|
|
— typed as ``str`` here to leave room for provider-specific values.
|
|
"""
|
|
|
|
role: str
|
|
parts: List[PartDict]
|
|
provider_metadata: NotRequired[Dict[str, Any]]
|
|
|
|
|
|
# ---- Response + nested shapes -----------------------------------------------
|
|
|
|
|
|
class PromptDict(TypedDict):
|
|
"""The ``prompt`` sub-dict of ``Response.to_dict()`` — captures the
|
|
full input chain that was sent for this turn plus any options that
|
|
apply."""
|
|
|
|
messages: List[MessageDict]
|
|
options: NotRequired[Dict[str, Any]]
|
|
system: NotRequired[str]
|
|
|
|
|
|
class UsageDict(TypedDict, total=False):
|
|
"""Optional usage block on ``ResponseDict``. All fields optional;
|
|
providers vary in which they report."""
|
|
|
|
input: int
|
|
output: int
|
|
details: Dict[str, Any]
|
|
|
|
|
|
class ResponseDict(TypedDict):
|
|
"""JSON-safe form of ``llm.Response`` — everything needed for
|
|
``Response.from_dict`` to rehydrate and ``response.reply()`` to
|
|
continue a conversation across a process boundary.
|
|
"""
|
|
|
|
model: str
|
|
prompt: PromptDict
|
|
messages: List[MessageDict]
|
|
# Audit fields — present on a freshly-serialized response, optional
|
|
# on hand-constructed ones.
|
|
id: NotRequired[str]
|
|
usage: NotRequired[UsageDict]
|
|
datetime_utc: NotRequired[str]
|