Source code for modelly.llm.request

"""One call to a language model and its reply, and the cache key that names the call."""

from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass, field
from typing import Any

USER = "user"
ASSISTANT = "assistant"
ROLES = frozenset({USER, ASSISTANT})
# Version of the key recipe: bump it and every old cache entry stops matching.
KEY_VERSION = 1
# How much of a prompt an error message quotes.
EXCERPT = 60


[docs] @dataclass(frozen=True) class Message: """One turn of a conversation: who spoke (``"user"`` or ``"assistant"``) and what.""" role: str content: str def __post_init__(self) -> None: """Refuse a role the providers do not share; the system prompt is separate.""" if self.role not in ROLES: raise ValueError(f"role must be one of {sorted(ROLES)}, got {self.role!r}")
[docs] @dataclass(frozen=True) class Request: """Everything that decides a reply, and who asked. ``scope`` and ``call_index`` say which caller made the call and how many it made before: an agent's id and its own call counter. They are part of the cache key, so two agents asking the same question get their own replies, and the key of a call does not depend on which thread sent it. Example: >>> r = Request("fake", "m", (Message("user", "Hi"),), scope="a1", call_index=0) >>> len(r.key()) 64 """ provider: str model: str messages: tuple[Message, ...] system: str = "" params: tuple[tuple[str, Any], ...] = () scope: str = "model" call_index: int = 0 seed: int | None = None schema: dict | None = field(default=None, compare=False, hash=False) @property def max_tokens(self) -> int | None: """The ``max_tokens`` parameter, the most the reply may use.""" return dict(self.params).get("max_tokens")
[docs] def identity(self) -> dict[str, Any]: """The fields the cache key hashes, as plain JSON values.""" return { "v": KEY_VERSION, "provider": self.provider, "model": self.model, "system": self.system, "messages": [[m.role, m.content] for m in self.messages], "params": sorted([k, v] for k, v in self.params), "scope": self.scope, "call": self.call_index, "seed": self.seed, }
[docs] def key(self) -> str: """SHA-256 of the canonical JSON of :meth:`identity`: the cache key.""" text = json.dumps(self.identity(), sort_keys=True, separators=(",", ":")) return hashlib.sha256(text.encode("utf-8")).hexdigest()
[docs] def prompt_chars(self) -> int: """Characters in the system prompt and every message.""" return len(self.system) + sum(len(m.content) for m in self.messages)
[docs] def excerpt(self) -> str: """The start of the last message, for error messages.""" text = self.messages[-1].content if self.messages else "" return text[:EXCERPT]
[docs] def follow_up(self, reply: str, prompt: str, call_index: int) -> Request: """This conversation, the model's ``reply``, then a new user ``prompt``.""" messages = (*self.messages, Message(ASSISTANT, reply), Message(USER, prompt)) return Request( self.provider, self.model, messages, self.system, self.params, self.scope, call_index, self.seed, self.schema, )
[docs] @dataclass(frozen=True) class Response: """A reply: its text, the tokens it used, and whether it came from the cache.""" text: str input_tokens: int = 0 output_tokens: int = 0 cached: bool = False key: str = "" @property def tokens(self) -> int: """Input plus output tokens.""" return self.input_tokens + self.output_tokens
def freeze_params(params: dict[str, Any]) -> tuple[tuple[str, Any], ...]: """Sorted ``(name, value)`` pairs, dropping None values, so requests hash and compare.""" return tuple(sorted((k, v) for k, v in params.items() if v is not None))