From 4c907e5d9251db55350cc5c6c11dde0fc8bd52bb Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Thu, 11 Dec 2025 17:15:40 -0500 Subject: [PATCH 01/21] implemented unified encoding type --- effectful/handlers/llm/encoding.py | 179 +++++++++++++ tests/test_handlers_llm_encoding.py | 398 ++++++++++++++++++++++++++++ 2 files changed, 577 insertions(+) create mode 100644 effectful/handlers/llm/encoding.py create mode 100644 tests/test_handlers_llm_encoding.py diff --git a/effectful/handlers/llm/encoding.py b/effectful/handlers/llm/encoding.py new file mode 100644 index 00000000..fd049f4f --- /dev/null +++ b/effectful/handlers/llm/encoding.py @@ -0,0 +1,179 @@ +import base64 +import io +import numbers +import typing +from abc import ABC, abstractmethod +from collections.abc import Callable + +from litellm import ChatCompletionImageUrlObject +from PIL import Image + +from effectful.ops.syntax import _CustomSingleDispatchCallable + + +def _pil_image_to_base64_data(pil_image: Image.Image) -> str: + buf = io.BytesIO() + pil_image.save(buf, format="PNG") + return base64.b64encode(buf.getvalue()).decode("utf-8") + + +def _pil_image_to_base64_data_uri(pil_image: Image.Image) -> str: + return f"data:image/png;base64,{_pil_image_to_base64_data(pil_image)}" + + +class _Encodable[T, U](ABC): + t: type[U] + + @classmethod + @abstractmethod + def encode(cls, t: T) -> U: + pass + + @classmethod + @abstractmethod + def decode(cls, t: U) -> T: + pass + + +class Encodable[T](_Encodable[T, type]): + t = type + + +@_CustomSingleDispatchCallable +def type_to_encodable_type[T]( + __dispatch: Callable[[type[T]], Callable[..., Encodable[T]]], ty: type[T] +) -> Encodable[T]: + origin_ty = typing.get_origin(ty) or ty + return __dispatch(origin_ty)(ty) + + +@type_to_encodable_type.register(object) +@type_to_encodable_type.register(int) +@type_to_encodable_type.register(float) +@type_to_encodable_type.register(bool) +def _type_encodable_type_base[T](ty: type[T]) -> Encodable[T]: + class BaseEncodable(_Encodable[T, T]): + t = ty + + @classmethod + def encode(cls, t: T) -> T: + return t + + @classmethod + def decode(cls, t: T) -> T: + return t + + return typing.cast(Encodable[T], BaseEncodable()) + + +@type_to_encodable_type.register(numbers.Number) +class EncodableNumber(_Encodable[numbers.Number, float]): + t = float + + def __init__(self, _): + pass + + @classmethod + def encode(cls, n: numbers.Number) -> float: + return float(n) # type: ignore + + @classmethod + def decode(cls, n: float) -> numbers.Number: + return typing.cast(numbers.Number, n) + + +@type_to_encodable_type.register(Image.Image) +class EncodableImage(_Encodable[Image.Image, ChatCompletionImageUrlObject]): + t = ChatCompletionImageUrlObject + + def __init__(self, _): + pass + + @classmethod + def encode(cls, image: Image.Image) -> ChatCompletionImageUrlObject: + return { + "detail": "auto", + "url": _pil_image_to_base64_data_uri(image), + } + + @classmethod + def decode(cls, image: ChatCompletionImageUrlObject) -> Image.Image: + image_url = image["url"] + if not image_url.startswith("data:image/"): + raise RuntimeError( + f"expected base64 encoded image as data uri, received {image_url}" + ) + data = image_url.split(",")[1] + return Image.open(fp=io.BytesIO(base64.b64decode(data))) + + +@type_to_encodable_type.register(tuple) +def _type_encodable_type_tuple[T](ty: type[T]) -> Encodable[T]: + args = typing.get_args(ty) + + # Handle empty tuple, or tuple with no args + if not args or args == ((),): + return _type_encodable_type_base(ty) + + # Create encoders for each element type + element_encoders = [type_to_encodable_type(arg) for arg in args] + + encoded_ty: type = tuple[*(encoder.t for encoder in element_encoders)] # type: ignore + + class TupleEncodable(_Encodable[T, encoded_ty]): # type: ignore + t = encoded_ty + + @classmethod + def encode(cls, t: T) -> encoded_ty: # type: ignore + if not isinstance(t, tuple): + raise TypeError(f"Expected tuple, got {type(t)}") + if len(t) != len(element_encoders): + raise ValueError( + f"Tuple length {len(t)} does not match expected length {len(element_encoders)}" + ) + return tuple([enc.encode(elem) for enc, elem in zip(element_encoders, t)]) + + @classmethod + def decode(cls, t: encoded_ty) -> T: # type: ignore + if len(t) != len(element_encoders): + raise ValueError( + f"tuple length {len(t)} does not match expected length {len(element_encoders)}" + ) + decoded_elements = [ # type: ignore + enc.decode(elem) for enc, elem in zip(element_encoders, t) + ] + return typing.cast(T, tuple(decoded_elements)) + + return typing.cast(Encodable[T], TupleEncodable()) + + +@type_to_encodable_type.register(list) +def _type_encodable_type_list[T](ty: type[T]) -> Encodable[T]: + args = typing.get_args(ty) + + # Handle unparameterized list (list without type args) + if not args: + return _type_encodable_type_base(ty) + + # Get the element type (first type argument) + element_ty = args[0] + element_encoder: Encodable[T] = type_to_encodable_type(element_ty) + + # Build the encoded type (list of encoded element type) + encoded_ty: type = list[element_encoder.t] # type: ignore + + class ListEncodable(_Encodable[T, encoded_ty]): # type: ignore + t = encoded_ty + + @classmethod + def encode(cls, t: T) -> encoded_ty: # type: ignore + if not isinstance(t, list): + raise TypeError(f"Expected list, got {type(t)}") + return [element_encoder.encode(elem) for elem in t] + + @classmethod + def decode(cls, t: encoded_ty) -> T: # type: ignore + decoded_elements = [element_encoder.decode(elem) for elem in t] # type: ignore + return typing.cast(T, decoded_elements) + + return typing.cast(Encodable[T], ListEncodable()) diff --git a/tests/test_handlers_llm_encoding.py b/tests/test_handlers_llm_encoding.py new file mode 100644 index 00000000..92572f78 --- /dev/null +++ b/tests/test_handlers_llm_encoding.py @@ -0,0 +1,398 @@ +import numbers +from typing import NamedTuple, TypedDict + +import pydantic +import pytest +from PIL import Image + +from effectful.handlers.llm.encoding import type_to_encodable_type + + +def test_type_to_encodable_type_str(): + encodable = type_to_encodable_type(str) + encoded = encodable.encode("hello") + decoded = encodable.decode(encoded) + assert decoded == "hello" + Model = pydantic.create_model("Model", value=encodable.t) + decoded = Model.model_validate({"value": "hello"}) + assert decoded.value == "hello" + + +def test_type_to_encodable_type_int(): + encodable = type_to_encodable_type(int) + encoded = encodable.encode(42) + decoded = encodable.decode(encoded) + assert decoded == 42 + assert isinstance(decoded, int) + Model = pydantic.create_model("Model", value=encodable.t) + decoded = Model.model_validate({"value": 42}) + assert decoded.value == 42 + assert isinstance(decoded.value, int) + + +def test_type_to_encodable_type_bool(): + encodable = type_to_encodable_type(bool) + encoded = encodable.encode(True) + decoded = encodable.decode(encoded) + assert decoded is True + assert isinstance(decoded, bool) + encoded_false = encodable.encode(False) + decoded_false = encodable.decode(encoded_false) + assert decoded_false is False + Model = pydantic.create_model("Model", value=encodable.t) + decoded = Model.model_validate({"value": True}) + assert decoded.value is True + assert isinstance(decoded.value, bool) + + +def test_type_to_encodable_type_float(): + encodable = type_to_encodable_type(float) + encoded = encodable.encode(3.14) + decoded = encodable.decode(encoded) + assert decoded == 3.14 + assert isinstance(decoded, float) + Model = pydantic.create_model("Model", value=encodable.t) + decoded = Model.model_validate({"value": 3.14}) + assert decoded.value == 3.14 + assert isinstance(decoded.value, float) + + +def test_type_to_encodable_type_image(): + encodable = type_to_encodable_type(Image.Image) + image = Image.new("RGB", (10, 10), color="red") + encoded = encodable.encode(image) + assert isinstance(encoded, dict) + assert "url" in encoded + assert "detail" in encoded + assert encoded["detail"] == "auto" + assert encoded["url"].startswith("data:image/png;base64,") + decoded = encodable.decode(encoded) + assert isinstance(decoded, Image.Image) + assert decoded.size == (10, 10) + Model = pydantic.create_model("Model", value=encodable.t) + decoded = Model.model_validate({"value": encoded}) + assert decoded.value["url"] == encoded["url"] + assert decoded.value["detail"] == "auto" + + +def test_type_to_encodable_type_image_roundtrip(): + encodable = type_to_encodable_type(Image.Image) + original = Image.new("RGB", (20, 20), color="green") + encoded = encodable.encode(original) + decoded = encodable.decode(encoded) + assert isinstance(decoded, Image.Image) + assert decoded.size == original.size + assert decoded.mode == original.mode + + +def test_type_to_encodable_type_image_decode_invalid_url(): + encodable = type_to_encodable_type(Image.Image) + encoded = {"url": "http://example.com/image.png", "detail": "auto"} + with pytest.raises(RuntimeError, match="expected base64 encoded image as data uri"): + encodable.decode(encoded) + + +def test_type_to_encodable_type_tuple(): + encodable = type_to_encodable_type(tuple[int, str]) + value = (1, "test") + encoded = encodable.encode(value) + decoded = encodable.decode(encoded) + assert decoded == value + assert isinstance(decoded, tuple) + assert decoded[0] == 1 + assert decoded[1] == "test" + # Test with pydantic model validation + Model = pydantic.create_model("Model", value=encodable.t) + model_instance = Model.model_validate({"value": encoded}) + assert model_instance.value == encoded + assert isinstance(model_instance.value, tuple) + assert model_instance.value[0] == 1 + assert model_instance.value[1] == "test" + # Decode from model + decoded_from_model = encodable.decode(model_instance.value) + assert decoded_from_model == value + assert isinstance(decoded_from_model, tuple) + + +def test_type_to_encodable_type_tuple_empty(): + encodable = type_to_encodable_type(tuple[()]) + value = () + encoded = encodable.encode(value) + decoded = encodable.decode(encoded) + assert decoded == value + assert isinstance(decoded, tuple) + assert len(decoded) == 0 + # Test with pydantic model validation + Model = pydantic.create_model("Model", value=encodable.t) + model_instance = Model.model_validate({"value": encoded}) + assert model_instance.value == encoded + assert isinstance(model_instance.value, tuple) + assert len(model_instance.value) == 0 + # Decode from model + decoded_from_model = encodable.decode(model_instance.value) + assert decoded_from_model == value + assert isinstance(decoded_from_model, tuple) + + +def test_type_to_encodable_type_tuple_three_elements(): + encodable = type_to_encodable_type(tuple[int, str, bool]) + value = (42, "hello", True) + encoded = encodable.encode(value) + decoded = encodable.decode(encoded) + assert decoded == value + assert isinstance(decoded, tuple) + assert decoded[0] == 42 + assert decoded[1] == "hello" + assert decoded[2] is True + # Test with pydantic model validation + Model = pydantic.create_model("Model", value=encodable.t) + model_instance = Model.model_validate({"value": encoded}) + assert model_instance.value == encoded + assert isinstance(model_instance.value, tuple) + assert model_instance.value[0] == 42 + assert model_instance.value[1] == "hello" + assert model_instance.value[2] is True + # Decode from model + decoded_from_model = encodable.decode(model_instance.value) + assert decoded_from_model == value + assert isinstance(decoded_from_model, tuple) + + +def test_type_to_encodable_type_list(): + encodable = type_to_encodable_type(list[int]) + value = [1, 2, 3, 4, 5] + encoded = encodable.encode(value) + decoded = encodable.decode(encoded) + assert decoded == value + assert isinstance(decoded, list) + assert all(isinstance(elem, int) for elem in decoded) + # Test with pydantic model validation + Model = pydantic.create_model("Model", value=encodable.t) + model_instance = Model.model_validate({"value": encoded}) + assert model_instance.value == encoded + assert isinstance(model_instance.value, list) + assert model_instance.value == [1, 2, 3, 4, 5] + # Decode from model + decoded_from_model = encodable.decode(model_instance.value) + assert decoded_from_model == value + assert isinstance(decoded_from_model, list) + assert all(isinstance(elem, int) for elem in decoded_from_model) + + +def test_type_to_encodable_type_list_str(): + encodable = type_to_encodable_type(list[str]) + value = ["hello", "world", "test"] + encoded = encodable.encode(value) + decoded = encodable.decode(encoded) + assert decoded == value + assert isinstance(decoded, list) + assert all(isinstance(elem, str) for elem in decoded) + # Test with pydantic model validation + Model = pydantic.create_model("Model", value=encodable.t) + model_instance = Model.model_validate({"value": encoded}) + assert model_instance.value == encoded + assert isinstance(model_instance.value, list) + assert model_instance.value == ["hello", "world", "test"] + # Decode from model + decoded_from_model = encodable.decode(model_instance.value) + assert decoded_from_model == value + assert isinstance(decoded_from_model, list) + assert all(isinstance(elem, str) for elem in decoded_from_model) + + +def test_type_to_encodable_type_namedtuple(): + class Point(NamedTuple): + x: int + y: int + + encodable = type_to_encodable_type(Point) + point = Point(10, 20) + encoded = encodable.encode(point) + decoded = encodable.decode(encoded) + assert decoded == point + assert isinstance(decoded, Point) + assert decoded.x == 10 + assert decoded.y == 20 + Model = pydantic.create_model("Model", value=encodable.t) + decoded = Model.model_validate({"value": {"x": 10, "y": 20}}) + assert decoded.value == point + assert isinstance(decoded.value, Point) + + +def test_type_to_encodable_type_namedtuple_with_str(): + class Person(NamedTuple): + name: str + age: int + + encodable = type_to_encodable_type(Person) + person = Person("Alice", 30) + encoded = encodable.encode(person) + decoded = encodable.decode(encoded) + assert decoded == person + assert isinstance(decoded, Person) + assert decoded.name == "Alice" + assert decoded.age == 30 + Model = pydantic.create_model("Model", value=encodable.t) + decoded = Model.model_validate({"value": {"name": "Alice", "age": 30}}) + assert decoded.value == person + assert isinstance(decoded.value, Person) + + +def test_type_to_encodable_type_typeddict(): + class User(TypedDict): + name: str + age: int + + encodable = type_to_encodable_type(User) + user = User(name="Bob", age=25) + encoded = encodable.encode(user) + decoded = encodable.decode(encoded) + assert decoded == user + assert isinstance(decoded, dict) + assert decoded["name"] == "Bob" + assert decoded["age"] == 25 + Model = pydantic.create_model("Model", value=encodable.t) + decoded = Model.model_validate({"value": {"name": "Bob", "age": 25}}) + assert decoded.value == user + assert isinstance(decoded.value, dict) + + +def test_type_to_encodable_type_typeddict_optional(): + class Config(TypedDict, total=False): + host: str + port: int + + encodable = type_to_encodable_type(Config) + config = Config(host="localhost", port=8080) + encoded = encodable.encode(config) + decoded = encodable.decode(encoded) + assert decoded == config + assert decoded["host"] == "localhost" + assert decoded["port"] == 8080 + Model = pydantic.create_model("Model", value=encodable.t) + decoded = Model.model_validate({"value": {"host": "localhost", "port": 8080}}) + assert decoded.value == config + assert isinstance(decoded.value, dict) + + +def test_type_to_encodable_type_number_float(): + encodable = type_to_encodable_type(numbers.Number) + Model = pydantic.create_model("Model", value=encodable.t) + decoded = Model.model_validate({"value": 3.14}) + assert decoded.value == 3.14 + + +def test_type_to_encodable_type_tuple_of_images(): + encodable = type_to_encodable_type(tuple[Image.Image, Image.Image]) + image1 = Image.new("RGB", (10, 10), color="red") + image2 = Image.new("RGB", (20, 20), color="blue") + value = (image1, image2) + + encoded = encodable.encode(value) + assert isinstance(encoded, tuple) + assert len(encoded) == 2 + assert isinstance(encoded[0], dict) + assert isinstance(encoded[1], dict) + assert "url" in encoded[0] + assert "url" in encoded[1] + assert encoded[0]["url"].startswith("data:image/png;base64,") + assert encoded[1]["url"].startswith("data:image/png;base64,") + + decoded = encodable.decode(encoded) + assert isinstance(decoded, tuple) + assert len(decoded) == 2 + assert isinstance(decoded[0], Image.Image) + assert isinstance(decoded[1], Image.Image) + assert decoded[0].size == (10, 10) + assert decoded[1].size == (20, 20) + + # Test with pydantic model validation + Model = pydantic.create_model("Model", value=encodable.t) + model_instance = Model.model_validate({"value": encoded}) + assert model_instance.value == encoded + assert isinstance(model_instance.value, tuple) + assert len(model_instance.value) == 2 + assert isinstance(model_instance.value[0], dict) + assert isinstance(model_instance.value[1], dict) + assert model_instance.value[0]["url"] == encoded[0]["url"] + assert model_instance.value[1]["url"] == encoded[1]["url"] + # Decode from model + decoded_from_model = encodable.decode(model_instance.value) + assert isinstance(decoded_from_model, tuple) + assert len(decoded_from_model) == 2 + assert isinstance(decoded_from_model[0], Image.Image) + assert isinstance(decoded_from_model[1], Image.Image) + assert decoded_from_model[0].size == (10, 10) + assert decoded_from_model[1].size == (20, 20) + + # Roundtrip test + original = ( + Image.new("RGB", (15, 15), color="green"), + Image.new("RGB", (25, 25), color="yellow"), + ) + encoded_roundtrip = encodable.encode(original) + decoded_roundtrip = encodable.decode(encoded_roundtrip) + assert isinstance(decoded_roundtrip, tuple) + assert len(decoded_roundtrip) == 2 + assert decoded_roundtrip[0].size == original[0].size + assert decoded_roundtrip[1].size == original[1].size + assert decoded_roundtrip[0].mode == original[0].mode + assert decoded_roundtrip[1].mode == original[1].mode + + +def test_type_to_encodable_type_list_of_images(): + encodable = type_to_encodable_type(list[Image.Image]) + images = [ + Image.new("RGB", (10, 10), color="red"), + Image.new("RGB", (20, 20), color="blue"), + Image.new("RGB", (30, 30), color="green"), + ] + + encoded = encodable.encode(images) + assert isinstance(encoded, list) + assert len(encoded) == 3 + assert all(isinstance(elem, dict) for elem in encoded) + assert all("url" in elem for elem in encoded) + assert all(elem["url"].startswith("data:image/png;base64,") for elem in encoded) + + decoded = encodable.decode(encoded) + assert isinstance(decoded, list) + assert len(decoded) == 3 + assert all(isinstance(elem, Image.Image) for elem in decoded) + assert decoded[0].size == (10, 10) + assert decoded[1].size == (20, 20) + assert decoded[2].size == (30, 30) + + # Test with pydantic model validation + Model = pydantic.create_model("Model", value=encodable.t) + model_instance = Model.model_validate({"value": encoded}) + assert model_instance.value == encoded + assert isinstance(model_instance.value, list) + assert len(model_instance.value) == 3 + assert all(isinstance(elem, dict) for elem in model_instance.value) + assert all("url" in elem for elem in model_instance.value) + assert model_instance.value[0]["url"] == encoded[0]["url"] + assert model_instance.value[1]["url"] == encoded[1]["url"] + assert model_instance.value[2]["url"] == encoded[2]["url"] + # Decode from model + decoded_from_model = encodable.decode(model_instance.value) + assert isinstance(decoded_from_model, list) + assert len(decoded_from_model) == 3 + assert all(isinstance(elem, Image.Image) for elem in decoded_from_model) + assert decoded_from_model[0].size == (10, 10) + assert decoded_from_model[1].size == (20, 20) + assert decoded_from_model[2].size == (30, 30) + + # Roundtrip test + original = [ + Image.new("RGB", (15, 15), color="yellow"), + Image.new("RGB", (25, 25), color="purple"), + ] + encoded_roundtrip = encodable.encode(original) + decoded_roundtrip = encodable.decode(encoded_roundtrip) + assert isinstance(decoded_roundtrip, list) + assert len(decoded_roundtrip) == 2 + assert decoded_roundtrip[0].size == original[0].size + assert decoded_roundtrip[1].size == original[1].size + assert decoded_roundtrip[0].mode == original[0].mode + assert decoded_roundtrip[1].mode == original[1].mode From 3477aa147fda408f1cfb1ca7b8b915e1e1a0a441 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Thu, 11 Dec 2025 21:03:28 -0500 Subject: [PATCH 02/21] implemented decoding --- effectful/handlers/llm/encoding.py | 98 +++++++++++- effectful/handlers/llm/providers.py | 173 ++++++++++++++-------- tests/test_handlers_llm_encoding.py | 221 ++++++++++++++++++++++++++++ 3 files changed, 420 insertions(+), 72 deletions(-) diff --git a/effectful/handlers/llm/encoding.py b/effectful/handlers/llm/encoding.py index fd049f4f..af643c9c 100644 --- a/effectful/handlers/llm/encoding.py +++ b/effectful/handlers/llm/encoding.py @@ -1,12 +1,15 @@ import base64 +import dataclasses import io import numbers import typing from abc import ABC, abstractmethod from collections.abc import Callable +import pydantic from litellm import ChatCompletionImageUrlObject from PIL import Image +from pydantic import Field from effectful.ops.syntax import _CustomSingleDispatchCallable @@ -48,20 +51,28 @@ def type_to_encodable_type[T]( @type_to_encodable_type.register(object) +def _type_encodable_type_object[T](ty: type[T]) -> Encodable[T]: + # Check if it's a dataclass and redirect to dataclass handler + if dataclasses.is_dataclass(ty): + return _type_encodable_type_dataclass(ty) + return _type_encodable_type_base(ty) + + @type_to_encodable_type.register(int) @type_to_encodable_type.register(float) @type_to_encodable_type.register(bool) +@type_to_encodable_type.register(str) def _type_encodable_type_base[T](ty: type[T]) -> Encodable[T]: class BaseEncodable(_Encodable[T, T]): t = ty @classmethod - def encode(cls, t: T) -> T: - return t + def encode(cls, vl): + return vl @classmethod - def decode(cls, t: T) -> T: - return t + def decode(cls, vl: t) -> T: # type: ignore + return vl return typing.cast(Encodable[T], BaseEncodable()) @@ -74,12 +85,12 @@ def __init__(self, _): pass @classmethod - def encode(cls, n: numbers.Number) -> float: - return float(n) # type: ignore + def encode(cls, vl: numbers.Number) -> t: # type: ignore + return float(vl) # type: ignore @classmethod - def decode(cls, n: float) -> numbers.Number: - return typing.cast(numbers.Number, n) + def decode(cls, vl: t) -> numbers.Number: # type: ignore + return vl @type_to_encodable_type.register(Image.Image) @@ -107,6 +118,77 @@ def decode(cls, image: ChatCompletionImageUrlObject) -> Image.Image: return Image.open(fp=io.BytesIO(base64.b64decode(data))) +def _type_encodable_type_dataclass[T](ty: type[T]) -> Encodable[T]: + """Handle dataclass encoding/decoding with recursive field encoding.""" + if not dataclasses.is_dataclass(ty): + raise TypeError(f"Expected dataclass, got {ty}") + + fields = dataclasses.fields(ty) + + # Create encoders for each field type + field_encoders: dict[str, Encodable] = {} + encoded_field_types: dict[str, typing.Any] = {} + + for field in fields: + field_encoder = type_to_encodable_type(field.type) # type: ignore + field_encoders[field.name] = field_encoder + + # Determine if field is required or has a default + if field.default != dataclasses.MISSING: + # Field has a default value + encoded_field_types[field.name] = (field_encoder.t, field.default) + elif field.default_factory != dataclasses.MISSING: + # Field has a default factory + encoded_field_types[field.name] = ( + field_encoder.t, + Field(default_factory=field.default_factory), + ) + else: + # Required field + encoded_field_types[field.name] = (field_encoder.t, ...) + + # Create a dynamic pydantic model for the encoded type + model_name = f"{ty.__name__}Encoded" + EncodedModel = pydantic.create_model(model_name, **encoded_field_types) + + class DataclassEncodable(_Encodable[T, EncodedModel]): # type: ignore + t = EncodedModel + + @classmethod + def encode(cls, t: T) -> EncodedModel: # type: ignore + if not isinstance(t, ty): + raise TypeError(f"Expected {ty}, got {type(t)}") + + result: dict[str, typing.Any] = {} + for field in fields: + field_value = getattr(t, field.name) + field_encoder = field_encoders[field.name] + result[field.name] = field_encoder.encode(field_value) + + return EncodedModel(**result) + + @classmethod + def decode(cls, vl: EncodedModel | dict[str, typing.Any]) -> T: # type: ignore + # Handle both pydantic model instance and dict + if isinstance(vl, dict): + # Validate dict and convert to model + validated = EncodedModel.model_validate(vl) + else: + validated = vl + + decoded_fields: dict[str, typing.Any] = {} + + for field in fields: + # Get value from validated model + field_value = getattr(validated, field.name) + field_encoder = field_encoders[field.name] + decoded_fields[field.name] = field_encoder.decode(field_value) + + return typing.cast(T, ty(**decoded_fields)) + + return typing.cast(Encodable[T], DataclassEncodable()) + + @type_to_encodable_type.register(tuple) def _type_encodable_type_tuple[T](ty: type[T]) -> Encodable[T]: args = typing.get_args(ty) diff --git a/effectful/handlers/llm/providers.py b/effectful/handlers/llm/providers.py index d0e9c1aa..c00a402e 100644 --- a/effectful/handlers/llm/providers.py +++ b/effectful/handlers/llm/providers.py @@ -13,6 +13,8 @@ import litellm import pydantic +from effectful.handlers.llm.encoding import type_to_encodable_type + try: from PIL import Image except ImportError: @@ -20,6 +22,7 @@ from litellm import ( ChatCompletionImageObject, + ChatCompletionImageUrlObject, Choices, Message, OpenAIChatCompletionToolParam, @@ -58,33 +61,41 @@ def _pil_image_to_openai_image_param( @defop @functools.singledispatch -def format_value(value: Any) -> OpenAIMessageContent: +def serialize(value: Any) -> OpenAIMessageContent: """Convert a Python value to internal message part representation. This function can be extended by registering handlers for - different types using @format_value.register. + different types using @serialize.register. Returns a OpenAIMessageContent - either a string or a list of OpenAIMessageContentListBlock. """ return [{"type": "text", "text": str(value)}] -@format_value.register(Image.Image) # type: ignore -def _(value: Image.Image) -> OpenAIMessageContent: - return [_pil_image_to_openai_image_param(value)] +@serialize.register(dict) # type: ignore +def _(value: dict) -> OpenAIMessageContent: + if "url" in value and isinstance(value["url"], str): + return [ + { + "type": "image_url", + "image_url": typing.cast(ChatCompletionImageUrlObject, value), + } + ] + else: + return [{"type": "text", "text": str(value)}] -@format_value.register(str) # type: ignore +@serialize.register(str) # type: ignore def _(value: str) -> OpenAIMessageContent: return [{"type": "text", "text": value}] -@format_value.register(bytes) # type: ignore +@serialize.register(bytes) # type: ignore def _(value: bytes) -> OpenAIMessageContent: return [{"type": "text", "text": str(value)}] -@format_value.register(Sequence) # type: ignore +@serialize.register(Sequence) # type: ignore def _(values: Sequence) -> OpenAIMessageContent: if all(isinstance(value, Image.Image) for value in values): return [_pil_image_to_openai_image_param(value) for value in values] @@ -94,32 +105,69 @@ def _(values: Sequence) -> OpenAIMessageContent: @dataclasses.dataclass class Tool[**P, T]: - parameter_model: type[pydantic.BaseModel] operation: Operation[P, T] name: str def serialise_return_value(self, value) -> OpenAIMessageContent: """Serializes a value returned by the function into a json format suitable for the OpenAI API.""" sig = inspect.signature(self.operation) - ret_ty = sig.return_annotation - ret_ty_origin = typing.get_origin(ret_ty) or ret_ty + encoded_ty = type_to_encodable_type(sig.return_annotation) + encoded_value = encoded_ty.encode(value) + return serialize.dispatch(encoded_ty.t)(encoded_value) # type: ignore - return format_value.dispatch(ret_ty_origin)(value) # type: ignore - - @classmethod - def of_operation(cls, op: Operation[P, T], name: str): + @property + def parameter_model(self) -> type[pydantic.BaseModel]: + op = self.operation sig = inspect.signature(op) hints = get_type_hints(op) fields = { - param_name: hints.get(param_name, str) for param_name in sig.parameters + param_name: type_to_encodable_type(hints.get(param_name, str)).t + for param_name in sig.parameters } - parameter_model = pydantic.create_model( - "Params", __config__={"extra": "forbid"}, **fields + "Params", + __config__={"extra": "forbid"}, + **fields, # type: ignore ) + return parameter_model + def call_with_json_args( + self, template: Template, json_str: str + ) -> OpenAIMessageContent: + """Implements a roundtrip call to a python function. Input is a json string representing an LLM tool call request parameters. The output is the serialised response to the model.""" + try: + op = self.operation + # build dict of raw encodable types U + raw_args = self.parameter_model.model_validate_json(json_str) + + sig = inspect.signature(op) + hints = get_type_hints(op) + + # use encoders to decode Us to python types T + params = { + param_name: type_to_encodable_type(hints.get(param_name, str)).decode( + getattr(raw_args, param_name) + ) + for param_name in raw_args.model_fields_set + } + + # call tool with python types + result = tool_call( + template, + self.operation, + **params, + ) + # serialize back to U using encoder for return type + encoded_ty = type_to_encodable_type(sig.return_annotation) + encoded_value = encoded_ty.encode(result) + # serialise back to Json + return serialize.dispatch(encoded_ty.t)(encoded_value) # type: ignore + except Exception as exn: + return str({"status": "failure", "exception": str(exn)}) + + @classmethod + def of_operation(cls, op: Operation[P, T], name: str): return cls( - parameter_model=parameter_model, operation=op, name=name, ) @@ -178,22 +226,23 @@ def push_current_text(): if field_name is not None: obj, _ = self.get_field(field_name, args, kwargs) obj = self.convert_field(obj, conversion) - - if isinstance(obj, Image.Image): - assert not format_spec, ( - "image template parameters cannot have format specifiers" - ) - push_current_text() - prompt_parts.append( - { - "type": "image_url", - "image_url": _pil_image_to_base64_data_uri(obj), - } + # TODO(kg): should this use the serialize mechanism? + part = serialize(obj) + # special casing for text + if ( + isinstance(part, list) + and len(part) == 1 + and part[0]["type"] == "text" + ): + current_text += self.format_field( + part[0]["text"], format_spec if format_spec else "" ) else: - current_text += self.format_field( - obj, format_spec if format_spec else "" + assert not format_spec, ( + "non-text serialized template parameters cannot have format specifiers" ) + push_current_text() + prompt_parts.extend(part) push_current_text() return prompt_parts @@ -343,24 +392,6 @@ def _retry_completion(self, template: Template, *args, **kwargs) -> Any: raise Exception("Max retries reached") -def _call_tool_with_json_args( - template: Template, tool: Tool, json_str_args: str -) -> OpenAIMessageContent: - try: - args = tool.parameter_model.model_validate_json(json_str_args) - result = tool_call( - template, - tool.operation, - **{ - field: getattr(args, field) - for field in tool.parameter_model.model_fields - }, - ) - return tool.serialise_return_value(result) - except Exception as exn: - return str({"status": "failure", "exception": str(exn)}) - - def _pydantic_model_from_type(typ: type): return pydantic.create_model("Response", value=typ, __config__={"extra": "forbid"}) @@ -375,13 +406,19 @@ def compute_response(template: Template, model_input: list[Any]) -> ModelRespons tools = _tools_of_operations(template.tools) tool_schemas = [t.function_definition for t in tools.values()] - response_format = _pydantic_model_from_type(ret_type) if ret_type != str else None + response_encoding_type: type | None = type_to_encodable_type(ret_type).t + if response_encoding_type == str: + response_encoding_type = None # loop based on: https://cookbook.openai.com/examples/reasoning_function_calls while True: response: ModelResponse = completion( messages=model_input, - response_format=response_format, + response_format=pydantic.create_model( + "Response", value=response_encoding_type, __config__={"extra": "forbid"} + ) + if response_encoding_type + else None, tools=tool_schemas, ) @@ -395,7 +432,7 @@ def compute_response(template: Template, model_input: list[Any]) -> ModelRespons function = tool_call.function function_name = typing.cast(str, function.name) tool = tools[function_name] - tool_result = _call_tool_with_json_args(template, tool, function.arguments) + tool_result = tool.call_with_json_args(template, function.arguments) model_input.append( { "role": "tool", @@ -406,13 +443,9 @@ def compute_response(template: Template, model_input: list[Any]) -> ModelRespons ) -# Note: typing template as Template[P, T] causes term conversion to fail due to -# unification limitations. -@defop def decode_response[**P, T](template: Callable[P, T], response: ModelResponse) -> T: """Decode an LLM response into an instance of the template return type. This operation should raise if the output cannot be decoded. - """ assert isinstance(template, Template) choice: Choices = typing.cast(Choices, response.choices[0]) @@ -422,13 +455,18 @@ def decode_response[**P, T](template: Callable[P, T], response: ModelResponse) - assert result_str ret_type = template.__signature__.return_annotation - if ret_type == str: - return result_str # type: ignore[return-value] + encodable_ty = type_to_encodable_type(ret_type) - Result = _pydantic_model_from_type(ret_type) - result = Result.model_validate_json(result_str) - assert isinstance(result, Result) - return result.value + if encodable_ty.t == str: + # if encoding as a type, value is just directly what the llm returned + value = result_str + else: + Result = pydantic.create_model("Result", value=encodable_ty.t) + result = Result.model_validate_json(result_str) + assert isinstance(result, Result) + value = result.value # type: ignore + + return encodable_ty.decode(value) # type: ignore @defop @@ -441,8 +479,15 @@ def format_model_input[**P, T]( """ bound_args = template.__signature__.bind(*args, **kwargs) bound_args.apply_defaults() + # encode arguments + arguments = { + param: type_to_encodable_type( + template.__signature__.parameters[param].annotation + ).encode(bound_args.arguments[param]) + for param in bound_args.arguments + } prompt = _OpenAIPromptFormatter().format_as_messages( - template.__prompt_template__, **bound_args.arguments + template.__prompt_template__, **arguments ) # Note: The OpenAI api only seems to accept images in the 'user' role. The diff --git a/tests/test_handlers_llm_encoding.py b/tests/test_handlers_llm_encoding.py index 92572f78..f00cd01f 100644 --- a/tests/test_handlers_llm_encoding.py +++ b/tests/test_handlers_llm_encoding.py @@ -1,4 +1,5 @@ import numbers +from dataclasses import dataclass from typing import NamedTuple, TypedDict import pydantic @@ -396,3 +397,223 @@ def test_type_to_encodable_type_list_of_images(): assert decoded_roundtrip[1].size == original[1].size assert decoded_roundtrip[0].mode == original[0].mode assert decoded_roundtrip[1].mode == original[1].mode + + +def test_type_to_encodable_type_dataclass(): + @dataclass + class Point: + x: int + y: int + + encodable = type_to_encodable_type(Point) + point = Point(10, 20) + encoded = encodable.encode(point) + decoded = encodable.decode(encoded) + assert decoded == point + assert isinstance(decoded, Point) + assert decoded.x == 10 + assert decoded.y == 20 + # Test with pydantic model validation + Model = pydantic.create_model("Model", value=encodable.t) + model_instance = Model.model_validate({"value": encoded.model_dump()}) + assert model_instance.value.x == 10 + assert model_instance.value.y == 20 + # Decode from model + decoded_from_model = encodable.decode(model_instance.value) + assert decoded_from_model == point + assert isinstance(decoded_from_model, Point) + + +def test_type_to_encodable_type_dataclass_with_str(): + @dataclass + class Person: + name: str + age: int + + encodable = type_to_encodable_type(Person) + person = Person("Alice", 30) + encoded = encodable.encode(person) + decoded = encodable.decode(encoded) + assert decoded == person + assert isinstance(decoded, Person) + assert decoded.name == "Alice" + assert decoded.age == 30 + # Test with pydantic model validation + Model = pydantic.create_model("Model", value=encodable.t) + model_instance = Model.model_validate({"value": encoded.model_dump()}) + assert model_instance.value.name == "Alice" + assert model_instance.value.age == 30 + # Decode from model + decoded_from_model = encodable.decode(model_instance.value) + assert decoded_from_model == person + assert isinstance(decoded_from_model, Person) + + +def test_type_to_encodable_type_dataclass_with_list(): + @dataclass + class Container: + items: list[int] + name: str + + encodable = type_to_encodable_type(Container) + container = Container(items=[1, 2, 3], name="test") + encoded = encodable.encode(container) + decoded = encodable.decode(encoded) + assert decoded == container + assert isinstance(decoded, Container) + assert decoded.items == [1, 2, 3] + assert decoded.name == "test" + # Test with pydantic model validation + Model = pydantic.create_model("Model", value=encodable.t) + model_instance = Model.model_validate({"value": encoded.model_dump()}) + assert model_instance.value.items == [1, 2, 3] + assert model_instance.value.name == "test" + # Decode from model + decoded_from_model = encodable.decode(model_instance.value) + assert decoded_from_model == container + assert isinstance(decoded_from_model, Container) + + +def test_type_to_encodable_type_dataclass_with_tuple(): + @dataclass + class Pair: + values: tuple[int, str] + count: int + + encodable = type_to_encodable_type(Pair) + pair = Pair(values=(42, "hello"), count=2) + encoded = encodable.encode(pair) + decoded = encodable.decode(encoded) + assert decoded == pair + assert isinstance(decoded, Pair) + assert decoded.values == (42, "hello") + assert decoded.count == 2 + # Test with pydantic model validation + Model = pydantic.create_model("Model", value=encodable.t) + model_instance = Model.model_validate({"value": encoded.model_dump()}) + assert model_instance.value.values == (42, "hello") + assert model_instance.value.count == 2 + # Decode from model + decoded_from_model = encodable.decode(model_instance.value) + assert decoded_from_model == pair + assert isinstance(decoded_from_model, Pair) + + +def test_type_to_encodable_type_dataclass_with_images(): + @dataclass + class ImagePair: + image1: Image.Image + image2: Image.Image + + encodable = type_to_encodable_type(ImagePair) + image1 = Image.new("RGB", (10, 10), color="red") + image2 = Image.new("RGB", (20, 20), color="blue") + pair = ImagePair(image1=image1, image2=image2) + + encoded = encodable.encode(pair) + assert hasattr(encoded, "image1") + assert hasattr(encoded, "image2") + assert isinstance(encoded.image1, dict) + assert isinstance(encoded.image2, dict) + assert encoded.image1["url"].startswith("data:image/png;base64,") + assert encoded.image2["url"].startswith("data:image/png;base64,") + + decoded = encodable.decode(encoded) + assert isinstance(decoded, ImagePair) + assert isinstance(decoded.image1, Image.Image) + assert isinstance(decoded.image2, Image.Image) + assert decoded.image1.size == (10, 10) + assert decoded.image2.size == (20, 20) + + # Test with pydantic model validation + Model = pydantic.create_model("Model", value=encodable.t) + model_instance = Model.model_validate({"value": encoded.model_dump()}) + assert model_instance.value.image1["url"] == encoded.image1["url"] + assert model_instance.value.image2["url"] == encoded.image2["url"] + # Decode from model + decoded_from_model = encodable.decode(model_instance.value) + assert isinstance(decoded_from_model, ImagePair) + assert decoded_from_model.image1.size == (10, 10) + assert decoded_from_model.image2.size == (20, 20) + + +def test_type_to_encodable_type_dataclass_with_optional(): + @dataclass + class Config: + host: str + port: int + timeout: float | None = None + + encodable = type_to_encodable_type(Config) + config = Config(host="localhost", port=8080, timeout=5.0) + encoded = encodable.encode(config) + decoded = encodable.decode(encoded) + assert decoded == config + assert isinstance(decoded, Config) + assert decoded.host == "localhost" + assert decoded.port == 8080 + assert decoded.timeout == 5.0 + + # Test with None value + config_none = Config(host="localhost", port=8080, timeout=None) + encoded_none = encodable.encode(config_none) + decoded_none = encodable.decode(encoded_none) + assert decoded_none == config_none + assert decoded_none.timeout is None + + # Test with pydantic model validation + Model = pydantic.create_model("Model", value=encodable.t) + model_instance = Model.model_validate({"value": encoded.model_dump()}) + assert model_instance.value.host == "localhost" + assert model_instance.value.port == 8080 + assert model_instance.value.timeout == 5.0 + # Decode from model + decoded_from_model = encodable.decode(model_instance.value) + assert decoded_from_model == config + + +def test_type_to_encodable_type_nested_dataclass(): + @dataclass + class Address: + street: str + city: str + + @dataclass + class Person: + name: str + age: int + address: Address + + encodable = type_to_encodable_type(Person) + address = Address(street="123 Main St", city="New York") + person = Person(name="Bob", age=25, address=address) + + encoded = encodable.encode(person) + assert isinstance(encoded, pydantic.BaseModel) + assert hasattr(encoded, "name") + assert hasattr(encoded, "age") + assert hasattr(encoded, "address") + assert isinstance(encoded.address, pydantic.BaseModel) + assert encoded.address.street == "123 Main St" + assert encoded.address.city == "New York" + + decoded = encodable.decode(encoded) + assert isinstance(decoded, Person) + assert isinstance(decoded.address, Address) + assert decoded.name == "Bob" + assert decoded.age == 25 + assert decoded.address.street == "123 Main St" + assert decoded.address.city == "New York" + + # Test with pydantic model validation + Model = pydantic.create_model("Model", value=encodable.t) + model_instance = Model.model_validate({"value": encoded.model_dump()}) + assert model_instance.value.name == "Bob" + assert model_instance.value.age == 25 + assert model_instance.value.address.street == "123 Main St" + assert model_instance.value.address.city == "New York" + # Decode from model + decoded_from_model = encodable.decode(model_instance.value) + assert decoded_from_model == person + assert isinstance(decoded_from_model, Person) + assert isinstance(decoded_from_model.address, Address) From 63c1c982ecca9f71601530b20ebced71f79cbef2 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Fri, 12 Dec 2025 11:13:23 -0500 Subject: [PATCH 03/21] unified __init__ --- effectful/handlers/llm/encoding.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/effectful/handlers/llm/encoding.py b/effectful/handlers/llm/encoding.py index af643c9c..2f983c51 100644 --- a/effectful/handlers/llm/encoding.py +++ b/effectful/handlers/llm/encoding.py @@ -27,6 +27,9 @@ def _pil_image_to_base64_data_uri(pil_image: Image.Image) -> str: class _Encodable[T, U](ABC): t: type[U] + def __init__(self, *args, **kwargs): + pass + @classmethod @abstractmethod def encode(cls, t: T) -> U: @@ -81,9 +84,6 @@ def decode(cls, vl: t) -> T: # type: ignore class EncodableNumber(_Encodable[numbers.Number, float]): t = float - def __init__(self, _): - pass - @classmethod def encode(cls, vl: numbers.Number) -> t: # type: ignore return float(vl) # type: ignore @@ -97,9 +97,6 @@ def decode(cls, vl: t) -> numbers.Number: # type: ignore class EncodableImage(_Encodable[Image.Image, ChatCompletionImageUrlObject]): t = ChatCompletionImageUrlObject - def __init__(self, _): - pass - @classmethod def encode(cls, image: Image.Image) -> ChatCompletionImageUrlObject: return { From 418253a3e68cac65838e98311bf9b33e80368167 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Fri, 12 Dec 2025 11:17:04 -0500 Subject: [PATCH 04/21] added tests for basemodels --- tests/test_handlers_llm_encoding.py | 117 ++++++++++++++++++++++++ tests/test_handlers_llm_provider.py | 133 +++++++++++++++++++++++++++- 2 files changed, 249 insertions(+), 1 deletion(-) diff --git a/tests/test_handlers_llm_encoding.py b/tests/test_handlers_llm_encoding.py index f00cd01f..408669aa 100644 --- a/tests/test_handlers_llm_encoding.py +++ b/tests/test_handlers_llm_encoding.py @@ -617,3 +617,120 @@ class Person: assert decoded_from_model == person assert isinstance(decoded_from_model, Person) assert isinstance(decoded_from_model.address, Address) + + +def test_type_to_encodable_type_pydantic_model(): + class Point(pydantic.BaseModel): + x: int + y: int + + encodable = type_to_encodable_type(Point) + point = Point(x=10, y=20) + encoded = encodable.encode(point) + decoded = encodable.decode(encoded) + assert decoded == point + assert isinstance(decoded, Point) + assert decoded.x == 10 + assert decoded.y == 20 + # Test with pydantic model validation + Model = pydantic.create_model("Model", value=encodable.t) + model_instance = Model.model_validate({"value": encoded.model_dump()}) + assert model_instance.value.x == 10 + assert model_instance.value.y == 20 + # Decode from model + decoded_from_model = encodable.decode(model_instance.value) + assert decoded_from_model == point + assert isinstance(decoded_from_model, Point) + + +def test_type_to_encodable_type_pydantic_model_with_str(): + class Person(pydantic.BaseModel): + name: str + age: int + + encodable = type_to_encodable_type(Person) + person = Person(name="Alice", age=30) + encoded = encodable.encode(person) + decoded = encodable.decode(encoded) + assert decoded == person + assert isinstance(decoded, Person) + assert decoded.name == "Alice" + assert decoded.age == 30 + # Test with pydantic model validation + Model = pydantic.create_model("Model", value=encodable.t) + model_instance = Model.model_validate({"value": encoded.model_dump()}) + assert model_instance.value.name == "Alice" + assert model_instance.value.age == 30 + # Decode from model + decoded_from_model = encodable.decode(model_instance.value) + assert decoded_from_model == person + assert isinstance(decoded_from_model, Person) + + +def test_type_to_encodable_type_pydantic_model_with_list(): + class Container(pydantic.BaseModel): + items: list[int] + name: str + + encodable = type_to_encodable_type(Container) + container = Container(items=[1, 2, 3], name="test") + encoded = encodable.encode(container) + decoded = encodable.decode(encoded) + assert decoded == container + assert isinstance(decoded, Container) + assert decoded.items == [1, 2, 3] + assert decoded.name == "test" + # Test with pydantic model validation + Model = pydantic.create_model("Model", value=encodable.t) + model_instance = Model.model_validate({"value": encoded.model_dump()}) + assert model_instance.value.items == [1, 2, 3] + assert model_instance.value.name == "test" + # Decode from model + decoded_from_model = encodable.decode(model_instance.value) + assert decoded_from_model == container + assert isinstance(decoded_from_model, Container) + + +def test_type_to_encodable_type_nested_pydantic_model(): + class Address(pydantic.BaseModel): + street: str + city: str + + class Person(pydantic.BaseModel): + name: str + age: int + address: Address + + encodable = type_to_encodable_type(Person) + address = Address(street="123 Main St", city="New York") + person = Person(name="Bob", age=25, address=address) + + encoded = encodable.encode(person) + assert isinstance(encoded, pydantic.BaseModel) + assert hasattr(encoded, "name") + assert hasattr(encoded, "age") + assert hasattr(encoded, "address") + assert isinstance(encoded.address, pydantic.BaseModel) + assert encoded.address.street == "123 Main St" + assert encoded.address.city == "New York" + + decoded = encodable.decode(encoded) + assert isinstance(decoded, Person) + assert isinstance(decoded.address, Address) + assert decoded.name == "Bob" + assert decoded.age == 25 + assert decoded.address.street == "123 Main St" + assert decoded.address.city == "New York" + + # Test with pydantic model validation + Model = pydantic.create_model("Model", value=encodable.t) + model_instance = Model.model_validate({"value": encoded.model_dump()}) + assert model_instance.value.name == "Bob" + assert model_instance.value.age == 25 + assert model_instance.value.address.street == "123 Main St" + assert model_instance.value.address.city == "New York" + # Decode from model + decoded_from_model = encodable.decode(model_instance.value) + assert decoded_from_model == person + assert isinstance(decoded_from_model, Person) + assert isinstance(decoded_from_model.address, Address) diff --git a/tests/test_handlers_llm_provider.py b/tests/test_handlers_llm_provider.py index 3fbd307d..9a0bcc5c 100644 --- a/tests/test_handlers_llm_provider.py +++ b/tests/test_handlers_llm_provider.py @@ -13,7 +13,7 @@ import pytest from PIL import Image -from pydantic import Field +from pydantic import BaseModel, Field from pydantic.dataclasses import dataclass from effectful.handlers.llm import Template @@ -377,3 +377,134 @@ def test_image_input(): handler(LimitLLMCallsHandler(max_calls=3)), ): assert any("smile" in categorise_image(smiley_face()) for _ in range(3)) + + +class BookReview(BaseModel): + """A book review with rating and summary.""" + + title: str = Field(..., description="title of the book") + rating: int = Field(..., description="rating from 1 to 5", ge=1, le=5) + summary: str = Field(..., description="brief summary of the review") + + +@Template.define +def review_book(plot: str) -> BookReview: + """Review a book based on this plot: {plot}""" + raise NotImplementedError + + +class TestPydanticBaseModelReturn: + @requires_openai + def test_pydantic_basemodel_return(self): + plot = "A young wizard discovers he has magical powers and goes to a school for wizards." + + with ( + handler(LiteLLMProvider(model_name="gpt-5-nano")), + handler(LimitLLMCallsHandler(max_calls=1)), + ): + review = review_book(plot) + + assert isinstance(review, BookReview) + assert isinstance(review.title, str) + assert len(review.title) > 0 + assert isinstance(review.rating, int) + assert 1 <= review.rating <= 5 + assert isinstance(review.summary, str) + assert len(review.summary) > 0 + + +class BookRecommendation(BaseModel): + """A book recommendation with details.""" + + title: str = Field(..., description="title of the recommended book") + reason: str = Field(..., description="reason for the recommendation") + + +@defop +def recommend_book_tool(genre: str, explanation: str) -> BookRecommendation: + """Recommend a book based on genre preference. + + Parameters: + - genre: The genre of book to recommend + - explanation: Natural language explanation of the recommendation + """ + raise NotHandled + + +class LoggingBookRecommendationInterpretation(ObjectInterpretation): + """Provides an interpretation for `recommend_book_tool` that tracks recommendations.""" + + recommendation_count: int = 0 + recommendation_results: list[dict] = [] + + @implements(recommend_book_tool) + def _recommend_book_tool(self, genre: str, explanation: str) -> BookRecommendation: + self.recommendation_count += 1 + + # Simple heuristic: recommend based on genre + recommendations = { + "fantasy": BookRecommendation( + title="The Lord of the Rings", reason="Classic fantasy epic" + ), + "sci-fi": BookRecommendation( + title="Dune", reason="Epic science fiction masterpiece" + ), + "mystery": BookRecommendation( + title="The Hound of the Baskervilles", + reason="Classic mystery novel", + ), + } + + recommendation = recommendations.get( + genre.lower(), + BookRecommendation( + title="1984", reason="Thought-provoking dystopian novel" + ), + ) + + self.recommendation_results.append( + { + "genre": genre, + "explanation": explanation, + "recommendation": recommendation, + } + ) + + return recommendation + + +@Template.define(tools=[recommend_book_tool]) +def get_book_recommendation(user_preference: str) -> BookRecommendation: + """Get a book recommendation based on user preference: {user_preference}. + Use the provided tools to make a recommendation. + """ + raise NotHandled + + +class TestPydanticBaseModelToolCalls: + @pytest.mark.parametrize( + "model_name", + [ + pytest.param("gpt-5-nano", marks=requires_openai), + pytest.param("claude-sonnet-4-5-20250929", marks=requires_anthropic), + ], + ) + def test_pydantic_basemodel_tool_calling(self, model_name): + """Test that templates with tools work with Pydantic BaseModel.""" + book_rec_ctx = LoggingBookRecommendationInterpretation() + with ( + handler(LiteLLMProvider(model_name=model_name)), + handler(LimitLLMCallsHandler(max_calls=4)), + handler(book_rec_ctx), + ): + recommendation = get_book_recommendation("I love fantasy novels") + + assert isinstance(recommendation, BookRecommendation) + assert isinstance(recommendation.title, str) + assert len(recommendation.title) > 0 + assert isinstance(recommendation.reason, str) + assert len(recommendation.reason) > 0 + + # Verify the tool was called at least once + assert book_rec_ctx.recommendation_count >= 1 + assert len(book_rec_ctx.recommendation_results) >= 1 From 9de00736d7d41886910cf6cc1dbfd251b9519531 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Fri, 12 Dec 2025 11:19:38 -0500 Subject: [PATCH 05/21] s/@property/@functools.cached_property/ --- effectful/handlers/llm/providers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/effectful/handlers/llm/providers.py b/effectful/handlers/llm/providers.py index c00a402e..28dff8db 100644 --- a/effectful/handlers/llm/providers.py +++ b/effectful/handlers/llm/providers.py @@ -115,7 +115,7 @@ def serialise_return_value(self, value) -> OpenAIMessageContent: encoded_value = encoded_ty.encode(value) return serialize.dispatch(encoded_ty.t)(encoded_value) # type: ignore - @property + @functools.cached_property def parameter_model(self) -> type[pydantic.BaseModel]: op = self.operation sig = inspect.signature(op) From 95301800bd2d2f4036ce7a7ec18a375aa217bacf Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Fri, 12 Dec 2025 11:22:34 -0500 Subject: [PATCH 06/21] type for encode and decode --- effectful/handlers/llm/encoding.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/effectful/handlers/llm/encoding.py b/effectful/handlers/llm/encoding.py index 2f983c51..5a8e0dbb 100644 --- a/effectful/handlers/llm/encoding.py +++ b/effectful/handlers/llm/encoding.py @@ -67,14 +67,14 @@ def _type_encodable_type_object[T](ty: type[T]) -> Encodable[T]: @type_to_encodable_type.register(str) def _type_encodable_type_base[T](ty: type[T]) -> Encodable[T]: class BaseEncodable(_Encodable[T, T]): - t = ty + t: type[T] = ty @classmethod - def encode(cls, vl): + def encode(cls, vl: T) -> T: return vl @classmethod - def decode(cls, vl: t) -> T: # type: ignore + def decode(cls, vl: T) -> T: return vl return typing.cast(Encodable[T], BaseEncodable()) From e11759c68ab97f71b9a23ebf2e84ceb6bb5d8a9a Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Fri, 12 Dec 2025 11:35:44 -0500 Subject: [PATCH 07/21] removed handling for numbers.Number and explicit tests for complex --- effectful/handlers/llm/encoding.py | 19 +++---------------- tests/test_handlers_llm_encoding.py | 21 ++++++++++++++++----- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/effectful/handlers/llm/encoding.py b/effectful/handlers/llm/encoding.py index 5a8e0dbb..60adfe5a 100644 --- a/effectful/handlers/llm/encoding.py +++ b/effectful/handlers/llm/encoding.py @@ -1,7 +1,6 @@ import base64 import dataclasses import io -import numbers import typing from abc import ABC, abstractmethod from collections.abc import Callable @@ -61,10 +60,11 @@ def _type_encodable_type_object[T](ty: type[T]) -> Encodable[T]: return _type_encodable_type_base(ty) +@type_to_encodable_type.register(str) @type_to_encodable_type.register(int) -@type_to_encodable_type.register(float) @type_to_encodable_type.register(bool) -@type_to_encodable_type.register(str) +@type_to_encodable_type.register(float) +@type_to_encodable_type.register(complex) def _type_encodable_type_base[T](ty: type[T]) -> Encodable[T]: class BaseEncodable(_Encodable[T, T]): t: type[T] = ty @@ -80,19 +80,6 @@ def decode(cls, vl: T) -> T: return typing.cast(Encodable[T], BaseEncodable()) -@type_to_encodable_type.register(numbers.Number) -class EncodableNumber(_Encodable[numbers.Number, float]): - t = float - - @classmethod - def encode(cls, vl: numbers.Number) -> t: # type: ignore - return float(vl) # type: ignore - - @classmethod - def decode(cls, vl: t) -> numbers.Number: # type: ignore - return vl - - @type_to_encodable_type.register(Image.Image) class EncodableImage(_Encodable[Image.Image, ChatCompletionImageUrlObject]): t = ChatCompletionImageUrlObject diff --git a/tests/test_handlers_llm_encoding.py b/tests/test_handlers_llm_encoding.py index 408669aa..b9eddbe8 100644 --- a/tests/test_handlers_llm_encoding.py +++ b/tests/test_handlers_llm_encoding.py @@ -1,4 +1,3 @@ -import numbers from dataclasses import dataclass from typing import NamedTuple, TypedDict @@ -276,11 +275,23 @@ class Config(TypedDict, total=False): assert isinstance(decoded.value, dict) -def test_type_to_encodable_type_number_float(): - encodable = type_to_encodable_type(numbers.Number) +def test_type_to_encodable_type_complex(): + encodable = type_to_encodable_type(complex) + value = 3 + 4j + encoded = encodable.encode(value) + decoded = encodable.decode(encoded) + assert decoded == value + assert isinstance(decoded, complex) + assert decoded.real == 3.0 + assert decoded.imag == 4.0 + # Test with pydantic model validation Model = pydantic.create_model("Model", value=encodable.t) - decoded = Model.model_validate({"value": 3.14}) - assert decoded.value == 3.14 + model_instance = Model.model_validate({"value": encoded}) + assert model_instance.value == encoded + # Decode from model + decoded_from_model = encodable.decode(model_instance.value) + assert decoded_from_model == value + assert isinstance(decoded_from_model, complex) def test_type_to_encodable_type_tuple_of_images(): From 2ed02545c1b9b5cd969abca7b45cf440edb91cf2 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Fri, 12 Dec 2025 11:37:54 -0500 Subject: [PATCH 08/21] fixed is_dataclass checks --- effectful/handlers/llm/encoding.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/effectful/handlers/llm/encoding.py b/effectful/handlers/llm/encoding.py index 60adfe5a..00d3d698 100644 --- a/effectful/handlers/llm/encoding.py +++ b/effectful/handlers/llm/encoding.py @@ -55,7 +55,7 @@ def type_to_encodable_type[T]( @type_to_encodable_type.register(object) def _type_encodable_type_object[T](ty: type[T]) -> Encodable[T]: # Check if it's a dataclass and redirect to dataclass handler - if dataclasses.is_dataclass(ty): + if isinstance(ty, type) and dataclasses.is_dataclass(ty): return _type_encodable_type_dataclass(ty) return _type_encodable_type_base(ty) @@ -104,7 +104,7 @@ def decode(cls, image: ChatCompletionImageUrlObject) -> Image.Image: def _type_encodable_type_dataclass[T](ty: type[T]) -> Encodable[T]: """Handle dataclass encoding/decoding with recursive field encoding.""" - if not dataclasses.is_dataclass(ty): + if not (isinstance(ty, type) and dataclasses.is_dataclass(ty)): raise TypeError(f"Expected dataclass, got {ty}") fields = dataclasses.fields(ty) From 14f0906d2d652866e049a723667616115a22780a Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Fri, 12 Dec 2025 11:47:42 -0500 Subject: [PATCH 09/21] updated to check parameter annotations in Tool.of_operation constructor --- effectful/handlers/llm/providers.py | 40 ++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/effectful/handlers/llm/providers.py b/effectful/handlers/llm/providers.py index 28dff8db..05b24fc6 100644 --- a/effectful/handlers/llm/providers.py +++ b/effectful/handlers/llm/providers.py @@ -107,6 +107,7 @@ def _(values: Sequence) -> OpenAIMessageContent: class Tool[**P, T]: operation: Operation[P, T] name: str + parameter_annotations: dict[str, type] def serialise_return_value(self, value) -> OpenAIMessageContent: """Serializes a value returned by the function into a json format suitable for the OpenAI API.""" @@ -117,12 +118,9 @@ def serialise_return_value(self, value) -> OpenAIMessageContent: @functools.cached_property def parameter_model(self) -> type[pydantic.BaseModel]: - op = self.operation - sig = inspect.signature(op) - hints = get_type_hints(op) fields = { - param_name: type_to_encodable_type(hints.get(param_name, str)).t - for param_name in sig.parameters + param_name: type_to_encodable_type(param_type).t + for param_name, param_type in self.parameter_annotations.items() } parameter_model = pydantic.create_model( "Params", @@ -140,14 +138,11 @@ def call_with_json_args( # build dict of raw encodable types U raw_args = self.parameter_model.model_validate_json(json_str) - sig = inspect.signature(op) - hints = get_type_hints(op) - # use encoders to decode Us to python types T - params = { - param_name: type_to_encodable_type(hints.get(param_name, str)).decode( - getattr(raw_args, param_name) - ) + params: dict[str, Any] = { + param_name: type_to_encodable_type( + self.parameter_annotations[param_name] + ).decode(getattr(raw_args, param_name)) for param_name in raw_args.model_fields_set } @@ -158,6 +153,7 @@ def call_with_json_args( **params, ) # serialize back to U using encoder for return type + sig = inspect.signature(op) encoded_ty = type_to_encodable_type(sig.return_annotation) encoded_value = encoded_ty.encode(result) # serialise back to Json @@ -167,9 +163,29 @@ def call_with_json_args( @classmethod def of_operation(cls, op: Operation[P, T], name: str): + sig = inspect.signature(op) + hints = get_type_hints(op) + parameter_annotations: dict[str, type] = {} + + for param_name, param in sig.parameters.items(): + # Check if parameter annotation is missing (inspect.Parameter.empty) + if param.annotation is inspect.Parameter.empty: + raise TypeError( + f"Parameter '{param_name}' in operation '{op.__name__}' " + "does not have a type annotation" + ) + # get_type_hints might not include the parameter if annotation is invalid + if param_name not in hints: + raise TypeError( + f"Parameter '{param_name}' in operation '{op.__name__}' " + "does not have a valid type annotation" + ) + parameter_annotations[param_name] = hints[param_name] + return cls( operation=op, name=name, + parameter_annotations=parameter_annotations, ) @property From ade480d017696c4f91dbf38d7bd67a9553f3918e Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Fri, 12 Dec 2025 11:49:44 -0500 Subject: [PATCH 10/21] updated serializer to be more selective in what is an image --- effectful/handlers/llm/providers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/effectful/handlers/llm/providers.py b/effectful/handlers/llm/providers.py index 05b24fc6..653c5bea 100644 --- a/effectful/handlers/llm/providers.py +++ b/effectful/handlers/llm/providers.py @@ -74,7 +74,11 @@ def serialize(value: Any) -> OpenAIMessageContent: @serialize.register(dict) # type: ignore def _(value: dict) -> OpenAIMessageContent: - if "url" in value and isinstance(value["url"], str): + if ( + "url" in value + and isinstance(value["url"], str) + and value["url"].startswith("data:image/") + ): return [ { "type": "image_url", From 39c4cfa63a316a643a7d8f14505f276c6685996e Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Fri, 12 Dec 2025 12:10:10 -0500 Subject: [PATCH 11/21] reducing number of #type: ignores, and switching to typing.Any --- effectful/handlers/llm/encoding.py | 51 ++++++++++++++++++------------ 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/effectful/handlers/llm/encoding.py b/effectful/handlers/llm/encoding.py index 00d3d698..3b1461d0 100644 --- a/effectful/handlers/llm/encoding.py +++ b/effectful/handlers/llm/encoding.py @@ -102,6 +102,9 @@ def decode(cls, image: ChatCompletionImageUrlObject) -> Image.Image: return Image.open(fp=io.BytesIO(base64.b64decode(data))) +U = typing.TypeVar("U", bound=pydantic.BaseModel) + + def _type_encodable_type_dataclass[T](ty: type[T]) -> Encodable[T]: """Handle dataclass encoding/decoding with recursive field encoding.""" if not (isinstance(ty, type) and dataclasses.is_dataclass(ty)): @@ -114,7 +117,9 @@ def _type_encodable_type_dataclass[T](ty: type[T]) -> Encodable[T]: encoded_field_types: dict[str, typing.Any] = {} for field in fields: - field_encoder = type_to_encodable_type(field.type) # type: ignore + field_encoder = type_to_encodable_type( + typing.cast(type[typing.Any], field.type) + ) field_encoders[field.name] = field_encoder # Determine if field is required or has a default @@ -133,13 +138,16 @@ def _type_encodable_type_dataclass[T](ty: type[T]) -> Encodable[T]: # Create a dynamic pydantic model for the encoded type model_name = f"{ty.__name__}Encoded" - EncodedModel = pydantic.create_model(model_name, **encoded_field_types) - class DataclassEncodable(_Encodable[T, EncodedModel]): # type: ignore - t = EncodedModel + EncodedModel: type[pydantic.BaseModel] = pydantic.create_model( + model_name, **encoded_field_types + ) + + class DataclassEncodable(_Encodable[T, typing.Any]): + t: type[typing.Any] = EncodedModel @classmethod - def encode(cls, t: T) -> EncodedModel: # type: ignore + def encode(cls, t: T) -> typing.Any: if not isinstance(t, ty): raise TypeError(f"Expected {ty}, got {type(t)}") @@ -152,7 +160,7 @@ def encode(cls, t: T) -> EncodedModel: # type: ignore return EncodedModel(**result) @classmethod - def decode(cls, vl: EncodedModel | dict[str, typing.Any]) -> T: # type: ignore + def decode(cls, vl: typing.Any) -> T: # Handle both pydantic model instance and dict if isinstance(vl, dict): # Validate dict and convert to model @@ -184,13 +192,14 @@ def _type_encodable_type_tuple[T](ty: type[T]) -> Encodable[T]: # Create encoders for each element type element_encoders = [type_to_encodable_type(arg) for arg in args] - encoded_ty: type = tuple[*(encoder.t for encoder in element_encoders)] # type: ignore + # Build tuple type from element encoder types (runtime-created, use Any) + encoded_ty: type[typing.Any] = typing.cast(type[typing.Any], tuple) - class TupleEncodable(_Encodable[T, encoded_ty]): # type: ignore - t = encoded_ty + class TupleEncodable(_Encodable[T, typing.Any]): + t: type[typing.Any] = encoded_ty @classmethod - def encode(cls, t: T) -> encoded_ty: # type: ignore + def encode(cls, t: T) -> typing.Any: if not isinstance(t, tuple): raise TypeError(f"Expected tuple, got {type(t)}") if len(t) != len(element_encoders): @@ -200,12 +209,12 @@ def encode(cls, t: T) -> encoded_ty: # type: ignore return tuple([enc.encode(elem) for enc, elem in zip(element_encoders, t)]) @classmethod - def decode(cls, t: encoded_ty) -> T: # type: ignore + def decode(cls, t: typing.Any) -> T: if len(t) != len(element_encoders): raise ValueError( f"tuple length {len(t)} does not match expected length {len(element_encoders)}" ) - decoded_elements = [ # type: ignore + decoded_elements: list[typing.Any] = [ enc.decode(elem) for enc, elem in zip(element_encoders, t) ] return typing.cast(T, tuple(decoded_elements)) @@ -223,23 +232,25 @@ def _type_encodable_type_list[T](ty: type[T]) -> Encodable[T]: # Get the element type (first type argument) element_ty = args[0] - element_encoder: Encodable[T] = type_to_encodable_type(element_ty) + element_encoder = type_to_encodable_type(element_ty) - # Build the encoded type (list of encoded element type) - encoded_ty: type = list[element_encoder.t] # type: ignore + # Build the encoded type (list of encoded element type) - runtime-created, use Any + encoded_ty: type[typing.Any] = typing.cast(type[typing.Any], list) - class ListEncodable(_Encodable[T, encoded_ty]): # type: ignore - t = encoded_ty + class ListEncodable(_Encodable[T, typing.Any]): + t: type[typing.Any] = encoded_ty @classmethod - def encode(cls, t: T) -> encoded_ty: # type: ignore + def encode(cls, t: T) -> typing.Any: if not isinstance(t, list): raise TypeError(f"Expected list, got {type(t)}") return [element_encoder.encode(elem) for elem in t] @classmethod - def decode(cls, t: encoded_ty) -> T: # type: ignore - decoded_elements = [element_encoder.decode(elem) for elem in t] # type: ignore + def decode(cls, t: typing.Any) -> T: + decoded_elements: list[typing.Any] = [ + element_encoder.decode(elem) for elem in t + ] return typing.cast(T, decoded_elements) return typing.cast(Encodable[T], ListEncodable()) From 9378d0995d7ba95ea464b24bc9c3bdc5ad21f07c Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Fri, 12 Dec 2025 12:10:57 -0500 Subject: [PATCH 12/21] removed comment --- effectful/handlers/llm/providers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/effectful/handlers/llm/providers.py b/effectful/handlers/llm/providers.py index 653c5bea..dea78a6b 100644 --- a/effectful/handlers/llm/providers.py +++ b/effectful/handlers/llm/providers.py @@ -246,7 +246,6 @@ def push_current_text(): if field_name is not None: obj, _ = self.get_field(field_name, args, kwargs) obj = self.convert_field(obj, conversion) - # TODO(kg): should this use the serialize mechanism? part = serialize(obj) # special casing for text if ( From 5ec14516e75549c4d58681a6073ce591f642c11c Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Fri, 12 Dec 2025 12:21:47 -0500 Subject: [PATCH 13/21] dropped dataclass support --- effectful/handlers/llm/encoding.py | 85 ------------------------------ 1 file changed, 85 deletions(-) diff --git a/effectful/handlers/llm/encoding.py b/effectful/handlers/llm/encoding.py index 3b1461d0..f0934b30 100644 --- a/effectful/handlers/llm/encoding.py +++ b/effectful/handlers/llm/encoding.py @@ -1,5 +1,4 @@ import base64 -import dataclasses import io import typing from abc import ABC, abstractmethod @@ -8,7 +7,6 @@ import pydantic from litellm import ChatCompletionImageUrlObject from PIL import Image -from pydantic import Field from effectful.ops.syntax import _CustomSingleDispatchCallable @@ -53,13 +51,6 @@ def type_to_encodable_type[T]( @type_to_encodable_type.register(object) -def _type_encodable_type_object[T](ty: type[T]) -> Encodable[T]: - # Check if it's a dataclass and redirect to dataclass handler - if isinstance(ty, type) and dataclasses.is_dataclass(ty): - return _type_encodable_type_dataclass(ty) - return _type_encodable_type_base(ty) - - @type_to_encodable_type.register(str) @type_to_encodable_type.register(int) @type_to_encodable_type.register(bool) @@ -105,82 +96,6 @@ def decode(cls, image: ChatCompletionImageUrlObject) -> Image.Image: U = typing.TypeVar("U", bound=pydantic.BaseModel) -def _type_encodable_type_dataclass[T](ty: type[T]) -> Encodable[T]: - """Handle dataclass encoding/decoding with recursive field encoding.""" - if not (isinstance(ty, type) and dataclasses.is_dataclass(ty)): - raise TypeError(f"Expected dataclass, got {ty}") - - fields = dataclasses.fields(ty) - - # Create encoders for each field type - field_encoders: dict[str, Encodable] = {} - encoded_field_types: dict[str, typing.Any] = {} - - for field in fields: - field_encoder = type_to_encodable_type( - typing.cast(type[typing.Any], field.type) - ) - field_encoders[field.name] = field_encoder - - # Determine if field is required or has a default - if field.default != dataclasses.MISSING: - # Field has a default value - encoded_field_types[field.name] = (field_encoder.t, field.default) - elif field.default_factory != dataclasses.MISSING: - # Field has a default factory - encoded_field_types[field.name] = ( - field_encoder.t, - Field(default_factory=field.default_factory), - ) - else: - # Required field - encoded_field_types[field.name] = (field_encoder.t, ...) - - # Create a dynamic pydantic model for the encoded type - model_name = f"{ty.__name__}Encoded" - - EncodedModel: type[pydantic.BaseModel] = pydantic.create_model( - model_name, **encoded_field_types - ) - - class DataclassEncodable(_Encodable[T, typing.Any]): - t: type[typing.Any] = EncodedModel - - @classmethod - def encode(cls, t: T) -> typing.Any: - if not isinstance(t, ty): - raise TypeError(f"Expected {ty}, got {type(t)}") - - result: dict[str, typing.Any] = {} - for field in fields: - field_value = getattr(t, field.name) - field_encoder = field_encoders[field.name] - result[field.name] = field_encoder.encode(field_value) - - return EncodedModel(**result) - - @classmethod - def decode(cls, vl: typing.Any) -> T: - # Handle both pydantic model instance and dict - if isinstance(vl, dict): - # Validate dict and convert to model - validated = EncodedModel.model_validate(vl) - else: - validated = vl - - decoded_fields: dict[str, typing.Any] = {} - - for field in fields: - # Get value from validated model - field_value = getattr(validated, field.name) - field_encoder = field_encoders[field.name] - decoded_fields[field.name] = field_encoder.decode(field_value) - - return typing.cast(T, ty(**decoded_fields)) - - return typing.cast(Encodable[T], DataclassEncodable()) - - @type_to_encodable_type.register(tuple) def _type_encodable_type_tuple[T](ty: type[T]) -> Encodable[T]: args = typing.get_args(ty) From 6d6f5f20e35fa5982c650a588846f68896e73dfa Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Fri, 12 Dec 2025 12:24:28 -0500 Subject: [PATCH 14/21] dropped tests for dataclass with image --- tests/test_handlers_llm_encoding.py | 38 ----------------------------- 1 file changed, 38 deletions(-) diff --git a/tests/test_handlers_llm_encoding.py b/tests/test_handlers_llm_encoding.py index b9eddbe8..6b30cf36 100644 --- a/tests/test_handlers_llm_encoding.py +++ b/tests/test_handlers_llm_encoding.py @@ -510,44 +510,6 @@ class Pair: assert isinstance(decoded_from_model, Pair) -def test_type_to_encodable_type_dataclass_with_images(): - @dataclass - class ImagePair: - image1: Image.Image - image2: Image.Image - - encodable = type_to_encodable_type(ImagePair) - image1 = Image.new("RGB", (10, 10), color="red") - image2 = Image.new("RGB", (20, 20), color="blue") - pair = ImagePair(image1=image1, image2=image2) - - encoded = encodable.encode(pair) - assert hasattr(encoded, "image1") - assert hasattr(encoded, "image2") - assert isinstance(encoded.image1, dict) - assert isinstance(encoded.image2, dict) - assert encoded.image1["url"].startswith("data:image/png;base64,") - assert encoded.image2["url"].startswith("data:image/png;base64,") - - decoded = encodable.decode(encoded) - assert isinstance(decoded, ImagePair) - assert isinstance(decoded.image1, Image.Image) - assert isinstance(decoded.image2, Image.Image) - assert decoded.image1.size == (10, 10) - assert decoded.image2.size == (20, 20) - - # Test with pydantic model validation - Model = pydantic.create_model("Model", value=encodable.t) - model_instance = Model.model_validate({"value": encoded.model_dump()}) - assert model_instance.value.image1["url"] == encoded.image1["url"] - assert model_instance.value.image2["url"] == encoded.image2["url"] - # Decode from model - decoded_from_model = encodable.decode(model_instance.value) - assert isinstance(decoded_from_model, ImagePair) - assert decoded_from_model.image1.size == (10, 10) - assert decoded_from_model.image2.size == (20, 20) - - def test_type_to_encodable_type_dataclass_with_optional(): @dataclass class Config: From 6cf41573c18165bfe0e3f68c7eefd23fd09fdecc Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Fri, 12 Dec 2025 12:29:16 -0500 Subject: [PATCH 15/21] updated dataclass tests to stop assuming pydantic models --- tests/test_handlers_llm_encoding.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/test_handlers_llm_encoding.py b/tests/test_handlers_llm_encoding.py index 6b30cf36..ce50979e 100644 --- a/tests/test_handlers_llm_encoding.py +++ b/tests/test_handlers_llm_encoding.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import asdict, dataclass from typing import NamedTuple, TypedDict import pydantic @@ -426,7 +426,7 @@ class Point: assert decoded.y == 20 # Test with pydantic model validation Model = pydantic.create_model("Model", value=encodable.t) - model_instance = Model.model_validate({"value": encoded.model_dump()}) + model_instance = Model.model_validate({"value": asdict(encoded)}) assert model_instance.value.x == 10 assert model_instance.value.y == 20 # Decode from model @@ -451,7 +451,7 @@ class Person: assert decoded.age == 30 # Test with pydantic model validation Model = pydantic.create_model("Model", value=encodable.t) - model_instance = Model.model_validate({"value": encoded.model_dump()}) + model_instance = Model.model_validate({"value": asdict(encoded)}) assert model_instance.value.name == "Alice" assert model_instance.value.age == 30 # Decode from model @@ -476,7 +476,7 @@ class Container: assert decoded.name == "test" # Test with pydantic model validation Model = pydantic.create_model("Model", value=encodable.t) - model_instance = Model.model_validate({"value": encoded.model_dump()}) + model_instance = Model.model_validate({"value": asdict(encoded)}) assert model_instance.value.items == [1, 2, 3] assert model_instance.value.name == "test" # Decode from model @@ -501,7 +501,7 @@ class Pair: assert decoded.count == 2 # Test with pydantic model validation Model = pydantic.create_model("Model", value=encodable.t) - model_instance = Model.model_validate({"value": encoded.model_dump()}) + model_instance = Model.model_validate({"value": asdict(encoded)}) assert model_instance.value.values == (42, "hello") assert model_instance.value.count == 2 # Decode from model @@ -536,7 +536,7 @@ class Config: # Test with pydantic model validation Model = pydantic.create_model("Model", value=encodable.t) - model_instance = Model.model_validate({"value": encoded.model_dump()}) + model_instance = Model.model_validate({"value": asdict(encoded)}) assert model_instance.value.host == "localhost" assert model_instance.value.port == 8080 assert model_instance.value.timeout == 5.0 @@ -562,11 +562,11 @@ class Person: person = Person(name="Bob", age=25, address=address) encoded = encodable.encode(person) - assert isinstance(encoded, pydantic.BaseModel) + assert isinstance(encoded, Person) assert hasattr(encoded, "name") assert hasattr(encoded, "age") assert hasattr(encoded, "address") - assert isinstance(encoded.address, pydantic.BaseModel) + assert isinstance(encoded.address, Address) assert encoded.address.street == "123 Main St" assert encoded.address.city == "New York" @@ -580,7 +580,7 @@ class Person: # Test with pydantic model validation Model = pydantic.create_model("Model", value=encodable.t) - model_instance = Model.model_validate({"value": encoded.model_dump()}) + model_instance = Model.model_validate({"value": asdict(encoded)}) assert model_instance.value.name == "Bob" assert model_instance.value.age == 25 assert model_instance.value.address.street == "123 Main St" From 2ab6ad112faab627a1f2b2e2e7d168ea90d0421c Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Fri, 12 Dec 2025 12:56:23 -0500 Subject: [PATCH 16/21] test for tool that returns list of images --- tests/test_handlers_llm_provider.py | 36 +++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_handlers_llm_provider.py b/tests/test_handlers_llm_provider.py index 9a0bcc5c..5d365a8b 100644 --- a/tests/test_handlers_llm_provider.py +++ b/tests/test_handlers_llm_provider.py @@ -379,6 +379,42 @@ def test_image_input(): assert any("smile" in categorise_image(smiley_face()) for _ in range(3)) +@defop +def get_images_tool(count: int) -> list[Image.Image]: + """Get a list of images. + + Parameters: + - count: Number of images to return + """ + raise NotHandled + + +class ImageListInterpretation(ObjectInterpretation): + """Provides an interpretation for `get_images_tool` that returns a list of images.""" + + @implements(get_images_tool) + def _get_images_tool(self, count: int) -> list[Image.Image]: + return [smiley_face() for _ in range(count)] + + +@Template.define(tools=[get_images_tool]) +def describe_images() -> str: + """Use the provided tool to get images and describe what you see.""" + raise NotHandled + + +@requires_openai +def test_tool_returns_list_of_images(): + """Test that LLM can handle tools that return lists of images.""" + with ( + handler(LiteLLMProvider(model_name="gpt-4o")), + handler(LimitLLMCallsHandler(max_calls=4)), + handler(ImageListInterpretation()), + ): + print(describe_images()) + assert any("smile" in describe_images() for _ in range(3)) + + class BookReview(BaseModel): """A book review with rating and summary.""" From 193b777f4d74a75a9df335f4caa2583d2d495f30 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Fri, 12 Dec 2025 14:09:22 -0500 Subject: [PATCH 17/21] made serialization a parameter of encodable and thus type-directed --- effectful/handlers/llm/encoding.py | 88 ++++++++++++++++++++++++++-- effectful/handlers/llm/providers.py | 90 +++++------------------------ 2 files changed, 96 insertions(+), 82 deletions(-) diff --git a/effectful/handlers/llm/encoding.py b/effectful/handlers/llm/encoding.py index f0934b30..8b457b2b 100644 --- a/effectful/handlers/llm/encoding.py +++ b/effectful/handlers/llm/encoding.py @@ -5,7 +5,10 @@ from collections.abc import Callable import pydantic -from litellm import ChatCompletionImageUrlObject +from litellm import ( + ChatCompletionImageUrlObject, + OpenAIMessageContentListBlock, +) from PIL import Image from effectful.ops.syntax import _CustomSingleDispatchCallable @@ -29,14 +32,18 @@ def __init__(self, *args, **kwargs): @classmethod @abstractmethod - def encode(cls, t: T) -> U: + def encode(cls, vl: T) -> U: pass @classmethod @abstractmethod - def decode(cls, t: U) -> T: + def decode(cls, vl: U) -> T: pass + @classmethod + def serialize(cls, value: U) -> list[OpenAIMessageContentListBlock]: + return [{"type": "text", "text": str(value)}] + class Encodable[T](_Encodable[T, type]): t = type @@ -71,6 +78,28 @@ def decode(cls, vl: T) -> T: return typing.cast(Encodable[T], BaseEncodable()) +@type_to_encodable_type.register(pydantic.BaseModel) +def _type_encodable_type_pydantic_base_model[T: pydantic.BaseModel]( + ty: type[T], +) -> Encodable[T]: + class EncodablePydanticBaseModel(_Encodable[T, T]): + t: type[T] = ty + + @classmethod + def decode(cls, vl: T) -> T: + return vl + + @classmethod + def encode(cls, vl: T) -> T: + return vl + + @classmethod + def serialize(cls, vl: T) -> list[OpenAIMessageContentListBlock]: + return [{"type": "text", "text": vl.model_dump_json()}] + + return typing.cast(Encodable[T], EncodablePydanticBaseModel()) + + @type_to_encodable_type.register(Image.Image) class EncodableImage(_Encodable[Image.Image, ChatCompletionImageUrlObject]): t = ChatCompletionImageUrlObject @@ -92,6 +121,12 @@ def decode(cls, image: ChatCompletionImageUrlObject) -> Image.Image: data = image_url.split(",")[1] return Image.open(fp=io.BytesIO(base64.b64decode(data))) + @classmethod + def serialize( + cls, value: ChatCompletionImageUrlObject + ) -> list[OpenAIMessageContentListBlock]: + return [{"type": "image_url", "image_url": value}] + U = typing.TypeVar("U", bound=pydantic.BaseModel) @@ -107,8 +142,13 @@ def _type_encodable_type_tuple[T](ty: type[T]) -> Encodable[T]: # Create encoders for each element type element_encoders = [type_to_encodable_type(arg) for arg in args] - # Build tuple type from element encoder types (runtime-created, use Any) - encoded_ty: type[typing.Any] = typing.cast(type[typing.Any], tuple) + # Check if any element type is Image.Image + has_image = any(arg is Image.Image for arg in args) + + encoded_ty: type[typing.Any] = typing.cast( + type[typing.Any], + tuple[*(enc.t for enc in element_encoders)], # type: ignore + ) class TupleEncodable(_Encodable[T, typing.Any]): t: type[typing.Any] = encoded_ty @@ -134,6 +174,23 @@ def decode(cls, t: typing.Any) -> T: ] return typing.cast(T, tuple(decoded_elements)) + @classmethod + def serialize(cls, value: typing.Any) -> list[OpenAIMessageContentListBlock]: + if has_image: + # If tuple contains images, serialize each element and flatten the results + result: list[OpenAIMessageContentListBlock] = [] + if not isinstance(value, tuple): + raise TypeError(f"Expected tuple, got {type(value)}") + if len(value) != len(element_encoders): + raise ValueError( + f"Tuple length {len(value)} does not match expected length {len(element_encoders)}" + ) + for enc, elem in zip(element_encoders, value): + result.extend(enc.serialize(elem)) + return result + else: + return super().serialize(value) + return typing.cast(Encodable[T], TupleEncodable()) @@ -149,8 +206,14 @@ def _type_encodable_type_list[T](ty: type[T]) -> Encodable[T]: element_ty = args[0] element_encoder = type_to_encodable_type(element_ty) + # Check if element type is Image.Image + has_image = element_ty is Image.Image + # Build the encoded type (list of encoded element type) - runtime-created, use Any - encoded_ty: type[typing.Any] = typing.cast(type[typing.Any], list) + encoded_ty: type[typing.Any] = typing.cast( + type[typing.Any], + list[element_encoder.t], # type: ignore + ) class ListEncodable(_Encodable[T, typing.Any]): t: type[typing.Any] = encoded_ty @@ -168,4 +231,17 @@ def decode(cls, t: typing.Any) -> T: ] return typing.cast(T, decoded_elements) + @classmethod + def serialize(cls, value: typing.Any) -> list[OpenAIMessageContentListBlock]: + if has_image: + # If list contains images, serialize each element and flatten the results + result: list[OpenAIMessageContentListBlock] = [] + if not isinstance(value, list): + raise TypeError(f"Expected list, got {type(value)}") + for elem in value: + result.extend(element_encoder.serialize(elem)) + return result + else: + return super().serialize(value) + return typing.cast(Encodable[T], ListEncodable()) diff --git a/effectful/handlers/llm/providers.py b/effectful/handlers/llm/providers.py index dea78a6b..27097f2d 100644 --- a/effectful/handlers/llm/providers.py +++ b/effectful/handlers/llm/providers.py @@ -7,7 +7,7 @@ import string import traceback import typing -from collections.abc import Callable, Hashable, Iterable, Mapping, Sequence +from collections.abc import Callable, Hashable, Iterable, Mapping from typing import Any, get_type_hints import litellm @@ -21,8 +21,6 @@ raise ImportError("'pillow' is required to use effectful.handlers.providers") from litellm import ( - ChatCompletionImageObject, - ChatCompletionImageUrlObject, Choices, Message, OpenAIChatCompletionToolParam, @@ -47,66 +45,6 @@ def _pil_image_to_base64_data_uri(pil_image: Image.Image) -> str: return f"data:image/png;base64,{_pil_image_to_base64_data(pil_image)}" -def _pil_image_to_openai_image_param( - pil_image: Image.Image, -) -> ChatCompletionImageObject: - return { - "type": "image_url", - "image_url": { - "detail": "auto", - "url": _pil_image_to_base64_data_uri(pil_image), - }, - } - - -@defop -@functools.singledispatch -def serialize(value: Any) -> OpenAIMessageContent: - """Convert a Python value to internal message part representation. - - This function can be extended by registering handlers for - different types using @serialize.register. - - Returns a OpenAIMessageContent - either a string or a list of OpenAIMessageContentListBlock. - """ - return [{"type": "text", "text": str(value)}] - - -@serialize.register(dict) # type: ignore -def _(value: dict) -> OpenAIMessageContent: - if ( - "url" in value - and isinstance(value["url"], str) - and value["url"].startswith("data:image/") - ): - return [ - { - "type": "image_url", - "image_url": typing.cast(ChatCompletionImageUrlObject, value), - } - ] - else: - return [{"type": "text", "text": str(value)}] - - -@serialize.register(str) # type: ignore -def _(value: str) -> OpenAIMessageContent: - return [{"type": "text", "text": value}] - - -@serialize.register(bytes) # type: ignore -def _(value: bytes) -> OpenAIMessageContent: - return [{"type": "text", "text": str(value)}] - - -@serialize.register(Sequence) # type: ignore -def _(values: Sequence) -> OpenAIMessageContent: - if all(isinstance(value, Image.Image) for value in values): - return [_pil_image_to_openai_image_param(value) for value in values] - else: - return [{"type": "text", "text": str(values)}] - - @dataclasses.dataclass class Tool[**P, T]: operation: Operation[P, T] @@ -118,7 +56,7 @@ def serialise_return_value(self, value) -> OpenAIMessageContent: sig = inspect.signature(self.operation) encoded_ty = type_to_encodable_type(sig.return_annotation) encoded_value = encoded_ty.encode(value) - return serialize.dispatch(encoded_ty.t)(encoded_value) # type: ignore + return encoded_ty.serialize(encoded_value) @functools.cached_property def parameter_model(self) -> type[pydantic.BaseModel]: @@ -161,7 +99,7 @@ def call_with_json_args( encoded_ty = type_to_encodable_type(sig.return_annotation) encoded_value = encoded_ty.encode(result) # serialise back to Json - return serialize.dispatch(encoded_ty.t)(encoded_value) # type: ignore + return encoded_ty.serialize(encoded_value) except Exception as exn: return str({"status": "failure", "exception": str(exn)}) @@ -245,8 +183,7 @@ def push_current_text(): if field_name is not None: obj, _ = self.get_field(field_name, args, kwargs) - obj = self.convert_field(obj, conversion) - part = serialize(obj) + part = self.convert_field(obj, conversion) # special casing for text if ( isinstance(part, list) @@ -256,12 +193,11 @@ def push_current_text(): current_text += self.format_field( part[0]["text"], format_spec if format_spec else "" ) - else: - assert not format_spec, ( - "non-text serialized template parameters cannot have format specifiers" - ) + elif isinstance(part, list): push_current_text() prompt_parts.extend(part) + else: + prompt_parts.append(part) push_current_text() return prompt_parts @@ -499,12 +435,14 @@ def format_model_input[**P, T]( bound_args = template.__signature__.bind(*args, **kwargs) bound_args.apply_defaults() # encode arguments - arguments = { - param: type_to_encodable_type( + arguments = {} + for param in bound_args.arguments: + encoder = type_to_encodable_type( template.__signature__.parameters[param].annotation - ).encode(bound_args.arguments[param]) - for param in bound_args.arguments - } + ) + encoded = encoder.encode(bound_args.arguments[param]) + arguments[param] = encoder.serialize(encoded) + prompt = _OpenAIPromptFormatter().format_as_messages( template.__prompt_template__, **arguments ) From 60195d6b7e1ccc34df286cfcdae863b6262d7bea Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Fri, 12 Dec 2025 14:21:17 -0500 Subject: [PATCH 18/21] dropped test for tool that returns list of images --- tests/test_handlers_llm_provider.py | 36 ----------------------------- 1 file changed, 36 deletions(-) diff --git a/tests/test_handlers_llm_provider.py b/tests/test_handlers_llm_provider.py index 5d365a8b..9a0bcc5c 100644 --- a/tests/test_handlers_llm_provider.py +++ b/tests/test_handlers_llm_provider.py @@ -379,42 +379,6 @@ def test_image_input(): assert any("smile" in categorise_image(smiley_face()) for _ in range(3)) -@defop -def get_images_tool(count: int) -> list[Image.Image]: - """Get a list of images. - - Parameters: - - count: Number of images to return - """ - raise NotHandled - - -class ImageListInterpretation(ObjectInterpretation): - """Provides an interpretation for `get_images_tool` that returns a list of images.""" - - @implements(get_images_tool) - def _get_images_tool(self, count: int) -> list[Image.Image]: - return [smiley_face() for _ in range(count)] - - -@Template.define(tools=[get_images_tool]) -def describe_images() -> str: - """Use the provided tool to get images and describe what you see.""" - raise NotHandled - - -@requires_openai -def test_tool_returns_list_of_images(): - """Test that LLM can handle tools that return lists of images.""" - with ( - handler(LiteLLMProvider(model_name="gpt-4o")), - handler(LimitLLMCallsHandler(max_calls=4)), - handler(ImageListInterpretation()), - ): - print(describe_images()) - assert any("smile" in describe_images() for _ in range(3)) - - class BookReview(BaseModel): """A book review with rating and summary.""" From d4cda9ad01a32ab906d39471221f4298fe77b0d7 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Fri, 12 Dec 2025 14:24:15 -0500 Subject: [PATCH 19/21] dropped registration of encodable types --- effectful/handlers/llm/encoding.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/effectful/handlers/llm/encoding.py b/effectful/handlers/llm/encoding.py index 8b457b2b..f3c28b83 100644 --- a/effectful/handlers/llm/encoding.py +++ b/effectful/handlers/llm/encoding.py @@ -58,11 +58,6 @@ def type_to_encodable_type[T]( @type_to_encodable_type.register(object) -@type_to_encodable_type.register(str) -@type_to_encodable_type.register(int) -@type_to_encodable_type.register(bool) -@type_to_encodable_type.register(float) -@type_to_encodable_type.register(complex) def _type_encodable_type_base[T](ty: type[T]) -> Encodable[T]: class BaseEncodable(_Encodable[T, T]): t: type[T] = ty From 2c33590e273d7a57a5e9ac0f91897df13de635dc Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Mon, 15 Dec 2025 10:52:59 -0500 Subject: [PATCH 20/21] dropped unused typevar --- effectful/handlers/llm/encoding.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/effectful/handlers/llm/encoding.py b/effectful/handlers/llm/encoding.py index f3c28b83..92b14bb1 100644 --- a/effectful/handlers/llm/encoding.py +++ b/effectful/handlers/llm/encoding.py @@ -123,9 +123,6 @@ def serialize( return [{"type": "image_url", "image_url": value}] -U = typing.TypeVar("U", bound=pydantic.BaseModel) - - @type_to_encodable_type.register(tuple) def _type_encodable_type_tuple[T](ty: type[T]) -> Encodable[T]: args = typing.get_args(ty) From a2506deed2202acb1340c925249c399d37378cd5 Mon Sep 17 00:00:00 2001 From: Kiran Gopinathan Date: Mon, 15 Dec 2025 10:54:14 -0500 Subject: [PATCH 21/21] s/_Encodable/EncodableAs/ --- effectful/handlers/llm/encoding.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/effectful/handlers/llm/encoding.py b/effectful/handlers/llm/encoding.py index 92b14bb1..fe8b7e21 100644 --- a/effectful/handlers/llm/encoding.py +++ b/effectful/handlers/llm/encoding.py @@ -24,7 +24,7 @@ def _pil_image_to_base64_data_uri(pil_image: Image.Image) -> str: return f"data:image/png;base64,{_pil_image_to_base64_data(pil_image)}" -class _Encodable[T, U](ABC): +class EncodableAs[T, U](ABC): t: type[U] def __init__(self, *args, **kwargs): @@ -45,7 +45,7 @@ def serialize(cls, value: U) -> list[OpenAIMessageContentListBlock]: return [{"type": "text", "text": str(value)}] -class Encodable[T](_Encodable[T, type]): +class Encodable[T](EncodableAs[T, type]): t = type @@ -59,7 +59,7 @@ def type_to_encodable_type[T]( @type_to_encodable_type.register(object) def _type_encodable_type_base[T](ty: type[T]) -> Encodable[T]: - class BaseEncodable(_Encodable[T, T]): + class BaseEncodable(EncodableAs[T, T]): t: type[T] = ty @classmethod @@ -77,7 +77,7 @@ def decode(cls, vl: T) -> T: def _type_encodable_type_pydantic_base_model[T: pydantic.BaseModel]( ty: type[T], ) -> Encodable[T]: - class EncodablePydanticBaseModel(_Encodable[T, T]): + class EncodablePydanticBaseModel(EncodableAs[T, T]): t: type[T] = ty @classmethod @@ -96,7 +96,7 @@ def serialize(cls, vl: T) -> list[OpenAIMessageContentListBlock]: @type_to_encodable_type.register(Image.Image) -class EncodableImage(_Encodable[Image.Image, ChatCompletionImageUrlObject]): +class EncodableImage(EncodableAs[Image.Image, ChatCompletionImageUrlObject]): t = ChatCompletionImageUrlObject @classmethod @@ -142,7 +142,7 @@ def _type_encodable_type_tuple[T](ty: type[T]) -> Encodable[T]: tuple[*(enc.t for enc in element_encoders)], # type: ignore ) - class TupleEncodable(_Encodable[T, typing.Any]): + class TupleEncodable(EncodableAs[T, typing.Any]): t: type[typing.Any] = encoded_ty @classmethod @@ -207,7 +207,7 @@ def _type_encodable_type_list[T](ty: type[T]) -> Encodable[T]: list[element_encoder.t], # type: ignore ) - class ListEncodable(_Encodable[T, typing.Any]): + class ListEncodable(EncodableAs[T, typing.Any]): t: type[typing.Any] = encoded_ty @classmethod