3460 lines
122 KiB
Python
3460 lines
122 KiB
Python
import asyncio
|
|
import base64
|
|
from condense_json import condense_json
|
|
import dataclasses
|
|
from dataclasses import dataclass, field
|
|
import datetime
|
|
from .errors import NeedsKeyException
|
|
import hashlib
|
|
import httpx
|
|
from itertools import islice
|
|
from pathlib import Path
|
|
import re
|
|
import time
|
|
from types import MethodType
|
|
from typing import (
|
|
TYPE_CHECKING,
|
|
Any,
|
|
AsyncGenerator,
|
|
AsyncIterator,
|
|
Awaitable,
|
|
Callable,
|
|
Dict,
|
|
Iterable,
|
|
Iterator,
|
|
List,
|
|
Optional,
|
|
Set,
|
|
Union,
|
|
cast,
|
|
get_type_hints,
|
|
)
|
|
from .serialization import ResponseDict
|
|
|
|
if TYPE_CHECKING:
|
|
from .parts import StreamEvent
|
|
from .utils import (
|
|
ensure_fragment,
|
|
ensure_tool,
|
|
make_schema_id,
|
|
mimetype_from_path,
|
|
mimetype_from_string,
|
|
token_usage_string,
|
|
monotonic_ulid,
|
|
Fragment,
|
|
)
|
|
from abc import ABC, abstractmethod
|
|
import inspect
|
|
import json
|
|
from pydantic import BaseModel, ConfigDict, create_model
|
|
|
|
CONVERSATION_NAME_LENGTH = 32
|
|
|
|
|
|
@dataclass
|
|
class Usage:
|
|
"Token usage information from a model response."
|
|
|
|
input: Optional[int] = None
|
|
output: Optional[int] = None
|
|
details: Optional[Dict[str, Any]] = None
|
|
|
|
|
|
@dataclass
|
|
class Attachment:
|
|
"An attachment (image, audio, etc) to include with a prompt."
|
|
|
|
type: Optional[str] = None
|
|
path: Optional[str] = None
|
|
url: Optional[str] = None
|
|
content: Optional[bytes] = None
|
|
_id: Optional[str] = None
|
|
|
|
def id(self):
|
|
# Hash of the binary content, or of '{"url": "https://..."}' for URL attachments
|
|
if self._id is None:
|
|
if self.content:
|
|
self._id = hashlib.sha256(self.content).hexdigest()
|
|
elif self.path:
|
|
self._id = hashlib.sha256(Path(self.path).read_bytes()).hexdigest()
|
|
else:
|
|
self._id = hashlib.sha256(
|
|
json.dumps({"url": self.url}).encode("utf-8")
|
|
).hexdigest()
|
|
return self._id
|
|
|
|
def resolve_type(self):
|
|
"Return the content type, guessing from content if not specified."
|
|
if self.type:
|
|
return self.type
|
|
# Derive it from path or url or content
|
|
if self.path:
|
|
return mimetype_from_path(self.path)
|
|
if self.url:
|
|
response = httpx.head(self.url)
|
|
response.raise_for_status()
|
|
return response.headers.get("content-type")
|
|
if self.content:
|
|
return mimetype_from_string(self.content)
|
|
raise ValueError("Attachment has no type and no content to derive it from")
|
|
|
|
def content_bytes(self):
|
|
"Return the binary content, reading from path or URL if needed."
|
|
content = self.content
|
|
if not content:
|
|
if self.path:
|
|
content = Path(self.path).read_bytes()
|
|
elif self.url:
|
|
response = httpx.get(self.url)
|
|
response.raise_for_status()
|
|
content = response.content
|
|
return content
|
|
|
|
def base64_content(self):
|
|
"Return the content as a base64-encoded string."
|
|
return base64.b64encode(self.content_bytes()).decode("utf-8")
|
|
|
|
def __repr__(self):
|
|
info = [f"<Attachment: {self.id()}"]
|
|
if self.type:
|
|
info.append(f'type="{self.type}"')
|
|
if self.path:
|
|
info.append(f'path="{self.path}"')
|
|
if self.url:
|
|
info.append(f'url="{self.url}"')
|
|
if self.content:
|
|
info.append(f"content={len(self.content)} bytes")
|
|
return " ".join(info) + ">"
|
|
|
|
@classmethod
|
|
def from_row(cls, row):
|
|
return cls(
|
|
_id=row["id"],
|
|
type=row["type"],
|
|
path=row["path"],
|
|
url=row["url"],
|
|
content=row["content"],
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Tool:
|
|
"A tool that can be called by a model."
|
|
|
|
name: str
|
|
description: Optional[str] = None
|
|
input_schema: Dict = field(default_factory=dict)
|
|
implementation: Optional[Callable] = None
|
|
plugin: Optional[str] = None # plugin tool came from, e.g. 'llm_tools_sqlite'
|
|
|
|
def __post_init__(self):
|
|
# Convert Pydantic model to JSON schema if needed
|
|
self.input_schema = _ensure_dict_schema(self.input_schema)
|
|
|
|
def hash(self):
|
|
"""Hash for tool based on its name, description and input schema (preserving key order)"""
|
|
to_hash = {
|
|
"name": self.name,
|
|
"description": self.description,
|
|
"input_schema": self.input_schema,
|
|
}
|
|
if self.plugin:
|
|
to_hash["plugin"] = self.plugin
|
|
return hashlib.sha256(json.dumps(to_hash).encode("utf-8")).hexdigest()
|
|
|
|
@classmethod
|
|
def function(cls, function, name=None, description=None):
|
|
"""
|
|
Turn a Python function into a Tool object by:
|
|
- Extracting the function name
|
|
- Using the function docstring for the Tool description
|
|
- Building a Pydantic model for inputs by inspecting the function signature
|
|
- Building a Pydantic model for the return value by using the function's return annotation
|
|
"""
|
|
if not name and function.__name__ == "<lambda>":
|
|
raise ValueError(
|
|
"Cannot create a Tool from a lambda function without providing name="
|
|
)
|
|
|
|
return cls(
|
|
name=name or function.__name__,
|
|
description=description or function.__doc__ or None,
|
|
input_schema=_get_arguments_input_schema(function, name),
|
|
implementation=function,
|
|
)
|
|
|
|
|
|
def _get_arguments_input_schema(function, name):
|
|
signature = inspect.signature(function)
|
|
type_hints = get_type_hints(function)
|
|
fields = {}
|
|
for param_name, param in signature.parameters.items():
|
|
if param_name in ("self", "llm_tool_call"):
|
|
# llm_tool_call is reserved: populated with the ToolCall object
|
|
# at execution time, never exposed to the model.
|
|
continue
|
|
# Determine the type annotation (default to string if missing)
|
|
annotated_type = type_hints.get(param_name, str)
|
|
|
|
# Handle default value if present; if there's no default, use '...'
|
|
if param.default is inspect.Parameter.empty:
|
|
fields[param_name] = (annotated_type, ...)
|
|
else:
|
|
fields[param_name] = (annotated_type, param.default)
|
|
|
|
return create_model(f"{name}InputSchema", **fields)
|
|
|
|
|
|
def _accepts_llm_tool_call(implementation) -> bool:
|
|
try:
|
|
signature = inspect.signature(implementation)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
return "llm_tool_call" in signature.parameters
|
|
|
|
|
|
def _implementation_arguments(tool: "Tool", tool_call: "ToolCall") -> dict:
|
|
"""Arguments to invoke a tool implementation with.
|
|
|
|
Implementations with an explicit ``llm_tool_call`` parameter receive
|
|
the ToolCall object itself - a ``**kwargs`` catch-all does not count.
|
|
"""
|
|
arguments = dict(tool_call.arguments)
|
|
if _accepts_llm_tool_call(tool.implementation):
|
|
arguments["llm_tool_call"] = tool_call
|
|
return arguments
|
|
|
|
|
|
class Toolbox:
|
|
name: Optional[str] = None
|
|
instance_id: Optional[int] = None
|
|
_blocked = (
|
|
"tools",
|
|
"add_tool",
|
|
"method_tools",
|
|
"__init_subclass__",
|
|
"prepare",
|
|
"prepare_async",
|
|
)
|
|
_extra_tools: List[Tool] = []
|
|
_config: Dict[str, Any] = {}
|
|
_prepared: bool = False
|
|
_async_prepared: bool = False
|
|
|
|
def __init_subclass__(cls, **kwargs):
|
|
super().__init_subclass__(**kwargs)
|
|
|
|
original_init = cls.__init__
|
|
|
|
def wrapped_init(self, *args, **kwargs):
|
|
# Track args/kwargs passed to constructor in self._config
|
|
# so we can serialize them to a database entry later on
|
|
sig = inspect.signature(original_init)
|
|
bound = sig.bind(self, *args, **kwargs)
|
|
bound.apply_defaults()
|
|
|
|
self._config = {
|
|
name: value
|
|
for name, value in bound.arguments.items()
|
|
if name != "self"
|
|
and sig.parameters[name].kind
|
|
not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
|
}
|
|
self._extra_tools = []
|
|
|
|
original_init(self, *args, **kwargs)
|
|
|
|
cls.__init__ = wrapped_init
|
|
|
|
@classmethod
|
|
def method_tools(cls) -> List[Tool]:
|
|
tools = []
|
|
for method_name in dir(cls):
|
|
if method_name.startswith("_") or method_name in cls._blocked:
|
|
continue
|
|
method = getattr(cls, method_name)
|
|
if callable(method):
|
|
tool = Tool.function(
|
|
method,
|
|
name="{}_{}".format(cls.__name__, method_name),
|
|
)
|
|
tools.append(tool)
|
|
return tools
|
|
|
|
def tools(self) -> Iterable[Tool]:
|
|
"Returns an llm.Tool() for each class method, plus any extras registered with add_tool()"
|
|
# method_tools() returns unbound methods, we need bound methods here:
|
|
for name in dir(self):
|
|
if name.startswith("_") or name in self._blocked:
|
|
continue
|
|
attr = getattr(self, name)
|
|
if callable(attr):
|
|
tool = Tool.function(attr, name=f"{self.__class__.__name__}_{name}")
|
|
tool.plugin = getattr(self, "plugin", None)
|
|
yield tool
|
|
yield from self._extra_tools
|
|
|
|
def add_tool(
|
|
self, tool_or_function: Union[Tool, Callable[..., Any]], pass_self: bool = False
|
|
):
|
|
"Add a tool to this toolbox"
|
|
|
|
def _upgrade(fn):
|
|
if pass_self:
|
|
return MethodType(fn, self)
|
|
return fn
|
|
|
|
if isinstance(tool_or_function, Tool):
|
|
self._extra_tools.append(tool_or_function)
|
|
elif callable(tool_or_function):
|
|
self._extra_tools.append(Tool.function(_upgrade(tool_or_function)))
|
|
else:
|
|
raise ValueError("Tool must be an instance of Tool or a callable function")
|
|
|
|
def prepare(self):
|
|
"""
|
|
Over-ride this to perform setup (and .add_tool() calls) before the toolbox is used.
|
|
Implement a similar prepare_async() method for async setup.
|
|
"""
|
|
pass
|
|
|
|
async def prepare_async(self):
|
|
"""
|
|
Over-ride this to perform async setup (and .add_tool() calls) before the toolbox is used.
|
|
"""
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class ToolCall:
|
|
"A request by the model to call a tool."
|
|
|
|
name: str
|
|
arguments: dict
|
|
tool_call_id: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class ToolResult:
|
|
"The result of executing a tool call."
|
|
|
|
name: str
|
|
output: str
|
|
attachments: List[Attachment] = field(default_factory=list)
|
|
tool_call_id: Optional[str] = None
|
|
instance: Optional[Toolbox] = None
|
|
exception: Optional[Exception] = None
|
|
|
|
|
|
@dataclass
|
|
class ToolOutput:
|
|
"Tool functions can return output with extra attachments"
|
|
|
|
output: Optional[Union[str, dict, list, bool, int, float]] = None
|
|
attachments: List[Attachment] = field(default_factory=list)
|
|
|
|
|
|
ToolDef = Union[Tool, Toolbox, Callable[..., Any]]
|
|
BeforeCallSync = Callable[[Optional[Tool], ToolCall], None]
|
|
AfterCallSync = Callable[[Tool, ToolCall, ToolResult], None]
|
|
BeforeCallAsync = Callable[[Optional[Tool], ToolCall], Union[None, Awaitable[None]]]
|
|
AfterCallAsync = Callable[[Tool, ToolCall, ToolResult], Union[None, Awaitable[None]]]
|
|
|
|
|
|
class CancelToolCall(Exception):
|
|
pass
|
|
|
|
|
|
class PauseChain(Exception):
|
|
"""Raise inside a tool implementation to pause the chain.
|
|
|
|
Unlike other exceptions - which are converted into error ToolResults
|
|
and sent back to the model - PauseChain propagates out of
|
|
``execute_tool_calls()`` and ``chain()``. Before it is re-raised the
|
|
framework populates two attributes:
|
|
|
|
- ``tool_call``: the ToolCall whose implementation paused
|
|
- ``tool_results``: ToolResults of sibling calls in the same batch
|
|
that completed
|
|
|
|
Concurrent (async) sibling tool calls always run to completion
|
|
before the exception propagates; sequential (sync) execution stops
|
|
at the paused call, leaving later calls unexecuted so they can
|
|
safely run when the chain is resumed. Resume by re-running the
|
|
chain with a ``messages=`` history that ends in the unresolved tool
|
|
calls.
|
|
"""
|
|
|
|
def __init__(self, *args):
|
|
super().__init__(*args)
|
|
self.tool_call: Optional["ToolCall"] = None
|
|
self.tool_results: List["ToolResult"] = []
|
|
|
|
|
|
@dataclass
|
|
class Prompt:
|
|
"The prompt being sent to the model."
|
|
|
|
_prompt: Optional[str]
|
|
model: "Model"
|
|
fragments: Optional[List[Union[str, Fragment]]]
|
|
attachments: Optional[List[Attachment]]
|
|
_system: Optional[str]
|
|
system_fragments: Optional[List[Union[str, Fragment]]]
|
|
prompt_json: Optional[str]
|
|
schema: Optional[Union[Dict, type[BaseModel]]]
|
|
tools: List[Tool]
|
|
tool_results: List[ToolResult]
|
|
options: "Options"
|
|
hide_reasoning: bool
|
|
|
|
def __init__(
|
|
self,
|
|
prompt,
|
|
model,
|
|
*,
|
|
fragments=None,
|
|
attachments=None,
|
|
system=None,
|
|
system_fragments=None,
|
|
prompt_json=None,
|
|
options=None,
|
|
schema=None,
|
|
tools=None,
|
|
tool_results=None,
|
|
messages=None,
|
|
hide_reasoning=False,
|
|
):
|
|
self._prompt = prompt
|
|
self.model = model
|
|
self.attachments = list(attachments or [])
|
|
self.fragments = fragments or []
|
|
self._system = system
|
|
self.system_fragments = system_fragments or []
|
|
self.prompt_json = prompt_json
|
|
if schema and not isinstance(schema, dict) and issubclass(schema, BaseModel):
|
|
schema = schema.model_json_schema()
|
|
self.schema = schema
|
|
self.tools = _wrap_tools(tools or [])
|
|
self.tool_results = tool_results or []
|
|
self.options = options or {}
|
|
self.hide_reasoning = hide_reasoning
|
|
# Explicit messages= list, if the caller supplied one. Copied so
|
|
# later mutation by the caller doesn't alter the Prompt.
|
|
self._explicit_messages = list(messages) if messages is not None else None
|
|
|
|
@property
|
|
def prompt(self):
|
|
"The text of the prompt, with any fragments concatenated."
|
|
return "\n".join(self.fragments + ([self._prompt] if self._prompt else []))
|
|
|
|
@property
|
|
def system(self):
|
|
"The system prompt, with any system fragments concatenated."
|
|
return _combine_system(self._system, self.system_fragments)
|
|
|
|
@property
|
|
def messages(self):
|
|
"""Canonical list of Message objects for this prompt.
|
|
|
|
**Invariant:** this property returns exactly what the model
|
|
was (or will be) sent for this turn — the full chain including
|
|
any prior conversation history.
|
|
|
|
- If ``messages=`` was passed explicitly, it is authoritative:
|
|
returned verbatim. Other kwargs (``prompt=``, ``system=``,
|
|
``attachments=``, ``tool_results=``) are ignored for the
|
|
messages list (they remain available via ``prompt.prompt``,
|
|
``prompt.system``, etc., for adapters that still read them).
|
|
- Otherwise the list is synthesized from the legacy kwargs
|
|
(system, tool_results, prompt, attachments), producing just
|
|
the current turn — prior history is not folded in, because
|
|
no conversation context is reachable here.
|
|
|
|
Conversation.prompt / AsyncConversation.prompt / reply() all
|
|
pre-compute the full chain and pass it as ``messages=``, so
|
|
``response.prompt.messages`` after those paths is the full
|
|
chain.
|
|
"""
|
|
from .parts import (
|
|
AttachmentPart,
|
|
Message,
|
|
TextPart,
|
|
ToolResultPart,
|
|
)
|
|
|
|
if self._explicit_messages is not None:
|
|
return list(self._explicit_messages)
|
|
|
|
result: List["Message"] = []
|
|
|
|
if self.system:
|
|
result.append(Message(role="system", parts=[TextPart(text=self.system)]))
|
|
|
|
if self.tool_results:
|
|
result.append(
|
|
Message(
|
|
role="tool",
|
|
parts=[
|
|
ToolResultPart(
|
|
name=tr.name,
|
|
output=tr.output,
|
|
tool_call_id=tr.tool_call_id,
|
|
)
|
|
for tr in self.tool_results
|
|
],
|
|
)
|
|
)
|
|
|
|
user_parts: List[Any] = []
|
|
if self.prompt:
|
|
user_parts.append(TextPart(text=self.prompt))
|
|
for att in self.attachments:
|
|
user_parts.append(AttachmentPart(attachment=att))
|
|
if user_parts:
|
|
result.append(Message(role="user", parts=user_parts))
|
|
|
|
return result
|
|
|
|
|
|
def _wrap_tools(tools: List[ToolDef]) -> List[Tool]:
|
|
wrapped_tools = []
|
|
for tool in tools:
|
|
if isinstance(tool, Tool):
|
|
wrapped_tools.append(tool)
|
|
elif isinstance(tool, Toolbox):
|
|
wrapped_tools.extend(tool.tools())
|
|
elif callable(tool):
|
|
wrapped_tools.append(Tool.function(tool))
|
|
else:
|
|
raise ValueError(f"Invalid tool: {tool}")
|
|
return wrapped_tools
|
|
|
|
|
|
def _combine_system(system, system_fragments):
|
|
"Concatenate the system prompt and any system fragments into one string."
|
|
bits = [
|
|
bit.strip()
|
|
for bit in ((system_fragments or []) + [system or ""])
|
|
if bit.strip()
|
|
]
|
|
return "\n\n".join(bits)
|
|
|
|
|
|
def _merge_options(options: Optional[dict], kwargs: dict) -> dict:
|
|
if not options:
|
|
return kwargs
|
|
overlap = set(options) & set(kwargs)
|
|
if overlap:
|
|
raise TypeError(
|
|
"Got values for these options both in options= and as keyword "
|
|
"arguments: {}".format(sorted(overlap))
|
|
)
|
|
return {**options, **kwargs}
|
|
|
|
|
|
@dataclass
|
|
class _BaseConversation:
|
|
model: "_BaseModel"
|
|
id: str = field(default_factory=lambda: str(monotonic_ulid()).lower())
|
|
name: Optional[str] = None
|
|
responses: List["_BaseResponse"] = field(default_factory=list)
|
|
tools: Optional[List[ToolDef]] = None
|
|
chain_limit: Optional[int] = None
|
|
|
|
@classmethod
|
|
@abstractmethod
|
|
def from_row(cls, row: Any) -> "_BaseConversation":
|
|
raise NotImplementedError
|
|
|
|
def _build_full_chain(
|
|
self,
|
|
prompt: Optional[str],
|
|
attachments,
|
|
tool_results,
|
|
explicit_messages,
|
|
system=None,
|
|
system_fragments=None,
|
|
) -> List[Any]:
|
|
"""Build the full message chain for the next turn.
|
|
|
|
Uses the last response's stored prompt chain to recover prior
|
|
history, then appends the new turn's content (explicit messages
|
|
first, or synthesized from prompt/attachments/tool_results).
|
|
|
|
Returns the list that should be passed as ``messages=`` to the
|
|
Prompt constructor so that ``response.prompt.messages`` equals
|
|
exactly what the model sees.
|
|
|
|
If ``explicit_messages`` is provided, the caller has opted out
|
|
of history reconstruction and the list is used as-is.
|
|
"""
|
|
from .parts import (
|
|
AttachmentPart,
|
|
Message,
|
|
TextPart,
|
|
ToolResultPart,
|
|
)
|
|
|
|
if explicit_messages is not None:
|
|
return list(explicit_messages)
|
|
|
|
chain: List[Any] = []
|
|
if self.responses:
|
|
last = self.responses[-1]
|
|
# last.prompt.messages already contains the full input chain
|
|
# under the invariant, so use the last response only and then
|
|
# append that response's structured output.
|
|
chain.extend(last.prompt.messages)
|
|
chain.extend(last._messages_now())
|
|
else:
|
|
# Start with the system prompt as the first message so adapters
|
|
# that build from prompt.messages see it. On later turns it
|
|
# is already carried forward in last.prompt.messages.
|
|
system_text = _combine_system(system, system_fragments)
|
|
if system_text:
|
|
chain.append(Message(role="system", parts=[TextPart(text=system_text)]))
|
|
|
|
# Append the new turn's input
|
|
if tool_results:
|
|
chain.append(
|
|
Message(
|
|
role="tool",
|
|
parts=[
|
|
ToolResultPart(
|
|
name=tr.name,
|
|
output=tr.output,
|
|
tool_call_id=tr.tool_call_id,
|
|
)
|
|
for tr in tool_results
|
|
],
|
|
)
|
|
)
|
|
|
|
user_parts: List[Any] = []
|
|
if prompt:
|
|
user_parts.append(TextPart(text=prompt))
|
|
for att in attachments or []:
|
|
user_parts.append(AttachmentPart(attachment=att))
|
|
if user_parts:
|
|
chain.append(Message(role="user", parts=user_parts))
|
|
|
|
return chain
|
|
|
|
|
|
@dataclass
|
|
class Conversation(_BaseConversation):
|
|
before_call: Optional[BeforeCallSync] = None
|
|
after_call: Optional[AfterCallSync] = None
|
|
|
|
def prompt(
|
|
self,
|
|
prompt: Optional[str] = None,
|
|
*,
|
|
fragments: Optional[List[Union[str, Fragment]]] = None,
|
|
attachments: Optional[List[Attachment]] = None,
|
|
system: Optional[str] = None,
|
|
schema: Optional[Union[dict, type[BaseModel]]] = None,
|
|
tools: Optional[List[ToolDef]] = None,
|
|
tool_results: Optional[List[ToolResult]] = None,
|
|
system_fragments: Optional[List[Union[str, Fragment]]] = None,
|
|
messages: Optional[List[Any]] = None,
|
|
stream: bool = True,
|
|
key: Optional[str] = None,
|
|
options: Optional[dict] = None,
|
|
hide_reasoning: bool = False,
|
|
**kwargs,
|
|
) -> "Response":
|
|
merged = _merge_options(options, kwargs)
|
|
# Build the authoritative chain so response.prompt.messages
|
|
# equals exactly what the model sees for this turn.
|
|
chain = self._build_full_chain(
|
|
prompt=prompt,
|
|
attachments=attachments,
|
|
tool_results=tool_results,
|
|
explicit_messages=messages,
|
|
system=system,
|
|
system_fragments=system_fragments,
|
|
)
|
|
return Response(
|
|
Prompt(
|
|
prompt,
|
|
model=self.model,
|
|
fragments=fragments,
|
|
attachments=attachments,
|
|
system=system,
|
|
schema=schema,
|
|
tools=tools or self.tools,
|
|
tool_results=tool_results,
|
|
system_fragments=system_fragments,
|
|
messages=chain,
|
|
options=self.model.Options(**merged),
|
|
hide_reasoning=hide_reasoning,
|
|
),
|
|
self.model,
|
|
stream,
|
|
conversation=self,
|
|
key=key,
|
|
)
|
|
|
|
def chain(
|
|
self,
|
|
prompt: Optional[str] = None,
|
|
*,
|
|
fragments: Optional[List[str]] = None,
|
|
attachments: Optional[List[Attachment]] = None,
|
|
system: Optional[str] = None,
|
|
system_fragments: Optional[List[str]] = None,
|
|
messages: Optional[List[Any]] = None,
|
|
stream: bool = True,
|
|
schema: Optional[Union[dict, type[BaseModel]]] = None,
|
|
tools: Optional[List[ToolDef]] = None,
|
|
tool_results: Optional[List[ToolResult]] = None,
|
|
chain_limit: Optional[int] = None,
|
|
before_call: Optional[BeforeCallSync] = None,
|
|
after_call: Optional[AfterCallSync] = None,
|
|
key: Optional[str] = None,
|
|
options: Optional[dict] = None,
|
|
hide_reasoning: bool = False,
|
|
) -> "ChainResponse":
|
|
self.model._validate_attachments(attachments)
|
|
# Parity with Conversation.prompt: pre-bake the full chain so
|
|
# response.prompt.messages is authoritative for the first turn
|
|
# of the chain loop. Subsequent tool-result turns extend the
|
|
# chain via _chain_for_tool_results.
|
|
chain_messages = self._build_full_chain(
|
|
prompt=prompt,
|
|
attachments=attachments,
|
|
tool_results=tool_results,
|
|
explicit_messages=messages,
|
|
system=system,
|
|
system_fragments=system_fragments,
|
|
)
|
|
return ChainResponse(
|
|
Prompt(
|
|
prompt,
|
|
fragments=fragments,
|
|
attachments=attachments,
|
|
system=system,
|
|
schema=schema,
|
|
tools=tools or self.tools,
|
|
tool_results=tool_results,
|
|
system_fragments=system_fragments,
|
|
messages=chain_messages,
|
|
model=self.model,
|
|
options=self.model.Options(**(options or {})),
|
|
hide_reasoning=hide_reasoning,
|
|
),
|
|
model=self.model,
|
|
stream=stream,
|
|
conversation=self,
|
|
key=key,
|
|
before_call=before_call or self.before_call,
|
|
after_call=after_call or self.after_call,
|
|
chain_limit=chain_limit if chain_limit is not None else self.chain_limit,
|
|
)
|
|
|
|
@classmethod
|
|
def from_row(cls, row):
|
|
from llm import get_model
|
|
|
|
return cls(
|
|
model=get_model(row["model"]),
|
|
id=row["id"],
|
|
name=row["name"],
|
|
)
|
|
|
|
def __repr__(self):
|
|
count = len(self.responses)
|
|
s = "s" if count == 1 else ""
|
|
return f"<{self.__class__.__name__}: {self.id} - {count} response{s}"
|
|
|
|
|
|
@dataclass
|
|
class AsyncConversation(_BaseConversation):
|
|
before_call: Optional[BeforeCallAsync] = None
|
|
after_call: Optional[AfterCallAsync] = None
|
|
|
|
def chain(
|
|
self,
|
|
prompt: Optional[str] = None,
|
|
*,
|
|
fragments: Optional[List[str]] = None,
|
|
attachments: Optional[List[Attachment]] = None,
|
|
system: Optional[str] = None,
|
|
system_fragments: Optional[List[str]] = None,
|
|
messages: Optional[List[Any]] = None,
|
|
stream: bool = True,
|
|
schema: Optional[Union[dict, type[BaseModel]]] = None,
|
|
tools: Optional[List[ToolDef]] = None,
|
|
tool_results: Optional[List[ToolResult]] = None,
|
|
chain_limit: Optional[int] = None,
|
|
before_call: Optional[BeforeCallAsync] = None,
|
|
after_call: Optional[AfterCallAsync] = None,
|
|
key: Optional[str] = None,
|
|
options: Optional[dict] = None,
|
|
hide_reasoning: bool = False,
|
|
) -> "AsyncChainResponse":
|
|
self.model._validate_attachments(attachments)
|
|
chain_messages = self._build_full_chain(
|
|
prompt=prompt,
|
|
attachments=attachments,
|
|
tool_results=tool_results,
|
|
explicit_messages=messages,
|
|
system=system,
|
|
system_fragments=system_fragments,
|
|
)
|
|
return AsyncChainResponse(
|
|
Prompt(
|
|
prompt,
|
|
fragments=fragments,
|
|
attachments=attachments,
|
|
system=system,
|
|
schema=schema,
|
|
tools=tools or self.tools,
|
|
tool_results=tool_results,
|
|
system_fragments=system_fragments,
|
|
messages=chain_messages,
|
|
model=self.model,
|
|
options=self.model.Options(**(options or {})),
|
|
hide_reasoning=hide_reasoning,
|
|
),
|
|
model=self.model,
|
|
stream=stream,
|
|
conversation=self,
|
|
key=key,
|
|
before_call=before_call or self.before_call,
|
|
after_call=after_call or self.after_call,
|
|
chain_limit=chain_limit if chain_limit is not None else self.chain_limit,
|
|
)
|
|
|
|
def prompt(
|
|
self,
|
|
prompt: Optional[str] = None,
|
|
*,
|
|
fragments: Optional[List[str]] = None,
|
|
attachments: Optional[List[Attachment]] = None,
|
|
system: Optional[str] = None,
|
|
schema: Optional[Union[dict, type[BaseModel]]] = None,
|
|
tools: Optional[List[ToolDef]] = None,
|
|
tool_results: Optional[List[ToolResult]] = None,
|
|
system_fragments: Optional[List[str]] = None,
|
|
messages: Optional[List[Any]] = None,
|
|
stream: bool = True,
|
|
key: Optional[str] = None,
|
|
options: Optional[dict] = None,
|
|
hide_reasoning: bool = False,
|
|
**kwargs,
|
|
) -> "AsyncResponse":
|
|
merged = _merge_options(options, kwargs)
|
|
chain = self._build_full_chain(
|
|
prompt=prompt,
|
|
attachments=attachments,
|
|
tool_results=tool_results,
|
|
explicit_messages=messages,
|
|
system=system,
|
|
system_fragments=system_fragments,
|
|
)
|
|
return AsyncResponse(
|
|
Prompt(
|
|
prompt,
|
|
model=self.model,
|
|
fragments=fragments,
|
|
attachments=attachments,
|
|
system=system,
|
|
schema=schema,
|
|
tools=tools,
|
|
tool_results=tool_results,
|
|
system_fragments=system_fragments,
|
|
messages=chain,
|
|
options=self.model.Options(**merged),
|
|
hide_reasoning=hide_reasoning,
|
|
),
|
|
self.model,
|
|
stream,
|
|
conversation=self,
|
|
key=key,
|
|
)
|
|
|
|
def to_sync_conversation(self):
|
|
return Conversation(
|
|
model=self.model,
|
|
id=self.id,
|
|
name=self.name,
|
|
responses=[], # Because we only use this in logging
|
|
tools=self.tools,
|
|
chain_limit=self.chain_limit,
|
|
)
|
|
|
|
@classmethod
|
|
def from_row(cls, row):
|
|
from llm import get_async_model
|
|
|
|
return cls(
|
|
model=get_async_model(row["model"]),
|
|
id=row["id"],
|
|
name=row["name"],
|
|
)
|
|
|
|
def __repr__(self):
|
|
count = len(self.responses)
|
|
s = "s" if count == 1 else ""
|
|
return f"<{self.__class__.__name__}: {self.id} - {count} response{s}"
|
|
|
|
|
|
FRAGMENT_SQL = """
|
|
select
|
|
'prompt' as fragment_type,
|
|
fragments.content,
|
|
pf."order" as ord
|
|
from prompt_fragments pf
|
|
join fragments on pf.fragment_id = fragments.id
|
|
where pf.response_id = :response_id
|
|
union all
|
|
select
|
|
'system' as fragment_type,
|
|
fragments.content,
|
|
sf."order" as ord
|
|
from system_fragments sf
|
|
join fragments on sf.fragment_id = fragments.id
|
|
where sf.response_id = :response_id
|
|
order by fragment_type desc, ord asc;
|
|
"""
|
|
|
|
|
|
class _BaseResponse:
|
|
"""Base response class shared between sync and async responses"""
|
|
|
|
id: str
|
|
prompt: "Prompt"
|
|
stream: bool
|
|
resolved_model: Optional[str] = None
|
|
conversation: Optional["_BaseConversation"] = None
|
|
_key: Optional[str] = None
|
|
_tool_calls: List[ToolCall] = []
|
|
|
|
def __init__(
|
|
self,
|
|
prompt: Prompt,
|
|
model: "_BaseModel",
|
|
stream: bool,
|
|
conversation: Optional[_BaseConversation] = None,
|
|
key: Optional[str] = None,
|
|
):
|
|
self.id = str(monotonic_ulid()).lower()
|
|
self.prompt = prompt
|
|
self._prompt_json = None
|
|
self.model = model
|
|
self.stream = stream
|
|
self._key = key
|
|
self._chunks: List[str] = []
|
|
# Every StreamEvent ever yielded by execute(), in order. Plain
|
|
# str yields are wrapped as text events (with part_index resolved
|
|
# by _resolve_part_index) so this buffer is the single source of
|
|
# truth for replay and for assembling response.messages.
|
|
self._stream_events: List[Any] = []
|
|
# Auto-allocator state for resolving StreamEvent.part_index=None.
|
|
# Plugins yield events with part_index=None (the default) and
|
|
# the framework assigns concrete integers based on context:
|
|
# consecutive same-family text/reasoning events concatenate,
|
|
# tool calls group by tool_call_id, and tool_result is always
|
|
# its own part. _auto_index_max tracks the highest index seen
|
|
# (explicit or allocated); _auto_last_index / _auto_last_family
|
|
# remember the previously-resolved event so same-family runs
|
|
# share an index; _auto_tool_id_to_index maps known tool ids to
|
|
# their assigned index for parallel-tool-call grouping.
|
|
self._auto_index_max: int = -1
|
|
self._auto_last_index: Optional[int] = None
|
|
self._auto_last_family: Optional[str] = None
|
|
self._auto_tool_id_to_index: Dict[str, int] = {}
|
|
self._done = False
|
|
self._tool_calls: List[ToolCall] = []
|
|
self.response_json: Optional[Dict[str, Any]] = None
|
|
self.conversation = conversation
|
|
self.attachments: List[Attachment] = []
|
|
self._start: Optional[float] = None
|
|
self._end: Optional[float] = None
|
|
self._start_utcnow: Optional[datetime.datetime] = None
|
|
self.input_tokens: Optional[int] = None
|
|
self.output_tokens: Optional[int] = None
|
|
self.token_details: Optional[dict] = None
|
|
self.done_callbacks: List[Callable] = []
|
|
|
|
if self.prompt.schema and not self.model.supports_schema:
|
|
raise ValueError(f"{self.model} does not support schemas")
|
|
|
|
if self.prompt.tools and not self.model.supports_tools:
|
|
raise ValueError(f"{self.model} does not support tools")
|
|
|
|
def _messages_now(self) -> List[Any]:
|
|
"""Assemble messages assuming the response is already drained.
|
|
|
|
Public ``messages()`` forces / awaits first, then delegates here.
|
|
Internal sync paths (``_response_to_dict``,
|
|
``_chain_for_tool_results``) call this directly so they don't
|
|
have to await on async responses.
|
|
"""
|
|
from .parts import Message
|
|
|
|
loaded = getattr(self, "_loaded_messages", None)
|
|
if loaded is not None:
|
|
return list(loaded)
|
|
parts = self._build_parts()
|
|
if not parts:
|
|
return []
|
|
return [Message(role="assistant", parts=parts)]
|
|
|
|
@staticmethod
|
|
def _event_family(event_type: str) -> str:
|
|
if event_type in ("tool_call_name", "tool_call_args"):
|
|
return "tool_call"
|
|
return event_type
|
|
|
|
def _resolve_part_index(self, event):
|
|
"""Mutate event.part_index in place when the plugin left it None.
|
|
|
|
Resolution rules: consecutive same-family text/reasoning events
|
|
share an index; tool-call events are grouped by tool_call_id;
|
|
tool_result always allocates a fresh index. Explicit indices
|
|
pass through but update the allocator's bookkeeping so future
|
|
None resolutions avoid collisions.
|
|
"""
|
|
fam = self._event_family(event.type)
|
|
|
|
if event.part_index is not None:
|
|
if event.part_index > self._auto_index_max:
|
|
self._auto_index_max = event.part_index
|
|
if (
|
|
event.type in ("tool_call_name", "tool_call_args")
|
|
and event.tool_call_id
|
|
):
|
|
self._auto_tool_id_to_index[event.tool_call_id] = event.part_index
|
|
self._auto_last_index = event.part_index
|
|
self._auto_last_family = fam
|
|
return
|
|
|
|
if event.type in ("tool_call_name", "tool_call_args"):
|
|
if event.tool_call_id:
|
|
existing = self._auto_tool_id_to_index.get(event.tool_call_id)
|
|
if existing is not None:
|
|
event.part_index = existing
|
|
self._auto_last_index = existing
|
|
self._auto_last_family = "tool_call"
|
|
return
|
|
self._auto_index_max += 1
|
|
new_idx = self._auto_index_max
|
|
self._auto_tool_id_to_index[event.tool_call_id] = new_idx
|
|
event.part_index = new_idx
|
|
self._auto_last_index = new_idx
|
|
self._auto_last_family = "tool_call"
|
|
return
|
|
# No tool_call_id — providers like Gemini omit the id on
|
|
# parallel tool calls. tool_call_args events glue onto the
|
|
# most recent tool-call index; a fresh tool_call_name
|
|
# always starts a new part (otherwise N parallel tool calls
|
|
# collapse into one with concatenated names and args).
|
|
if (
|
|
event.type == "tool_call_args"
|
|
and self._auto_last_family == "tool_call"
|
|
and self._auto_last_index is not None
|
|
):
|
|
event.part_index = self._auto_last_index
|
|
return
|
|
self._auto_index_max += 1
|
|
new_idx = self._auto_index_max
|
|
event.part_index = new_idx
|
|
self._auto_last_index = new_idx
|
|
self._auto_last_family = "tool_call"
|
|
return
|
|
|
|
if event.type == "tool_result":
|
|
self._auto_index_max += 1
|
|
new_idx = self._auto_index_max
|
|
event.part_index = new_idx
|
|
self._auto_last_index = new_idx
|
|
self._auto_last_family = "tool_result"
|
|
return
|
|
|
|
# text / reasoning: same family as previous → reuse, else new.
|
|
if self._auto_last_family == fam and self._auto_last_index is not None:
|
|
event.part_index = self._auto_last_index
|
|
return
|
|
self._auto_index_max += 1
|
|
new_idx = self._auto_index_max
|
|
event.part_index = new_idx
|
|
self._auto_last_index = new_idx
|
|
self._auto_last_family = fam
|
|
|
|
def _process_chunk(self, chunk):
|
|
"""Normalize a chunk from execute() into a StreamEvent and return
|
|
the text str (or None) that __iter__ should yield.
|
|
|
|
Plain str yields from legacy plugins are wrapped as text events
|
|
with an auto-allocated part_index. Side effects: populates
|
|
self._stream_events and self._chunks.
|
|
"""
|
|
from .parts import StreamEvent
|
|
|
|
if isinstance(chunk, StreamEvent):
|
|
self._resolve_part_index(chunk)
|
|
self._stream_events.append(chunk)
|
|
if chunk.type == "text":
|
|
self._chunks.append(chunk.chunk)
|
|
return chunk.chunk
|
|
return None
|
|
# Legacy plain-str plugin.
|
|
event = StreamEvent(type="text", chunk=chunk)
|
|
self._resolve_part_index(event)
|
|
self._stream_events.append(event)
|
|
self._chunks.append(chunk)
|
|
return chunk
|
|
|
|
def _build_parts(self) -> List[Any]:
|
|
"""Assemble Part objects from the accumulated stream events.
|
|
|
|
Events sharing a part_index group into one Part. Mixing
|
|
families (text vs tool_call vs reasoning vs tool_result) at the
|
|
same index is a plugin bug — raises ValueError instead of
|
|
silently dropping content.
|
|
|
|
Fallback: when no stream events were recorded (response was
|
|
rehydrated from SQLite via ``from_row``), synthesize a
|
|
TextPart from ``self._chunks`` plus any ``self._tool_calls``
|
|
restored by the row loader. Reasoning signatures are not
|
|
recoverable from SQLite in this fallback — use
|
|
``response.to_dict()`` / ``Response.from_dict()`` for
|
|
structure-preserving persistence.
|
|
"""
|
|
from .parts import (
|
|
ReasoningPart,
|
|
TextPart,
|
|
ToolCallPart,
|
|
ToolResultPart,
|
|
)
|
|
|
|
if not self._stream_events:
|
|
# Rehydrated-from-SQLite path: assemble from _chunks +
|
|
# _tool_calls so response.messages isn't empty after
|
|
# from_row, and Conversation.prompt-built chains include
|
|
# the assistant turn on follow-up calls.
|
|
fallback_parts: List[Any] = []
|
|
text = "".join(self._chunks)
|
|
if text:
|
|
fallback_parts.append(TextPart(text=text))
|
|
for tc in self._tool_calls:
|
|
fallback_parts.append(
|
|
ToolCallPart(
|
|
name=tc.name,
|
|
arguments=tc.arguments or {},
|
|
tool_call_id=tc.tool_call_id,
|
|
)
|
|
)
|
|
return fallback_parts
|
|
|
|
# Group events by their (resolved) part_index, preserving the
|
|
# order in which each index was first seen. Then build one Part
|
|
# per group. This handles non-adjacent same-index events (e.g.
|
|
# text → tool_call → text where the plugin pinned both text
|
|
# bursts to part_index=0) by merging them into one Part.
|
|
groups: Dict[int, List[Any]] = {}
|
|
order: List[int] = []
|
|
for event in self._stream_events:
|
|
pi = event.part_index
|
|
if pi not in groups:
|
|
groups[pi] = []
|
|
order.append(pi)
|
|
groups[pi].append(event)
|
|
|
|
parts: List[Any] = []
|
|
for pi in order:
|
|
evs = groups[pi]
|
|
fam_first = self._event_family(evs[0].type)
|
|
for e in evs:
|
|
if self._event_family(e.type) != fam_first:
|
|
raise ValueError(
|
|
f"StreamEvent type {e.type!r} is incompatible with "
|
|
f"prior type at part_index={pi}. "
|
|
"Allocate a new part_index for a different content type."
|
|
)
|
|
|
|
pm_merged: Optional[Dict[str, Any]] = None
|
|
for e in evs:
|
|
if e.provider_metadata:
|
|
merged = dict(pm_merged) if pm_merged else {}
|
|
for k, v in e.provider_metadata.items():
|
|
merged[k] = v
|
|
pm_merged = merged
|
|
|
|
if fam_first == "text":
|
|
text = "".join(e.chunk for e in evs)
|
|
if text:
|
|
parts.append(TextPart(text=text, provider_metadata=pm_merged))
|
|
elif fam_first == "reasoning":
|
|
text = "".join(e.chunk for e in evs)
|
|
redacted = any(e.redacted for e in evs)
|
|
if text or redacted:
|
|
parts.append(
|
|
ReasoningPart(
|
|
text=text,
|
|
redacted=redacted,
|
|
provider_metadata=pm_merged,
|
|
)
|
|
)
|
|
elif fam_first == "tool_call":
|
|
tool_name = "".join(e.chunk for e in evs if e.type == "tool_call_name")
|
|
args_str = "".join(e.chunk for e in evs if e.type == "tool_call_args")
|
|
try:
|
|
arguments = json.loads(args_str) if args_str else {}
|
|
except json.JSONDecodeError:
|
|
arguments = {"_raw": args_str}
|
|
tool_call_id = next(
|
|
(e.tool_call_id for e in evs if e.tool_call_id), None
|
|
)
|
|
server_executed = any(e.server_executed for e in evs)
|
|
parts.append(
|
|
ToolCallPart(
|
|
name=tool_name,
|
|
arguments=arguments,
|
|
tool_call_id=tool_call_id,
|
|
server_executed=server_executed,
|
|
provider_metadata=pm_merged,
|
|
)
|
|
)
|
|
elif fam_first == "tool_result":
|
|
tool_result_name = next((e.tool_name for e in evs if e.tool_name), "")
|
|
tool_call_id = next(
|
|
(e.tool_call_id for e in evs if e.tool_call_id), None
|
|
)
|
|
server_executed = any(e.server_executed for e in evs)
|
|
parts.append(
|
|
ToolResultPart(
|
|
name=tool_result_name,
|
|
output="".join(e.chunk for e in evs),
|
|
tool_call_id=tool_call_id,
|
|
server_executed=server_executed,
|
|
provider_metadata=pm_merged,
|
|
)
|
|
)
|
|
|
|
# Merge in any tool calls registered via add_tool_call() that the
|
|
# plugin didn't also emit as StreamEvents. Dedup by tool_call_id so
|
|
# plugins using both APIs in tandem don't double-count.
|
|
seen_ids = {
|
|
p.tool_call_id
|
|
for p in parts
|
|
if isinstance(p, ToolCallPart) and p.tool_call_id is not None
|
|
}
|
|
for tc in self._tool_calls:
|
|
if tc.tool_call_id is not None and tc.tool_call_id in seen_ids:
|
|
continue
|
|
parts.append(
|
|
ToolCallPart(
|
|
name=tc.name,
|
|
arguments=tc.arguments or {},
|
|
tool_call_id=tc.tool_call_id,
|
|
)
|
|
)
|
|
|
|
# Hoist redacted reasoning Parts to the start of the assembled
|
|
# message. Plugins typically emit them late (when usage arrives
|
|
# in the final chunk), but UIs render reasoning before content,
|
|
# so the framework reorders. Relative order among redacted
|
|
# Parts is preserved.
|
|
redacted_parts = [
|
|
p for p in parts if isinstance(p, ReasoningPart) and p.redacted
|
|
]
|
|
if redacted_parts:
|
|
other_parts = [
|
|
p for p in parts if not (isinstance(p, ReasoningPart) and p.redacted)
|
|
]
|
|
parts = redacted_parts + other_parts
|
|
|
|
return parts
|
|
|
|
def add_tool_call(self, tool_call: ToolCall):
|
|
if tool_call.tool_call_id is None:
|
|
# Guarantee every locally-executable tool call has a unique id.
|
|
# Some providers never supply one, which otherwise forces every
|
|
# consumer correlating calls with results (or keying external
|
|
# state on a call) to invent fallback matching schemes.
|
|
tool_call = dataclasses.replace(
|
|
tool_call,
|
|
tool_call_id="tc_{}".format(str(monotonic_ulid()).lower()),
|
|
)
|
|
self._tool_calls.append(tool_call)
|
|
|
|
def set_usage(
|
|
self,
|
|
*,
|
|
input: Optional[int] = None,
|
|
output: Optional[int] = None,
|
|
details: Optional[dict] = None,
|
|
):
|
|
self.input_tokens = input
|
|
self.output_tokens = output
|
|
self.token_details = details
|
|
|
|
def set_resolved_model(self, model_id: str):
|
|
self.resolved_model = model_id
|
|
|
|
@classmethod
|
|
def from_row(cls, db, row, _async=False):
|
|
from llm import get_model, get_async_model
|
|
|
|
if _async:
|
|
model = get_async_model(row["model"])
|
|
else:
|
|
model = get_model(row["model"])
|
|
|
|
# Schema
|
|
schema = None
|
|
if row["schema_id"]:
|
|
schema = json.loads(db["schemas"].get(row["schema_id"])["content"])
|
|
|
|
# Tool definitions and results for prompt
|
|
tools = [
|
|
Tool(
|
|
name=tool_row["name"],
|
|
description=tool_row["description"],
|
|
input_schema=json.loads(tool_row["input_schema"]),
|
|
# In this case we don't have a reference to the actual Python code
|
|
# but that's OK, we should not need it for prompts deserialized from DB
|
|
implementation=None,
|
|
plugin=tool_row["plugin"],
|
|
)
|
|
for tool_row in db.query(
|
|
"""
|
|
select tools.* from tools
|
|
join tool_responses on tools.id = tool_responses.tool_id
|
|
where tool_responses.response_id = ?
|
|
""",
|
|
[row["id"]],
|
|
)
|
|
]
|
|
tool_results = [
|
|
ToolResult(
|
|
name=tool_results_row["name"],
|
|
output=tool_results_row["output"],
|
|
tool_call_id=tool_results_row["tool_call_id"],
|
|
)
|
|
for tool_results_row in db.query(
|
|
"""
|
|
select * from tool_results
|
|
where response_id = ?
|
|
""",
|
|
[row["id"]],
|
|
)
|
|
]
|
|
|
|
all_fragments = list(db.query(FRAGMENT_SQL, {"response_id": row["id"]}))
|
|
fragments = [
|
|
row["content"] for row in all_fragments if row["fragment_type"] == "prompt"
|
|
]
|
|
system_fragments = [
|
|
row["content"] for row in all_fragments if row["fragment_type"] == "system"
|
|
]
|
|
response = cls(
|
|
model=model,
|
|
prompt=Prompt(
|
|
prompt=row["prompt"],
|
|
model=model,
|
|
fragments=fragments,
|
|
attachments=[],
|
|
system=row["system"],
|
|
schema=schema,
|
|
tools=tools,
|
|
tool_results=tool_results,
|
|
system_fragments=system_fragments,
|
|
options=model.Options(**json.loads(row["options_json"])),
|
|
),
|
|
stream=False,
|
|
)
|
|
prompt_json = json.loads(row["prompt_json"] or "null")
|
|
response.id = row["id"]
|
|
response._prompt_json = prompt_json
|
|
response.response_json = json.loads(row["response_json"] or "null")
|
|
response._done = True
|
|
response._chunks = [row["response"]]
|
|
# Attachments
|
|
response.attachments = [
|
|
Attachment.from_row(attachment_row)
|
|
for attachment_row in db.query(
|
|
"""
|
|
select attachments.* from attachments
|
|
join prompt_attachments on attachments.id = prompt_attachments.attachment_id
|
|
where prompt_attachments.response_id = ?
|
|
order by prompt_attachments."order"
|
|
""",
|
|
[row["id"]],
|
|
)
|
|
]
|
|
# Tool calls
|
|
response._tool_calls = [
|
|
ToolCall(
|
|
name=tool_row["name"],
|
|
arguments=json.loads(tool_row["arguments"]),
|
|
tool_call_id=tool_row["tool_call_id"],
|
|
)
|
|
for tool_row in db.query(
|
|
"""
|
|
select * from tool_calls
|
|
where response_id = ?
|
|
order by tool_call_id
|
|
""",
|
|
[row["id"]],
|
|
)
|
|
]
|
|
|
|
return response
|
|
|
|
def token_usage(self) -> str:
|
|
return token_usage_string(
|
|
self.input_tokens, self.output_tokens, self.token_details
|
|
)
|
|
|
|
def log_to_db(self, db):
|
|
conversation = self.conversation
|
|
if not conversation:
|
|
conversation = Conversation(model=self.model)
|
|
db["conversations"].insert(
|
|
{
|
|
"id": conversation.id,
|
|
"name": _conversation_name(
|
|
self.prompt.prompt or self.prompt.system or ""
|
|
),
|
|
"model": conversation.model.model_id,
|
|
},
|
|
ignore=True,
|
|
)
|
|
schema_id = None
|
|
if self.prompt.schema:
|
|
schema_id, schema_json = make_schema_id(self.prompt.schema)
|
|
db["schemas"].insert({"id": schema_id, "content": schema_json}, ignore=True)
|
|
|
|
response_id = self.id
|
|
replacements = {}
|
|
# Include replacements from previous responses
|
|
for previous_response in conversation.responses[:-1]:
|
|
for fragment in (previous_response.prompt.fragments or []) + (
|
|
previous_response.prompt.system_fragments or []
|
|
):
|
|
fragment_id = ensure_fragment(db, fragment)
|
|
replacements[f"f:{fragment_id}"] = fragment
|
|
replacements[f"r:{previous_response.id}"] = (
|
|
previous_response.text_or_raise()
|
|
)
|
|
|
|
for i, fragment in enumerate(self.prompt.fragments):
|
|
fragment_id = ensure_fragment(db, fragment)
|
|
replacements[f"f{fragment_id}"] = fragment
|
|
db["prompt_fragments"].insert(
|
|
{
|
|
"response_id": response_id,
|
|
"fragment_id": fragment_id,
|
|
"order": i,
|
|
},
|
|
)
|
|
for i, fragment in enumerate(self.prompt.system_fragments):
|
|
fragment_id = ensure_fragment(db, fragment)
|
|
replacements[f"f{fragment_id}"] = fragment
|
|
db["system_fragments"].insert(
|
|
{
|
|
"response_id": response_id,
|
|
"fragment_id": fragment_id,
|
|
"order": i,
|
|
},
|
|
)
|
|
|
|
response_text = self.text_or_raise()
|
|
replacements[f"r:{response_id}"] = response_text
|
|
# Concatenate visible reasoning text from the assembled
|
|
# ReasoningPart entries; redacted markers contribute nothing.
|
|
from .parts import ReasoningPart
|
|
|
|
reasoning_text = "".join(
|
|
p.text
|
|
for m in self._messages_now()
|
|
for p in m.parts
|
|
if isinstance(p, ReasoningPart) and p.text
|
|
)
|
|
json_data = self.json()
|
|
|
|
response = {
|
|
"id": response_id,
|
|
"model": self.model.model_id,
|
|
"prompt": self.prompt._prompt,
|
|
"system": self.prompt._system,
|
|
"prompt_json": condense_json(self._prompt_json, replacements),
|
|
"options_json": {
|
|
key: value
|
|
for key, value in dict(self.prompt.options).items()
|
|
if value is not None
|
|
},
|
|
"response": response_text,
|
|
"reasoning": reasoning_text or None,
|
|
"response_json": condense_json(json_data, replacements),
|
|
"conversation_id": conversation.id,
|
|
"duration_ms": self.duration_ms(),
|
|
"datetime_utc": self.datetime_utc(),
|
|
"input_tokens": self.input_tokens,
|
|
"output_tokens": self.output_tokens,
|
|
"token_details": (
|
|
json.dumps(self.token_details) if self.token_details else None
|
|
),
|
|
"schema_id": schema_id,
|
|
"resolved_model": self.resolved_model,
|
|
}
|
|
db["responses"].insert(response)
|
|
|
|
# Persist any attachments - loop through with index
|
|
for index, attachment in enumerate(self.prompt.attachments):
|
|
attachment_id = attachment.id()
|
|
db["attachments"].insert(
|
|
{
|
|
"id": attachment_id,
|
|
"type": attachment.resolve_type(),
|
|
"path": attachment.path,
|
|
"url": attachment.url,
|
|
"content": attachment.content,
|
|
},
|
|
replace=True,
|
|
)
|
|
db["prompt_attachments"].insert(
|
|
{
|
|
"response_id": response_id,
|
|
"attachment_id": attachment_id,
|
|
"order": index,
|
|
},
|
|
)
|
|
|
|
# Persist any tools, tool calls and tool results
|
|
tool_ids_by_name = {}
|
|
for tool in self.prompt.tools:
|
|
tool_id = ensure_tool(db, tool)
|
|
tool_ids_by_name[tool.name] = tool_id
|
|
db["tool_responses"].insert(
|
|
{
|
|
"tool_id": tool_id,
|
|
"response_id": response_id,
|
|
}
|
|
)
|
|
for tool_call in self.tool_calls(): # TODO Should be _or_raise()
|
|
db["tool_calls"].insert(
|
|
{
|
|
"response_id": response_id,
|
|
"tool_id": tool_ids_by_name.get(tool_call.name) or None,
|
|
"name": tool_call.name,
|
|
"arguments": json.dumps(tool_call.arguments),
|
|
"tool_call_id": tool_call.tool_call_id,
|
|
}
|
|
)
|
|
for tool_result in self.prompt.tool_results:
|
|
instance_id = None
|
|
if tool_result.instance:
|
|
try:
|
|
if not tool_result.instance.instance_id:
|
|
tool_result.instance.instance_id = (
|
|
db["tool_instances"]
|
|
.insert(
|
|
{
|
|
"plugin": tool.plugin,
|
|
"name": tool.name.split("_")[0],
|
|
"arguments": json.dumps(
|
|
tool_result.instance._config
|
|
),
|
|
}
|
|
)
|
|
.last_pk
|
|
)
|
|
instance_id = tool_result.instance.instance_id
|
|
except AttributeError:
|
|
pass
|
|
tool_result_id = (
|
|
db["tool_results"]
|
|
.insert(
|
|
{
|
|
"response_id": response_id,
|
|
"tool_id": tool_ids_by_name.get(tool_result.name) or None,
|
|
"name": tool_result.name,
|
|
"output": tool_result.output,
|
|
"tool_call_id": tool_result.tool_call_id,
|
|
"instance_id": instance_id,
|
|
"exception": (
|
|
(
|
|
"{}: {}".format(
|
|
tool_result.exception.__class__.__name__,
|
|
str(tool_result.exception),
|
|
)
|
|
)
|
|
if tool_result.exception
|
|
else None
|
|
),
|
|
}
|
|
)
|
|
.last_pk
|
|
)
|
|
# Persist attachments for tool results
|
|
for index, attachment in enumerate(tool_result.attachments):
|
|
attachment_id = attachment.id()
|
|
db["attachments"].insert(
|
|
{
|
|
"id": attachment_id,
|
|
"type": attachment.resolve_type(),
|
|
"path": attachment.path,
|
|
"url": attachment.url,
|
|
"content": attachment.content,
|
|
},
|
|
replace=True,
|
|
)
|
|
db["tool_results_attachments"].insert(
|
|
{
|
|
"tool_result_id": tool_result_id,
|
|
"attachment_id": attachment_id,
|
|
"order": index,
|
|
},
|
|
)
|
|
|
|
|
|
def _response_to_dict(response: "_BaseResponse") -> ResponseDict:
|
|
"""Shared serializer for Response.to_dict / AsyncResponse.to_dict.
|
|
|
|
The output is a JSON-safe dict — store it anywhere (file, Redis,
|
|
Postgres, HTTP body) and round-trip via Response.from_dict or
|
|
AsyncResponse.from_dict.
|
|
"""
|
|
options = {
|
|
key: value
|
|
for key, value in dict(response.prompt.options).items()
|
|
if value is not None
|
|
}
|
|
payload: Dict[str, Any] = {
|
|
"model": response.model.model_id,
|
|
"prompt": {
|
|
"messages": [m.to_dict() for m in response.prompt.messages],
|
|
},
|
|
"messages": [m.to_dict() for m in response._messages_now()],
|
|
}
|
|
if options:
|
|
payload["prompt"]["options"] = options
|
|
if response.prompt._system:
|
|
payload["prompt"]["system"] = response.prompt._system
|
|
# Optional audit fields — helpful for debugging, not needed for reply().
|
|
if response.id:
|
|
payload["id"] = response.id
|
|
if response._done:
|
|
if response.input_tokens is not None or response.output_tokens is not None:
|
|
usage: Dict[str, Any] = {}
|
|
if response.input_tokens is not None:
|
|
usage["input"] = response.input_tokens
|
|
if response.output_tokens is not None:
|
|
usage["output"] = response.output_tokens
|
|
if response.token_details is not None:
|
|
usage["details"] = response.token_details
|
|
payload["usage"] = usage
|
|
if response._start_utcnow is not None:
|
|
payload["datetime_utc"] = response._start_utcnow.isoformat()
|
|
return cast(ResponseDict, payload)
|
|
|
|
|
|
def _response_from_dict(
|
|
data: ResponseDict,
|
|
cls,
|
|
*,
|
|
model=None,
|
|
async_: bool = False,
|
|
) -> "_BaseResponse":
|
|
"""Shared deserializer for Response.from_dict / AsyncResponse.from_dict."""
|
|
from .parts import Message
|
|
|
|
if model is None:
|
|
from llm import get_async_model, get_model
|
|
|
|
getter = get_async_model if async_ else get_model
|
|
model = getter(data["model"])
|
|
|
|
prompt_data = data.get("prompt", {})
|
|
input_messages = [Message.from_dict(m) for m in prompt_data.get("messages", [])]
|
|
output_messages = [Message.from_dict(m) for m in data.get("messages", [])]
|
|
|
|
options_kwargs = prompt_data.get("options") or {}
|
|
system = prompt_data.get("system")
|
|
|
|
prompt = Prompt(
|
|
None,
|
|
model=model,
|
|
messages=input_messages,
|
|
system=system,
|
|
options=model.Options(**options_kwargs),
|
|
)
|
|
response = cls(prompt, model=model, stream=False)
|
|
# Preserve id for audit continuity.
|
|
if "id" in data:
|
|
response.id = data["id"]
|
|
# Rebuild chunks from the assistant's text parts so response.text()
|
|
# works without re-running the assembler.
|
|
from .parts import TextPart
|
|
|
|
response._chunks = [
|
|
p.text
|
|
for m in output_messages
|
|
for p in m.parts
|
|
if isinstance(p, TextPart) and p.text
|
|
]
|
|
# Stash the structured output so response.messages returns the
|
|
# full picture (reasoning, tool calls, signatures) without needing
|
|
# a StreamEvent replay.
|
|
response._loaded_messages = output_messages
|
|
response._done = True
|
|
# Restore usage if present.
|
|
usage = data.get("usage")
|
|
if usage:
|
|
response.input_tokens = usage.get("input")
|
|
response.output_tokens = usage.get("output")
|
|
response.token_details = usage.get("details")
|
|
return response
|
|
|
|
|
|
class Response(_BaseResponse):
|
|
"Sync response from a model."
|
|
|
|
model: "Model"
|
|
conversation: Optional["Conversation"] = None
|
|
|
|
def reply(
|
|
self,
|
|
prompt: Optional[str] = None,
|
|
*,
|
|
messages: Optional[List[Any]] = None,
|
|
tool_results: Optional[List[ToolResult]] = None,
|
|
options: Optional[dict] = None,
|
|
**kwargs,
|
|
) -> "Response":
|
|
"""Continue the conversation from this response.
|
|
|
|
Builds the next turn's chain as
|
|
``self.prompt.messages + self.messages + [tool_message] +
|
|
[user(prompt)] + messages`` and calls
|
|
``self.model.prompt(messages=chain, ...)``.
|
|
|
|
If this response made tool calls and ``tool_results=`` is not
|
|
passed, ``reply()`` runs ``self.execute_tool_calls()``
|
|
automatically and threads the results into the chain. Pass an
|
|
explicit ``tool_results=`` list (e.g. results you mutated, or
|
|
synthetic ones for testing) to skip auto-execution.
|
|
"""
|
|
from .parts import Message, TextPart, ToolResultPart
|
|
|
|
self._force()
|
|
if tool_results is None and self._tool_calls:
|
|
tool_results = self.execute_tool_calls()
|
|
# Forward original tools so the next turn can call them again
|
|
# (mirrors Conversation.prompt's `tools or self.tools` rule).
|
|
if "tools" not in kwargs and self.prompt.tools:
|
|
kwargs["tools"] = self.prompt.tools
|
|
chain: List[Any] = list(self.prompt.messages) + list(self._messages_now())
|
|
if tool_results:
|
|
chain.append(
|
|
Message(
|
|
role="tool",
|
|
parts=[
|
|
ToolResultPart(
|
|
name=tr.name,
|
|
output=tr.output,
|
|
tool_call_id=tr.tool_call_id,
|
|
)
|
|
for tr in tool_results
|
|
],
|
|
)
|
|
)
|
|
if prompt:
|
|
chain.append(Message(role="user", parts=[TextPart(text=prompt)]))
|
|
if messages:
|
|
chain.extend(messages)
|
|
return self.model.prompt(messages=chain, options=options, **kwargs)
|
|
|
|
def to_dict(self) -> ResponseDict:
|
|
"""Serialize this response for JSON persistence.
|
|
|
|
Captures exactly what is needed to continue the conversation:
|
|
model id, the input chain that was sent
|
|
(``response.prompt.messages``), the structured assistant output
|
|
(``response.messages``), and any explicit options. Pair with
|
|
:meth:`Response.from_dict` to rehydrate and
|
|
:meth:`Response.reply` to continue.
|
|
|
|
Returns :class:`~llm.serialization.ResponseDict`.
|
|
"""
|
|
return _response_to_dict(self)
|
|
|
|
@classmethod
|
|
def from_dict(
|
|
cls,
|
|
data: ResponseDict,
|
|
*,
|
|
model: Optional["Model"] = None,
|
|
) -> "Response":
|
|
"""Rehydrate a Response from a ``to_dict()`` payload.
|
|
|
|
The returned Response is in the ``_done`` state with
|
|
``response.text()`` and ``response.messages`` populated.
|
|
``model`` overrides the stored model id (useful for continuing
|
|
on a different model).
|
|
"""
|
|
return cast(
|
|
"Response", _response_from_dict(data, cls, model=model, async_=False)
|
|
)
|
|
|
|
def on_done(self, callback):
|
|
"Register a callback to be called when the response is complete."
|
|
if not self._done:
|
|
self.done_callbacks.append(callback)
|
|
else:
|
|
callback(self)
|
|
|
|
def _on_done(self):
|
|
for callback in self.done_callbacks:
|
|
callback(self)
|
|
|
|
def __str__(self) -> str:
|
|
return self.text()
|
|
|
|
def _force(self):
|
|
if not self._done:
|
|
list(self)
|
|
|
|
def text(self) -> str:
|
|
"Return the full text of the response, executing the prompt if needed."
|
|
self._force()
|
|
return "".join(self._chunks)
|
|
|
|
def text_or_raise(self) -> str:
|
|
return self.text()
|
|
|
|
def execute_tool_calls(
|
|
self,
|
|
*,
|
|
before_call: Optional[BeforeCallSync] = None,
|
|
after_call: Optional[AfterCallSync] = None,
|
|
tool_calls_list: Optional[List[ToolCall]] = None,
|
|
) -> List[ToolResult]:
|
|
"""Execute tool calls using this response's tools.
|
|
|
|
By default executes ``self.tool_calls()``; pass
|
|
``tool_calls_list=`` to execute an explicit list instead (used
|
|
when resuming a chain whose history ends in unresolved calls).
|
|
"""
|
|
tool_results = []
|
|
tools_by_name = {tool.name: tool for tool in self.prompt.tools}
|
|
if tool_calls_list is None:
|
|
tool_calls_list = self.tool_calls()
|
|
|
|
# Run prepare() on all Toolbox instances that need it
|
|
instances_to_prepare: list[Toolbox] = []
|
|
for tool_to_prep in tools_by_name.values():
|
|
inst = _get_instance(tool_to_prep.implementation)
|
|
if isinstance(inst, Toolbox) and not getattr(inst, "_prepared", False):
|
|
instances_to_prepare.append(inst)
|
|
|
|
for inst in instances_to_prepare:
|
|
inst.prepare()
|
|
inst._prepared = True
|
|
|
|
for tool_call in tool_calls_list:
|
|
tool: Optional[Tool] = tools_by_name.get(tool_call.name)
|
|
# Tool could be None if the tool was not found in the prompt tools,
|
|
# but we still call the before_call method:
|
|
if before_call:
|
|
try:
|
|
cb_result = before_call(tool, tool_call)
|
|
if inspect.isawaitable(cb_result):
|
|
raise TypeError(
|
|
"Asynchronous 'before_call' callback provided to a synchronous tool execution context. "
|
|
"Please use an async chain/response or a synchronous callback."
|
|
)
|
|
except CancelToolCall as ex:
|
|
tool_results.append(
|
|
ToolResult(
|
|
name=tool_call.name,
|
|
output="Cancelled: " + str(ex),
|
|
tool_call_id=tool_call.tool_call_id,
|
|
exception=ex,
|
|
)
|
|
)
|
|
continue
|
|
|
|
if tool is None:
|
|
msg = 'tool "{}" does not exist'.format(tool_call.name)
|
|
tool_results.append(
|
|
ToolResult(
|
|
name=tool_call.name,
|
|
output="Error: " + msg,
|
|
tool_call_id=tool_call.tool_call_id,
|
|
exception=KeyError(msg),
|
|
)
|
|
)
|
|
continue
|
|
|
|
if not tool.implementation:
|
|
raise ValueError(
|
|
"No implementation available for tool: {}".format(tool_call.name)
|
|
)
|
|
|
|
attachments = []
|
|
exception = None
|
|
|
|
try:
|
|
implementation_arguments = _implementation_arguments(tool, tool_call)
|
|
if inspect.iscoroutinefunction(tool.implementation):
|
|
result = asyncio.run(
|
|
tool.implementation(**implementation_arguments)
|
|
)
|
|
else:
|
|
result = tool.implementation(**implementation_arguments)
|
|
|
|
if isinstance(result, ToolOutput):
|
|
attachments = result.attachments
|
|
result = result.output
|
|
|
|
if not isinstance(result, str):
|
|
result = json.dumps(result, default=repr)
|
|
except PauseChain as ex:
|
|
# Pause: propagate instead of converting to an error
|
|
# result. Sequential execution stops here - later calls
|
|
# never started, so they can safely run on resume.
|
|
ex.tool_call = tool_call
|
|
ex.tool_results = list(tool_results)
|
|
raise
|
|
except Exception as ex:
|
|
result = f"Error: {ex}"
|
|
exception = ex
|
|
|
|
tool_result_obj = ToolResult(
|
|
name=tool_call.name,
|
|
output=result,
|
|
attachments=attachments,
|
|
tool_call_id=tool_call.tool_call_id,
|
|
instance=_get_instance(tool.implementation),
|
|
exception=exception,
|
|
)
|
|
|
|
if after_call:
|
|
cb_result = after_call(tool, tool_call, tool_result_obj)
|
|
if inspect.isawaitable(cb_result):
|
|
raise TypeError(
|
|
"Asynchronous 'after_call' callback provided to a synchronous tool execution context. "
|
|
"Please use an async chain/response or a synchronous callback."
|
|
)
|
|
tool_results.append(tool_result_obj)
|
|
return tool_results
|
|
|
|
def tool_calls(self) -> List[ToolCall]:
|
|
"Return the list of tool calls made during this response."
|
|
self._force()
|
|
return self._tool_calls
|
|
|
|
def tool_calls_or_raise(self) -> List[ToolCall]:
|
|
return self.tool_calls()
|
|
|
|
def json(self) -> Optional[Dict[str, Any]]:
|
|
"Return the raw JSON response from the model, if available."
|
|
self._force()
|
|
return self.response_json
|
|
|
|
def duration_ms(self) -> int:
|
|
self._force()
|
|
return int(((self._end or 0) - (self._start or 0)) * 1000)
|
|
|
|
def datetime_utc(self) -> str:
|
|
self._force()
|
|
return self._start_utcnow.isoformat() if self._start_utcnow else ""
|
|
|
|
def usage(self) -> Usage:
|
|
"Return token usage information for this response."
|
|
self._force()
|
|
return Usage(
|
|
input=self.input_tokens,
|
|
output=self.output_tokens,
|
|
details=self.token_details,
|
|
)
|
|
|
|
def _iter_events(self):
|
|
"""Drive self.model.execute() once and yield each raw chunk it
|
|
produces. Callers normalize chunks through _process_chunk.
|
|
"""
|
|
if isinstance(self.model, Model):
|
|
generator = self.model.execute(
|
|
self.prompt,
|
|
stream=self.stream,
|
|
response=self,
|
|
conversation=self.conversation,
|
|
)
|
|
elif isinstance(self.model, KeyModel):
|
|
generator = self.model.execute(
|
|
self.prompt,
|
|
stream=self.stream,
|
|
response=self,
|
|
conversation=self.conversation,
|
|
key=self.model.get_key(self._key),
|
|
)
|
|
else:
|
|
raise Exception("self.model must be a Model or KeyModel")
|
|
|
|
for chunk in generator:
|
|
assert chunk is not None
|
|
yield chunk
|
|
|
|
def __iter__(self) -> Iterator[str]:
|
|
self._start = time.monotonic()
|
|
self._start_utcnow = datetime.datetime.now(datetime.timezone.utc)
|
|
if self._done:
|
|
yield from self._chunks
|
|
return
|
|
|
|
for chunk in self._iter_events():
|
|
text = self._process_chunk(chunk)
|
|
if text is not None:
|
|
yield text
|
|
|
|
if self.conversation:
|
|
self.conversation.responses.append(self)
|
|
self._end = time.monotonic()
|
|
self._done = True
|
|
self._on_done()
|
|
|
|
def stream_events(self):
|
|
"""Yield StreamEvent objects as the model produces them.
|
|
|
|
Whichever of __iter__ and stream_events runs first during live
|
|
streaming consumes the underlying generator. After completion,
|
|
both work — each replays from its own buffer.
|
|
"""
|
|
if self._done:
|
|
yield from self._stream_events
|
|
return
|
|
|
|
self._start = time.monotonic()
|
|
self._start_utcnow = datetime.datetime.now(datetime.timezone.utc)
|
|
for chunk in self._iter_events():
|
|
# _process_chunk appends to self._stream_events; use it as
|
|
# the canonical source for what to yield so the replay path
|
|
# matches the live path byte-for-byte.
|
|
self._process_chunk(chunk)
|
|
yield self._stream_events[-1]
|
|
|
|
if self.conversation:
|
|
self.conversation.responses.append(self)
|
|
self._end = time.monotonic()
|
|
self._done = True
|
|
self._on_done()
|
|
|
|
def messages(self) -> List[Any]:
|
|
"""List of Message objects produced by this response.
|
|
|
|
Almost always a single assistant Message; multiple messages are
|
|
possible for providers that emit multi-message responses during
|
|
server-side tool execution.
|
|
|
|
Forces execution if the response has not yet been drained, so
|
|
``response.messages()`` is safe to call without a prior
|
|
``response.text()`` / iteration.
|
|
|
|
Responses rehydrated via ``Response.from_dict`` short-circuit
|
|
and return the stored messages directly.
|
|
"""
|
|
self._force()
|
|
return self._messages_now()
|
|
|
|
def __repr__(self):
|
|
text = "... not yet done ..."
|
|
if self._done:
|
|
text = "".join(self._chunks)
|
|
return "<Response prompt='{}' text='{}'>".format(self.prompt.prompt, text)
|
|
|
|
|
|
class AsyncResponse(_BaseResponse):
|
|
"Async response from a model."
|
|
|
|
model: "AsyncModel"
|
|
conversation: Optional["AsyncConversation"] = None
|
|
|
|
async def reply(
|
|
self,
|
|
prompt: Optional[str] = None,
|
|
*,
|
|
messages: Optional[List[Any]] = None,
|
|
tool_results: Optional[List[ToolResult]] = None,
|
|
options: Optional[dict] = None,
|
|
**kwargs,
|
|
) -> "AsyncResponse":
|
|
"""Async counterpart of Response.reply(). Requires this response
|
|
to have been awaited (so self.messages is available).
|
|
|
|
Awaitable so the auto-execute path can ``await
|
|
self.execute_tool_calls()``. See ``Response.reply`` for the
|
|
``tool_results=`` semantics.
|
|
"""
|
|
from .parts import Message, TextPart, ToolResultPart
|
|
|
|
if not self._done:
|
|
raise ValueError(
|
|
"Response not yet awaited — call `await response` before reply()"
|
|
)
|
|
if tool_results is None and self._tool_calls:
|
|
tool_results = await self.execute_tool_calls()
|
|
if "tools" not in kwargs and self.prompt.tools:
|
|
kwargs["tools"] = self.prompt.tools
|
|
chain: List[Any] = list(self.prompt.messages) + list(self._messages_now())
|
|
if tool_results:
|
|
chain.append(
|
|
Message(
|
|
role="tool",
|
|
parts=[
|
|
ToolResultPart(
|
|
name=tr.name,
|
|
output=tr.output,
|
|
tool_call_id=tr.tool_call_id,
|
|
)
|
|
for tr in tool_results
|
|
],
|
|
)
|
|
)
|
|
if prompt:
|
|
chain.append(Message(role="user", parts=[TextPart(text=prompt)]))
|
|
if messages:
|
|
chain.extend(messages)
|
|
return self.model.prompt(messages=chain, options=options, **kwargs)
|
|
|
|
def to_dict(self) -> ResponseDict:
|
|
"""Async counterpart of Response.to_dict(). Requires awaiting."""
|
|
if not self._done:
|
|
raise ValueError(
|
|
"Response not yet awaited — call `await response` before to_dict()"
|
|
)
|
|
return _response_to_dict(self)
|
|
|
|
@classmethod
|
|
def from_dict(
|
|
cls,
|
|
data: ResponseDict,
|
|
*,
|
|
model: Optional["AsyncModel"] = None,
|
|
) -> "AsyncResponse":
|
|
"""Async counterpart of Response.from_dict()."""
|
|
return cast(
|
|
"AsyncResponse", _response_from_dict(data, cls, model=model, async_=True)
|
|
)
|
|
|
|
@classmethod
|
|
def from_row(cls, db, row, _async=False):
|
|
return super().from_row(db, row, _async=True)
|
|
|
|
async def on_done(self, callback):
|
|
"Register a callback to be called when the response is complete."
|
|
if not self._done:
|
|
self.done_callbacks.append(callback)
|
|
else:
|
|
if callable(callback):
|
|
# Ensure we handle both sync and async callbacks correctly
|
|
processed_callback = callback(self)
|
|
if inspect.isawaitable(processed_callback):
|
|
await processed_callback
|
|
elif inspect.isawaitable(callback):
|
|
await callback
|
|
|
|
async def _on_done(self):
|
|
for callback_func in self.done_callbacks:
|
|
if callable(callback_func):
|
|
processed_callback = callback_func(self)
|
|
if inspect.isawaitable(processed_callback):
|
|
await processed_callback
|
|
elif inspect.isawaitable(callback_func):
|
|
await callback_func
|
|
|
|
async def execute_tool_calls(
|
|
self,
|
|
*,
|
|
before_call: Optional[BeforeCallAsync] = None,
|
|
after_call: Optional[AfterCallAsync] = None,
|
|
tool_calls_list: Optional[List[ToolCall]] = None,
|
|
) -> List[ToolResult]:
|
|
"""Execute tool calls using this response's tools.
|
|
|
|
By default executes ``await self.tool_calls()``; pass
|
|
``tool_calls_list=`` to execute an explicit list instead (used
|
|
when resuming a chain whose history ends in unresolved calls).
|
|
"""
|
|
if tool_calls_list is None:
|
|
tool_calls_list = await self.tool_calls()
|
|
tools_by_name = {tool.name: tool for tool in self.prompt.tools}
|
|
|
|
# Run async prepare_async() on all Toolbox instances that need it
|
|
instances_to_prepare: list[Toolbox] = []
|
|
for tool_to_prep in tools_by_name.values():
|
|
inst = _get_instance(tool_to_prep.implementation)
|
|
if isinstance(inst, Toolbox) and not getattr(
|
|
inst, "_async_prepared", False
|
|
):
|
|
instances_to_prepare.append(inst)
|
|
|
|
for inst in instances_to_prepare:
|
|
await inst.prepare_async()
|
|
inst._async_prepared = True
|
|
|
|
indexed_results: List[tuple[int, ToolResult]] = []
|
|
async_tasks: List[asyncio.Task] = []
|
|
async_task_indexes: List[int] = []
|
|
# Defined failure semantics: a pause or error in one call must not
|
|
# orphan concurrently-running siblings. Pauses and hook failures
|
|
# are collected here and raised only after every task that was
|
|
# started has finished.
|
|
paused: List[tuple[int, PauseChain]] = []
|
|
failures: List[tuple[int, BaseException]] = []
|
|
|
|
for idx, tc in enumerate(tool_calls_list):
|
|
tool: Optional[Tool] = tools_by_name.get(tc.name)
|
|
exception: Optional[Exception] = None
|
|
|
|
if tool is None or not tool.implementation:
|
|
# Mirror the sync executor: append an error ToolResult so
|
|
# the provider still receives a result for every tool
|
|
# call. before_call fires even though the tool is
|
|
# unavailable.
|
|
if before_call:
|
|
try:
|
|
cb = before_call(tool, tc)
|
|
if inspect.isawaitable(cb):
|
|
await cb
|
|
except CancelToolCall as ex:
|
|
indexed_results.append(
|
|
(
|
|
idx,
|
|
ToolResult(
|
|
name=tc.name,
|
|
output="Cancelled: " + str(ex),
|
|
tool_call_id=tc.tool_call_id,
|
|
exception=ex,
|
|
),
|
|
)
|
|
)
|
|
continue
|
|
except Exception as ex:
|
|
failures.append((idx, ex))
|
|
break
|
|
reason = "does not exist" if tool is None else "has no implementation"
|
|
msg = 'tool "{}" {}'.format(tc.name, reason)
|
|
indexed_results.append(
|
|
(
|
|
idx,
|
|
ToolResult(
|
|
name=tc.name,
|
|
output="Error: " + msg,
|
|
tool_call_id=tc.tool_call_id,
|
|
exception=KeyError(msg),
|
|
),
|
|
)
|
|
)
|
|
continue
|
|
|
|
if inspect.iscoroutinefunction(tool.implementation):
|
|
|
|
async def run_async(tc=tc, tool=tool, idx=idx):
|
|
# before_call inside the task
|
|
if before_call:
|
|
try:
|
|
cb = before_call(tool, tc)
|
|
if inspect.isawaitable(cb):
|
|
await cb
|
|
except CancelToolCall as ex:
|
|
return idx, ToolResult(
|
|
name=tc.name,
|
|
output="Cancelled: " + str(ex),
|
|
tool_call_id=tc.tool_call_id,
|
|
exception=ex,
|
|
)
|
|
|
|
exception = None
|
|
attachments = []
|
|
|
|
try:
|
|
result = await tool.implementation(
|
|
**_implementation_arguments(tool, tc)
|
|
)
|
|
if isinstance(result, ToolOutput):
|
|
attachments.extend(result.attachments)
|
|
result = result.output
|
|
output = (
|
|
result
|
|
if isinstance(result, str)
|
|
else json.dumps(result, default=repr)
|
|
)
|
|
except PauseChain as ex:
|
|
# Propagates out of the task; collected after
|
|
# the gather so siblings finish first.
|
|
ex.tool_call = tc
|
|
raise
|
|
except Exception as ex:
|
|
output = f"Error: {ex}"
|
|
exception = ex
|
|
|
|
tr = ToolResult(
|
|
name=tc.name,
|
|
output=output,
|
|
attachments=attachments,
|
|
tool_call_id=tc.tool_call_id,
|
|
instance=_get_instance(tool.implementation),
|
|
exception=exception,
|
|
)
|
|
|
|
# after_call inside the task
|
|
if tool is not None and after_call:
|
|
cb2 = after_call(tool, tc, tr)
|
|
if inspect.isawaitable(cb2):
|
|
await cb2
|
|
|
|
return idx, tr
|
|
|
|
async_tasks.append(asyncio.create_task(run_async()))
|
|
async_task_indexes.append(idx)
|
|
|
|
else:
|
|
# Sync implementation: do hooks and call inline
|
|
if before_call:
|
|
try:
|
|
cb = before_call(tool, tc)
|
|
if inspect.isawaitable(cb):
|
|
await cb
|
|
except CancelToolCall as ex:
|
|
indexed_results.append(
|
|
(
|
|
idx,
|
|
ToolResult(
|
|
name=tc.name,
|
|
output="Cancelled: " + str(ex),
|
|
tool_call_id=tc.tool_call_id,
|
|
exception=ex,
|
|
),
|
|
)
|
|
)
|
|
continue
|
|
except Exception as ex:
|
|
failures.append((idx, ex))
|
|
break
|
|
|
|
exception = None
|
|
attachments = []
|
|
|
|
try:
|
|
res = tool.implementation(**_implementation_arguments(tool, tc))
|
|
if inspect.isawaitable(res):
|
|
res = await res
|
|
if isinstance(res, ToolOutput):
|
|
attachments.extend(res.attachments)
|
|
res = res.output
|
|
output = (
|
|
res if isinstance(res, str) else json.dumps(res, default=repr)
|
|
)
|
|
except PauseChain as ex:
|
|
# Inline execution stops here; later calls never
|
|
# start. Tasks already started are still awaited
|
|
# below before the pause propagates.
|
|
ex.tool_call = tc
|
|
paused.append((idx, ex))
|
|
break
|
|
except Exception as ex:
|
|
output = f"Error: {ex}"
|
|
exception = ex
|
|
|
|
tr = ToolResult(
|
|
name=tc.name,
|
|
output=output,
|
|
attachments=attachments,
|
|
tool_call_id=tc.tool_call_id,
|
|
instance=_get_instance(tool.implementation),
|
|
exception=exception,
|
|
)
|
|
|
|
try:
|
|
if after_call:
|
|
cb2 = after_call(tool, tc, tr)
|
|
if inspect.isawaitable(cb2):
|
|
await cb2
|
|
except Exception as ex:
|
|
failures.append((idx, ex))
|
|
break
|
|
|
|
indexed_results.append((idx, tr))
|
|
|
|
# Await every task that was started; return_exceptions so a pause
|
|
# or hook failure in one task cannot orphan its siblings mid-flight.
|
|
if async_tasks:
|
|
outcomes = await asyncio.gather(*async_tasks, return_exceptions=True)
|
|
for task_idx, outcome in zip(async_task_indexes, outcomes):
|
|
if isinstance(outcome, PauseChain):
|
|
paused.append((task_idx, outcome))
|
|
elif isinstance(outcome, BaseException):
|
|
failures.append((task_idx, outcome))
|
|
else:
|
|
indexed_results.append(outcome)
|
|
|
|
# Reorder by original index
|
|
indexed_results.sort(key=lambda x: x[0])
|
|
results = [tr for _, tr in indexed_results]
|
|
|
|
# Hook failures are bugs: raise the first by call order.
|
|
if failures:
|
|
failures.sort(key=lambda item: item[0])
|
|
raise failures[0][1]
|
|
|
|
# Pauses propagate with the completed sibling results attached.
|
|
if paused:
|
|
paused.sort(key=lambda item: item[0])
|
|
pause = paused[0][1]
|
|
pause.tool_results = results
|
|
raise pause
|
|
|
|
return results
|
|
|
|
def __aiter__(self):
|
|
self._start = time.monotonic()
|
|
self._start_utcnow = datetime.datetime.now(datetime.timezone.utc)
|
|
if self._done:
|
|
self._iter_chunks = list(self._chunks) # Make a copy for iteration
|
|
return self
|
|
|
|
def _ensure_async_generator(self):
|
|
if not hasattr(self, "_generator"):
|
|
if isinstance(self.model, AsyncModel):
|
|
self._generator = self.model.execute(
|
|
self.prompt,
|
|
stream=self.stream,
|
|
response=self,
|
|
conversation=self.conversation,
|
|
)
|
|
elif isinstance(self.model, AsyncKeyModel):
|
|
self._generator = self.model.execute(
|
|
self.prompt,
|
|
stream=self.stream,
|
|
response=self,
|
|
conversation=self.conversation,
|
|
key=self.model.get_key(self._key),
|
|
)
|
|
else:
|
|
raise ValueError("self.model must be an AsyncModel or AsyncKeyModel")
|
|
|
|
async def _async_finalize(self):
|
|
if self.conversation:
|
|
self.conversation.responses.append(self)
|
|
self._end = time.monotonic()
|
|
self._done = True
|
|
if hasattr(self, "_generator"):
|
|
del self._generator
|
|
await self._on_done()
|
|
|
|
async def __anext__(self) -> str:
|
|
if self._done:
|
|
if hasattr(self, "_iter_chunks") and self._iter_chunks:
|
|
return self._iter_chunks.pop(0)
|
|
raise StopAsyncIteration
|
|
|
|
self._ensure_async_generator()
|
|
# Skip non-text events — iteration yields only text. Loop until
|
|
# we find a text chunk or the generator is exhausted.
|
|
while True:
|
|
try:
|
|
chunk = await self._generator.__anext__()
|
|
except StopAsyncIteration:
|
|
await self._async_finalize()
|
|
raise
|
|
assert chunk is not None
|
|
text = self._process_chunk(chunk)
|
|
if text is not None:
|
|
return text
|
|
|
|
async def astream_events(self):
|
|
"""Yield StreamEvent objects as the model produces them (async)."""
|
|
if self._done:
|
|
for event in self._stream_events:
|
|
yield event
|
|
return
|
|
|
|
self._start = time.monotonic()
|
|
self._start_utcnow = datetime.datetime.now(datetime.timezone.utc)
|
|
self._ensure_async_generator()
|
|
try:
|
|
while True:
|
|
try:
|
|
chunk = await self._generator.__anext__()
|
|
except StopAsyncIteration:
|
|
await self._async_finalize()
|
|
return
|
|
assert chunk is not None
|
|
self._process_chunk(chunk)
|
|
yield self._stream_events[-1]
|
|
finally:
|
|
pass
|
|
|
|
async def messages(self) -> List[Any]:
|
|
"""List of Message objects produced by this response.
|
|
|
|
Awaits ``self._force()`` so ``await response.messages()`` is
|
|
safe to call without first awaiting ``response.text()`` or
|
|
iterating the stream. Responses rehydrated via
|
|
``AsyncResponse.from_dict`` short-circuit and return the
|
|
stored messages.
|
|
"""
|
|
await self._force()
|
|
return self._messages_now()
|
|
|
|
async def _force(self):
|
|
if not self._done:
|
|
temp_chunks = []
|
|
async for chunk in self:
|
|
temp_chunks.append(chunk)
|
|
# This should populate self._chunks
|
|
return self
|
|
|
|
def text_or_raise(self) -> str:
|
|
if not self._done:
|
|
raise ValueError("Response not yet awaited")
|
|
return "".join(self._chunks)
|
|
|
|
async def text(self) -> str:
|
|
"Return the full text of the response, executing the prompt if needed."
|
|
await self._force()
|
|
return "".join(self._chunks)
|
|
|
|
async def tool_calls(self) -> List[ToolCall]:
|
|
"Return the list of tool calls made during this response."
|
|
await self._force()
|
|
return self._tool_calls
|
|
|
|
def tool_calls_or_raise(self) -> List[ToolCall]:
|
|
if not self._done:
|
|
raise ValueError("Response not yet awaited")
|
|
return self._tool_calls
|
|
|
|
async def json(self) -> Optional[Dict[str, Any]]:
|
|
"Return the raw JSON response from the model, if available."
|
|
await self._force()
|
|
return self.response_json
|
|
|
|
async def duration_ms(self) -> int:
|
|
await self._force()
|
|
return int(((self._end or 0) - (self._start or 0)) * 1000)
|
|
|
|
async def datetime_utc(self) -> str:
|
|
await self._force()
|
|
return self._start_utcnow.isoformat() if self._start_utcnow else ""
|
|
|
|
async def usage(self) -> Usage:
|
|
"Return token usage information for this response."
|
|
await self._force()
|
|
return Usage(
|
|
input=self.input_tokens,
|
|
output=self.output_tokens,
|
|
details=self.token_details,
|
|
)
|
|
|
|
def __await__(self):
|
|
return self._force().__await__()
|
|
|
|
async def to_sync_response(self) -> Response:
|
|
await self._force()
|
|
# This conversion might be tricky if the model is AsyncModel,
|
|
# as Response expects a sync Model. For simplicity, we'll assume
|
|
# the primary use case is data transfer after completion.
|
|
# The model type on the new Response might need careful handling
|
|
# if it's intended for further execution.
|
|
# For now, let's assume self.model can be cast or is compatible.
|
|
sync_model = self.model
|
|
if not isinstance(self.model, (Model, KeyModel)):
|
|
# This is a placeholder. A proper conversion or shared base might be needed
|
|
# if the sync_response needs to be fully functional with its model.
|
|
# For now, we pass the async model, which might limit what sync_response can do.
|
|
pass
|
|
|
|
response = Response(
|
|
self.prompt,
|
|
sync_model, # This might need adjustment based on how Model/AsyncModel relate
|
|
self.stream,
|
|
# conversation type needs to be compatible too.
|
|
conversation=(
|
|
self.conversation.to_sync_conversation() if self.conversation else None
|
|
),
|
|
)
|
|
response.id = self.id
|
|
response._chunks = list(self._chunks) # Copy chunks
|
|
response._done = self._done
|
|
response._end = self._end
|
|
response._start = self._start
|
|
response._start_utcnow = self._start_utcnow
|
|
response.input_tokens = self.input_tokens
|
|
response.output_tokens = self.output_tokens
|
|
response.token_details = self.token_details
|
|
response._prompt_json = self._prompt_json
|
|
response.response_json = self.response_json
|
|
response._tool_calls = list(self._tool_calls)
|
|
response.attachments = list(self.attachments)
|
|
response.resolved_model = self.resolved_model
|
|
return response
|
|
|
|
@classmethod
|
|
def fake(
|
|
cls,
|
|
model: "AsyncModel",
|
|
prompt: str,
|
|
*attachments: List[Attachment],
|
|
system: str,
|
|
response: str,
|
|
):
|
|
"Utility method to help with writing tests"
|
|
response_obj = cls(
|
|
model=model,
|
|
prompt=Prompt(
|
|
prompt,
|
|
model=model,
|
|
attachments=attachments,
|
|
system=system,
|
|
),
|
|
stream=False,
|
|
)
|
|
response_obj._done = True
|
|
response_obj._chunks = [response]
|
|
return response_obj
|
|
|
|
def __repr__(self):
|
|
text = "... not yet awaited ..."
|
|
if self._done:
|
|
text = "".join(self._chunks)
|
|
return "<AsyncResponse prompt='{}' text='{}'>".format(self.prompt.prompt, text)
|
|
|
|
|
|
def _append_tool_results_to_chain(chain, tool_results, attachments) -> List[Any]:
|
|
"""Append a tool-role message carrying ToolResults to a message
|
|
chain, plus a trailing user-role message for any attachments the
|
|
tools returned (mimics the legacy attachments=[] kwarg behavior)."""
|
|
from .parts import (
|
|
AttachmentPart,
|
|
Message,
|
|
ToolResultPart,
|
|
)
|
|
|
|
if tool_results:
|
|
chain.append(
|
|
Message(
|
|
role="tool",
|
|
parts=[
|
|
ToolResultPart(
|
|
name=tr.name,
|
|
output=tr.output,
|
|
tool_call_id=tr.tool_call_id,
|
|
)
|
|
for tr in tool_results
|
|
],
|
|
)
|
|
)
|
|
if attachments:
|
|
chain.append(
|
|
Message(
|
|
role="user",
|
|
parts=[AttachmentPart(attachment=a) for a in attachments],
|
|
)
|
|
)
|
|
return chain
|
|
|
|
|
|
def _chain_for_tool_results(prior_response, tool_results, attachments) -> List[Any]:
|
|
"""Build the message chain for a tool-result turn in a chain loop.
|
|
|
|
Takes the prior response's full input chain + its structured
|
|
output, then appends a tool-role message carrying the new
|
|
ToolResult outputs.
|
|
|
|
This is what gives ``response.prompt.messages`` on the tool-
|
|
result turn the complete history for the next provider call —
|
|
including any reasoning signatures or thoughtSignatures from the
|
|
prior turn.
|
|
"""
|
|
chain: List[Any] = list(prior_response.prompt.messages) + list(
|
|
prior_response._messages_now()
|
|
)
|
|
return _append_tool_results_to_chain(chain, tool_results, attachments)
|
|
|
|
|
|
def _trailing_pending_tool_calls(messages) -> List[ToolCall]:
|
|
"""Find unresolved tool calls at the end of a message history.
|
|
|
|
Returns ToolCall objects from the last assistant message containing
|
|
locally-executable tool_call parts, minus any that already have a
|
|
matching tool_result in subsequent tool-role messages. Returns []
|
|
when the history has moved on past those calls (a user/assistant/
|
|
system message follows them) - resuming only makes sense when the
|
|
calls are the latest thing that happened.
|
|
|
|
Matching uses tool_call_id when present; id-less calls (histories
|
|
persisted before ids were guaranteed) match results by name, one
|
|
result consumed per call.
|
|
"""
|
|
from .parts import ToolCallPart, ToolResultPart
|
|
|
|
last_index = None
|
|
call_parts: List[Any] = []
|
|
for i, msg in enumerate(messages or []):
|
|
parts = getattr(msg, "parts", None) or []
|
|
calls = [
|
|
p for p in parts if isinstance(p, ToolCallPart) and not p.server_executed
|
|
]
|
|
if getattr(msg, "role", None) == "assistant" and calls:
|
|
last_index = i
|
|
call_parts = calls
|
|
if last_index is None:
|
|
return []
|
|
|
|
results: List[Any] = []
|
|
for msg in messages[last_index + 1 :]:
|
|
role = getattr(msg, "role", None)
|
|
if role == "tool":
|
|
results.extend(
|
|
p
|
|
for p in (getattr(msg, "parts", None) or [])
|
|
if isinstance(p, ToolResultPart)
|
|
)
|
|
else:
|
|
# Conversation moved on past these calls
|
|
return []
|
|
|
|
matched_ids = {r.tool_call_id for r in results if r.tool_call_id}
|
|
unmatched_names = [r.name for r in results if not r.tool_call_id]
|
|
pending = []
|
|
for part in call_parts:
|
|
if part.tool_call_id:
|
|
if part.tool_call_id in matched_ids:
|
|
continue
|
|
elif part.name in unmatched_names:
|
|
unmatched_names.remove(part.name)
|
|
continue
|
|
pending.append(
|
|
ToolCall(
|
|
name=part.name,
|
|
arguments=part.arguments or {},
|
|
tool_call_id=part.tool_call_id,
|
|
)
|
|
)
|
|
return pending
|
|
|
|
|
|
class _BaseChainResponse:
|
|
prompt: "Prompt"
|
|
stream: bool
|
|
conversation: Optional["_BaseConversation"] = None
|
|
_key: Optional[str] = None
|
|
|
|
def __init__(
|
|
self,
|
|
prompt: Prompt,
|
|
model: "_BaseModel",
|
|
stream: bool,
|
|
conversation: _BaseConversation,
|
|
key: Optional[str] = None,
|
|
chain_limit: Optional[int] = 10,
|
|
before_call: Optional[Union[BeforeCallSync, BeforeCallAsync]] = None,
|
|
after_call: Optional[Union[AfterCallSync, AfterCallAsync]] = None,
|
|
):
|
|
self.prompt = prompt
|
|
self.model = model
|
|
self.stream = stream
|
|
self._key = key
|
|
self._responses: List[Any] = []
|
|
self.conversation = conversation
|
|
self.chain_limit = chain_limit
|
|
self.before_call = before_call
|
|
self.after_call = after_call
|
|
|
|
def log_to_db(self, db):
|
|
for response in self._responses:
|
|
if isinstance(response, AsyncResponse):
|
|
sync_response = asyncio.run(response.to_sync_response())
|
|
elif isinstance(response, Response):
|
|
sync_response = response
|
|
else:
|
|
assert False, "Should have been a Response or AsyncResponse"
|
|
sync_response.log_to_db(db)
|
|
|
|
def _pending_tool_calls(self) -> List[ToolCall]:
|
|
"""Unresolved tool calls at the end of this chain's history.
|
|
|
|
Non-empty when the supplied messages= end in an assistant
|
|
message whose tool calls have no results yet - e.g. a chain
|
|
that paused on PauseChain and is being resumed from persisted
|
|
history."""
|
|
if not self.prompt.tools:
|
|
return []
|
|
return _trailing_pending_tool_calls(self.prompt.messages)
|
|
|
|
def _resume_prompt(self, tool_results: List[ToolResult]) -> Prompt:
|
|
"""The first prompt for a resumed chain: the original history
|
|
plus a tool-role message carrying the freshly-executed results -
|
|
the same shape as the chain loop's own tool-result turns."""
|
|
prompt = self.prompt
|
|
attachments = []
|
|
for tool_result in tool_results:
|
|
attachments.extend(tool_result.attachments)
|
|
next_chain = _append_tool_results_to_chain(
|
|
list(prompt.messages), tool_results, attachments
|
|
)
|
|
return Prompt(
|
|
"",
|
|
self.model,
|
|
tools=prompt.tools,
|
|
tool_results=tool_results,
|
|
messages=next_chain,
|
|
system=prompt._system,
|
|
system_fragments=prompt.system_fragments,
|
|
options=prompt.options,
|
|
attachments=attachments,
|
|
hide_reasoning=prompt.hide_reasoning,
|
|
)
|
|
|
|
|
|
class ChainResponse(_BaseChainResponse):
|
|
_responses: List["Response"]
|
|
before_call: Optional[BeforeCallSync] = None
|
|
after_call: Optional[AfterCallSync] = None
|
|
|
|
def responses(self) -> Iterator[Response]:
|
|
prompt = self.prompt
|
|
count = 0
|
|
initial_response = Response(
|
|
prompt,
|
|
self.model,
|
|
self.stream,
|
|
key=self._key,
|
|
conversation=self.conversation,
|
|
)
|
|
# Resume: a history ending in unresolved tool calls means a
|
|
# previous run stopped (paused or crashed) before executing
|
|
# them. Execute those calls first - through the normal
|
|
# before_call/after_call machinery - then start the loop on
|
|
# the tool-result turn. This could raise llm.PauseChain.
|
|
pending_tool_calls = self._pending_tool_calls()
|
|
if pending_tool_calls:
|
|
tool_results = initial_response.execute_tool_calls(
|
|
before_call=self.before_call,
|
|
after_call=self.after_call,
|
|
tool_calls_list=pending_tool_calls,
|
|
)
|
|
initial_response = Response(
|
|
self._resume_prompt(tool_results),
|
|
self.model,
|
|
self.stream,
|
|
key=self._key,
|
|
conversation=self.conversation,
|
|
)
|
|
current_response: Optional[Response] = initial_response
|
|
while current_response:
|
|
count += 1
|
|
yield current_response
|
|
self._responses.append(current_response)
|
|
if self.chain_limit and count >= self.chain_limit:
|
|
raise ValueError(f"Chain limit of {self.chain_limit} exceeded.")
|
|
|
|
# This could raise llm.CancelToolCall:
|
|
tool_results = current_response.execute_tool_calls(
|
|
before_call=self.before_call, after_call=self.after_call
|
|
)
|
|
attachments = []
|
|
for tool_result in tool_results:
|
|
attachments.extend(tool_result.attachments)
|
|
if tool_results:
|
|
# Pre-bake the full chain for the tool-result turn so
|
|
# response.prompt.messages is what gets sent — carries
|
|
# thoughtSignatures, thinking signatures, and everything
|
|
# else the model needs for the next call.
|
|
next_chain = _chain_for_tool_results(
|
|
current_response, tool_results, attachments
|
|
)
|
|
current_response = Response(
|
|
Prompt(
|
|
"", # Next prompt text is empty; tool_results drive it
|
|
self.model,
|
|
tools=current_response.prompt.tools,
|
|
tool_results=tool_results,
|
|
messages=next_chain,
|
|
# Carry system + system_fragments forward so
|
|
# stateless-per-turn adapters (OpenAI and
|
|
# friends that read prompt.system directly)
|
|
# keep seeing the system prompt on every call
|
|
# of the chain loop.
|
|
system=self.prompt._system,
|
|
system_fragments=self.prompt.system_fragments,
|
|
options=self.prompt.options,
|
|
attachments=attachments,
|
|
hide_reasoning=current_response.prompt.hide_reasoning,
|
|
),
|
|
self.model,
|
|
stream=self.stream,
|
|
key=self._key,
|
|
conversation=self.conversation,
|
|
)
|
|
else:
|
|
current_response = None
|
|
break
|
|
|
|
def __iter__(self) -> Iterator[str]:
|
|
for response_item in self.responses():
|
|
yield from response_item
|
|
|
|
def stream_events(self):
|
|
"Yield StreamEvents from every response in the chain."
|
|
for response_item in self.responses():
|
|
yield from response_item.stream_events()
|
|
|
|
def text(self) -> str:
|
|
return "".join(self)
|
|
|
|
|
|
class AsyncChainResponse(_BaseChainResponse):
|
|
_responses: List["AsyncResponse"]
|
|
before_call: Optional[BeforeCallAsync] = None
|
|
after_call: Optional[AfterCallAsync] = None
|
|
|
|
async def responses(self) -> AsyncIterator[AsyncResponse]:
|
|
prompt = self.prompt
|
|
count = 0
|
|
initial_response = AsyncResponse(
|
|
prompt,
|
|
self.model,
|
|
self.stream,
|
|
key=self._key,
|
|
conversation=self.conversation,
|
|
)
|
|
# Resume: see ChainResponse.responses() - execute trailing
|
|
# unresolved tool calls before the first provider call. This
|
|
# could raise llm.PauseChain.
|
|
pending_tool_calls = self._pending_tool_calls()
|
|
if pending_tool_calls:
|
|
tool_results = await initial_response.execute_tool_calls(
|
|
before_call=self.before_call,
|
|
after_call=self.after_call,
|
|
tool_calls_list=pending_tool_calls,
|
|
)
|
|
initial_response = AsyncResponse(
|
|
self._resume_prompt(tool_results),
|
|
self.model,
|
|
self.stream,
|
|
key=self._key,
|
|
conversation=self.conversation,
|
|
)
|
|
current_response: Optional[AsyncResponse] = initial_response
|
|
while current_response:
|
|
count += 1
|
|
yield current_response
|
|
self._responses.append(current_response)
|
|
|
|
if self.chain_limit and count >= self.chain_limit:
|
|
raise ValueError(f"Chain limit of {self.chain_limit} exceeded.")
|
|
|
|
# This could raise llm.CancelToolCall:
|
|
tool_results = await current_response.execute_tool_calls(
|
|
before_call=self.before_call, after_call=self.after_call
|
|
)
|
|
if tool_results:
|
|
attachments = []
|
|
for tool_result in tool_results:
|
|
attachments.extend(tool_result.attachments)
|
|
# Pre-bake chain so prompt.messages carries full history
|
|
# + any thinking/tool-call signatures from prior turn.
|
|
next_chain = _chain_for_tool_results(
|
|
current_response, tool_results, attachments
|
|
)
|
|
prompt = Prompt(
|
|
"",
|
|
self.model,
|
|
tools=current_response.prompt.tools,
|
|
tool_results=tool_results,
|
|
messages=next_chain,
|
|
# Carry system + system_fragments forward — same
|
|
# reasoning as the sync path.
|
|
system=self.prompt._system,
|
|
system_fragments=self.prompt.system_fragments,
|
|
options=self.prompt.options,
|
|
attachments=attachments,
|
|
hide_reasoning=current_response.prompt.hide_reasoning,
|
|
)
|
|
current_response = AsyncResponse(
|
|
prompt,
|
|
self.model,
|
|
stream=self.stream,
|
|
key=self._key,
|
|
conversation=self.conversation,
|
|
)
|
|
else:
|
|
current_response = None
|
|
break
|
|
|
|
async def __aiter__(self) -> AsyncIterator[str]:
|
|
async for response_item in self.responses():
|
|
async for chunk in response_item:
|
|
yield chunk
|
|
|
|
async def astream_events(self):
|
|
"Yield StreamEvents from every response in the chain."
|
|
async for response_item in self.responses():
|
|
async for event in response_item.astream_events():
|
|
yield event
|
|
|
|
async def text(self) -> str:
|
|
all_chunks = []
|
|
async for chunk in self:
|
|
all_chunks.append(chunk)
|
|
return "".join(all_chunks)
|
|
|
|
|
|
class Options(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
_Options = Options
|
|
|
|
|
|
class _get_key_mixin:
|
|
needs_key: Optional[str] = None
|
|
key: Optional[str] = None
|
|
key_env_var: Optional[str] = None
|
|
|
|
def get_key(self, explicit_key: Optional[str] = None) -> Optional[str]:
|
|
from llm import get_key
|
|
|
|
if self.needs_key is None:
|
|
# This model doesn't use an API key
|
|
return None
|
|
|
|
if self.key is not None:
|
|
# Someone already set model.key='...'
|
|
return self.key
|
|
|
|
# Attempt to load a key using llm.get_key()
|
|
key_value = get_key(
|
|
explicit_key=explicit_key,
|
|
key_alias=self.needs_key,
|
|
env_var=self.key_env_var,
|
|
)
|
|
if key_value:
|
|
return key_value
|
|
|
|
# Show a useful error message
|
|
message = "No key found - add one using 'llm keys set {}'".format(
|
|
self.needs_key
|
|
)
|
|
if self.key_env_var:
|
|
message += " or set the {} environment variable".format(self.key_env_var)
|
|
raise NeedsKeyException(message)
|
|
|
|
|
|
class _BaseModel(ABC, _get_key_mixin):
|
|
model_id: str
|
|
can_stream: bool = False
|
|
attachment_types: Set = set()
|
|
|
|
supports_schema = False
|
|
supports_tools = False
|
|
|
|
class Options(_Options):
|
|
pass
|
|
|
|
def _validate_attachments(
|
|
self, attachments: Optional[List[Attachment]] = None
|
|
) -> None:
|
|
if attachments and not self.attachment_types:
|
|
raise ValueError("This model does not support attachments")
|
|
for attachment in attachments or []:
|
|
attachment_type = attachment.resolve_type()
|
|
if attachment_type not in self.attachment_types:
|
|
raise ValueError(
|
|
f"This model does not support attachments of type '{attachment_type}', "
|
|
f"only {', '.join(self.attachment_types)}"
|
|
)
|
|
|
|
def __str__(self) -> str:
|
|
return "{}{}: {}".format(
|
|
self.__class__.__name__,
|
|
" (async)" if isinstance(self, (AsyncModel, AsyncKeyModel)) else "",
|
|
self.model_id,
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<{str(self)}>"
|
|
|
|
|
|
class _Model(_BaseModel):
|
|
def conversation(
|
|
self,
|
|
tools: Optional[List[ToolDef]] = None,
|
|
before_call: Optional[BeforeCallSync] = None,
|
|
after_call: Optional[AfterCallSync] = None,
|
|
chain_limit: Optional[int] = None,
|
|
) -> Conversation:
|
|
return Conversation(
|
|
model=self,
|
|
tools=tools,
|
|
before_call=before_call,
|
|
after_call=after_call,
|
|
chain_limit=chain_limit,
|
|
)
|
|
|
|
def prompt(
|
|
self,
|
|
prompt: Optional[str] = None,
|
|
*,
|
|
fragments: Optional[List[Union[str, Fragment]]] = None,
|
|
attachments: Optional[List[Attachment]] = None,
|
|
system: Optional[str] = None,
|
|
system_fragments: Optional[List[Union[str, Fragment]]] = None,
|
|
messages: Optional[List[Any]] = None,
|
|
stream: bool = True,
|
|
schema: Optional[Union[dict, type[BaseModel]]] = None,
|
|
tools: Optional[List[ToolDef]] = None,
|
|
tool_results: Optional[List[ToolResult]] = None,
|
|
options: Optional[dict] = None,
|
|
hide_reasoning: bool = False,
|
|
**kwargs,
|
|
) -> Response:
|
|
key_value = kwargs.pop("key", None)
|
|
merged = _merge_options(options, kwargs)
|
|
self._validate_attachments(attachments)
|
|
return Response(
|
|
Prompt(
|
|
prompt,
|
|
fragments=fragments,
|
|
attachments=attachments,
|
|
system=system,
|
|
schema=schema,
|
|
tools=tools,
|
|
tool_results=tool_results,
|
|
system_fragments=system_fragments,
|
|
messages=messages,
|
|
model=self,
|
|
options=self.Options(**merged),
|
|
hide_reasoning=hide_reasoning,
|
|
),
|
|
self,
|
|
stream,
|
|
key=key_value,
|
|
)
|
|
|
|
def chain(
|
|
self,
|
|
prompt: Optional[str] = None,
|
|
*,
|
|
fragments: Optional[List[str]] = None,
|
|
attachments: Optional[List[Attachment]] = None,
|
|
system: Optional[str] = None,
|
|
system_fragments: Optional[List[str]] = None,
|
|
messages: Optional[List[Any]] = None,
|
|
stream: bool = True,
|
|
schema: Optional[Union[dict, type[BaseModel]]] = None,
|
|
tools: Optional[List[ToolDef]] = None,
|
|
tool_results: Optional[List[ToolResult]] = None,
|
|
before_call: Optional[BeforeCallSync] = None,
|
|
after_call: Optional[AfterCallSync] = None,
|
|
key: Optional[str] = None,
|
|
options: Optional[dict] = None,
|
|
hide_reasoning: bool = False,
|
|
) -> ChainResponse:
|
|
return self.conversation().chain(
|
|
prompt=prompt,
|
|
fragments=fragments,
|
|
attachments=attachments,
|
|
system=system,
|
|
system_fragments=system_fragments,
|
|
messages=messages,
|
|
stream=stream,
|
|
schema=schema,
|
|
tools=tools,
|
|
tool_results=tool_results,
|
|
before_call=before_call,
|
|
after_call=after_call,
|
|
key=key,
|
|
options=options,
|
|
hide_reasoning=hide_reasoning,
|
|
)
|
|
|
|
|
|
class Model(_Model):
|
|
@abstractmethod
|
|
def execute(
|
|
self,
|
|
prompt: Prompt,
|
|
stream: bool,
|
|
response: Response,
|
|
conversation: Optional[Conversation],
|
|
) -> Iterator[Union[str, "StreamEvent"]]:
|
|
pass
|
|
|
|
|
|
class KeyModel(_Model):
|
|
@abstractmethod
|
|
def execute(
|
|
self,
|
|
prompt: Prompt,
|
|
stream: bool,
|
|
response: Response,
|
|
conversation: Optional[Conversation],
|
|
key: Optional[str],
|
|
) -> Iterator[Union[str, "StreamEvent"]]:
|
|
pass
|
|
|
|
|
|
class _AsyncModel(_BaseModel):
|
|
def conversation(
|
|
self,
|
|
tools: Optional[List[ToolDef]] = None,
|
|
before_call: Optional[BeforeCallAsync] = None,
|
|
after_call: Optional[AfterCallAsync] = None,
|
|
chain_limit: Optional[int] = None,
|
|
) -> AsyncConversation:
|
|
return AsyncConversation(
|
|
model=self,
|
|
tools=tools,
|
|
before_call=before_call,
|
|
after_call=after_call,
|
|
chain_limit=chain_limit,
|
|
)
|
|
|
|
def prompt(
|
|
self,
|
|
prompt: Optional[str] = None,
|
|
*,
|
|
fragments: Optional[List[Union[str, Fragment]]] = None,
|
|
attachments: Optional[List[Attachment]] = None,
|
|
system: Optional[str] = None,
|
|
schema: Optional[Union[dict, type[BaseModel]]] = None,
|
|
tools: Optional[List[ToolDef]] = None,
|
|
tool_results: Optional[List[ToolResult]] = None,
|
|
system_fragments: Optional[List[Union[str, Fragment]]] = None,
|
|
messages: Optional[List[Any]] = None,
|
|
stream: bool = True,
|
|
options: Optional[dict] = None,
|
|
hide_reasoning: bool = False,
|
|
**kwargs,
|
|
) -> AsyncResponse:
|
|
key_value = kwargs.pop("key", None)
|
|
merged = _merge_options(options, kwargs)
|
|
self._validate_attachments(attachments)
|
|
return AsyncResponse(
|
|
Prompt(
|
|
prompt,
|
|
fragments=fragments,
|
|
attachments=attachments,
|
|
system=system,
|
|
schema=schema,
|
|
tools=tools,
|
|
tool_results=tool_results,
|
|
system_fragments=system_fragments,
|
|
messages=messages,
|
|
model=self,
|
|
options=self.Options(**merged),
|
|
hide_reasoning=hide_reasoning,
|
|
),
|
|
self,
|
|
stream,
|
|
key=key_value,
|
|
)
|
|
|
|
def chain(
|
|
self,
|
|
prompt: Optional[str] = None,
|
|
*,
|
|
fragments: Optional[List[str]] = None,
|
|
attachments: Optional[List[Attachment]] = None,
|
|
system: Optional[str] = None,
|
|
system_fragments: Optional[List[str]] = None,
|
|
messages: Optional[List[Any]] = None,
|
|
stream: bool = True,
|
|
schema: Optional[Union[dict, type[BaseModel]]] = None,
|
|
tools: Optional[List[ToolDef]] = None,
|
|
tool_results: Optional[List[ToolResult]] = None,
|
|
before_call: Optional[BeforeCallAsync] = None,
|
|
after_call: Optional[AfterCallAsync] = None,
|
|
key: Optional[str] = None,
|
|
options: Optional[dict] = None,
|
|
hide_reasoning: bool = False,
|
|
) -> AsyncChainResponse:
|
|
return self.conversation().chain(
|
|
prompt=prompt,
|
|
fragments=fragments,
|
|
attachments=attachments,
|
|
system=system,
|
|
system_fragments=system_fragments,
|
|
messages=messages,
|
|
stream=stream,
|
|
schema=schema,
|
|
tools=tools,
|
|
tool_results=tool_results,
|
|
before_call=before_call,
|
|
after_call=after_call,
|
|
key=key,
|
|
options=options,
|
|
hide_reasoning=hide_reasoning,
|
|
)
|
|
|
|
|
|
class AsyncModel(_AsyncModel):
|
|
@abstractmethod
|
|
async def execute(
|
|
self,
|
|
prompt: Prompt,
|
|
stream: bool,
|
|
response: AsyncResponse,
|
|
conversation: Optional[AsyncConversation],
|
|
) -> AsyncGenerator[Union[str, "StreamEvent"], None]:
|
|
if False: # Ensure it's a generator type
|
|
yield ""
|
|
pass
|
|
|
|
|
|
class AsyncKeyModel(_AsyncModel):
|
|
@abstractmethod
|
|
async def execute(
|
|
self,
|
|
prompt: Prompt,
|
|
stream: bool,
|
|
response: AsyncResponse,
|
|
conversation: Optional[AsyncConversation],
|
|
key: Optional[str],
|
|
) -> AsyncGenerator[Union[str, "StreamEvent"], None]:
|
|
if False: # Ensure it's a generator type
|
|
yield ""
|
|
pass
|
|
|
|
|
|
class EmbeddingModel(ABC, _get_key_mixin):
|
|
model_id: str
|
|
key: Optional[str] = None
|
|
needs_key: Optional[str] = None
|
|
key_env_var: Optional[str] = None
|
|
supports_text: bool = True
|
|
supports_binary: bool = False
|
|
batch_size: Optional[int] = None
|
|
|
|
def _check(self, item: Union[str, bytes]):
|
|
if not self.supports_binary and isinstance(item, bytes):
|
|
raise ValueError(
|
|
"This model does not support binary data, only text strings"
|
|
)
|
|
if not self.supports_text and isinstance(item, str):
|
|
raise ValueError(
|
|
"This model does not support text strings, only binary data"
|
|
)
|
|
|
|
def embed(self, item: Union[str, bytes]) -> List[float]:
|
|
"Embed a single text string or binary blob, return a list of floats"
|
|
self._check(item)
|
|
return next(iter(self.embed_batch([item])))
|
|
|
|
def embed_multi(
|
|
self, items: Iterable[Union[str, bytes]], batch_size: Optional[int] = None
|
|
) -> Iterator[List[float]]:
|
|
"Embed multiple items in batches according to the model batch_size"
|
|
iter_items = iter(items)
|
|
effective_batch_size = self.batch_size if batch_size is None else batch_size
|
|
if (not self.supports_binary) or (not self.supports_text):
|
|
|
|
def checking_iter(inner_items):
|
|
for item_to_check in inner_items:
|
|
self._check(item_to_check)
|
|
yield item_to_check
|
|
|
|
iter_items = checking_iter(items)
|
|
if effective_batch_size is None:
|
|
yield from self.embed_batch(iter_items)
|
|
return
|
|
while True:
|
|
batch_items = list(islice(iter_items, effective_batch_size))
|
|
if not batch_items:
|
|
break
|
|
yield from self.embed_batch(batch_items)
|
|
|
|
@abstractmethod
|
|
def embed_batch(self, items: Iterable[Union[str, bytes]]) -> Iterator[List[float]]:
|
|
"""
|
|
Embed a batch of strings or blobs, return a list of lists of floats
|
|
"""
|
|
pass
|
|
|
|
def __str__(self) -> str:
|
|
return "{}: {}".format(self.__class__.__name__, self.model_id)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<{str(self)}>"
|
|
|
|
|
|
@dataclass
|
|
class ModelWithAliases:
|
|
"A model with its optional async counterpart and aliases."
|
|
|
|
model: Model
|
|
async_model: AsyncModel
|
|
aliases: Set[str]
|
|
|
|
def matches(self, query: str) -> bool:
|
|
query_lower = query.lower()
|
|
all_strings: List[str] = []
|
|
all_strings.extend(self.aliases)
|
|
if self.model:
|
|
all_strings.append(str(self.model))
|
|
if self.async_model:
|
|
all_strings.append(str(self.async_model.model_id))
|
|
return any(query_lower in alias.lower() for alias in all_strings)
|
|
|
|
|
|
@dataclass
|
|
class EmbeddingModelWithAliases:
|
|
model: EmbeddingModel
|
|
aliases: Set[str]
|
|
|
|
def matches(self, query: str) -> bool:
|
|
query_lower = query.lower()
|
|
all_strings: List[str] = []
|
|
all_strings.extend(self.aliases)
|
|
all_strings.append(str(self.model))
|
|
return any(query_lower in alias.lower() for alias in all_strings)
|
|
|
|
|
|
def _conversation_name(text):
|
|
# Collapse whitespace, including newlines
|
|
text = re.sub(r"\s+", " ", text)
|
|
if len(text) <= CONVERSATION_NAME_LENGTH:
|
|
return text
|
|
return text[: CONVERSATION_NAME_LENGTH - 1] + "…"
|
|
|
|
|
|
def _ensure_dict_schema(schema):
|
|
"""Convert a Pydantic model to a JSON schema dict if needed."""
|
|
if schema and not isinstance(schema, dict) and issubclass(schema, BaseModel):
|
|
schema_dict = schema.model_json_schema()
|
|
_remove_titles_recursively(schema_dict)
|
|
return schema_dict
|
|
return schema
|
|
|
|
|
|
def _remove_titles_recursively(obj):
|
|
"""Recursively remove all 'title' fields from a nested dictionary."""
|
|
if isinstance(obj, dict):
|
|
# Remove title if present
|
|
obj.pop("title", None)
|
|
|
|
# Recursively process all values
|
|
for value in obj.values():
|
|
_remove_titles_recursively(value)
|
|
elif isinstance(obj, list):
|
|
# Process each item in lists
|
|
for item in obj:
|
|
_remove_titles_recursively(item)
|
|
|
|
|
|
def _get_instance(implementation):
|
|
if hasattr(implementation, "__self__"):
|
|
return implementation.__self__
|
|
return None
|