From f081157dc5f360905c7b8159e1c88048d3382266 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Mon, 9 Feb 2026 13:07:17 +0100 Subject: [PATCH 1/2] refactor: let Artifact validator handle base64 decoding across all providers Remove manual base64.b64decode() calls and pass base64 strings directly to ImageArtifact/VideoArtifact/AudioArtifact(data=...). The Artifact field validator automatically decodes base64 strings to bytes. Closes #138 Co-Authored-By: Claude Opus 4.6 --- src/celeste/modalities/audio/providers/google/client.py | 4 +--- src/celeste/modalities/images/providers/byteplus/client.py | 7 ++----- src/celeste/modalities/images/providers/ollama/client.py | 7 ++----- src/celeste/modalities/images/providers/openai/client.py | 7 ++----- src/celeste/modalities/images/providers/xai/client.py | 5 +---- src/celeste/modalities/videos/providers/openai/client.py | 4 +--- 6 files changed, 9 insertions(+), 25 deletions(-) diff --git a/src/celeste/modalities/audio/providers/google/client.py b/src/celeste/modalities/audio/providers/google/client.py index f78aed5c..71edb777 100644 --- a/src/celeste/modalities/audio/providers/google/client.py +++ b/src/celeste/modalities/audio/providers/google/client.py @@ -1,6 +1,5 @@ """Google audio client.""" -import base64 from typing import Any, Unpack from celeste.artifacts import AudioArtifact @@ -62,12 +61,11 @@ def _parse_content( ) -> AudioArtifact: """Extract audio bytes from response.""" audio_b64 = super()._parse_content(response_data) - audio_bytes = base64.b64decode(audio_b64) output_format = parameters.get(AudioParameter.OUTPUT_FORMAT) mime_type = AudioMimeType(output_format) if output_format else AudioMimeType.MP3 - return AudioArtifact(data=audio_bytes, mime_type=mime_type) + return AudioArtifact(data=audio_b64, mime_type=mime_type) def _parse_finish_reason(self, response_data: dict[str, Any]) -> AudioFinishReason: """Parse finish reason from response.""" diff --git a/src/celeste/modalities/images/providers/byteplus/client.py b/src/celeste/modalities/images/providers/byteplus/client.py index efbd07ef..435cd7c2 100644 --- a/src/celeste/modalities/images/providers/byteplus/client.py +++ b/src/celeste/modalities/images/providers/byteplus/client.py @@ -1,6 +1,5 @@ """BytePlus images client.""" -import base64 from typing import Any, Unpack from celeste.artifacts import ImageArtifact @@ -80,8 +79,7 @@ def _parse_chunk(self, event_data: dict[str, Any]) -> ImageChunk | None: if content_type == "url": artifact = ImageArtifact(url=content, mime_type=ImageMimeType.PNG) else: # b64_json - image_data = base64.b64decode(content) - artifact = ImageArtifact(data=image_data) + artifact = ImageArtifact(data=content) return ImageChunk( content=artifact, @@ -162,9 +160,8 @@ def _parse_content( mime_type=ImageMimeType.PNG, ) if image_data.get("b64_json"): - image_bytes = base64.b64decode(image_data["b64_json"]) return ImageArtifact( - data=image_bytes, + data=image_data["b64_json"], mime_type=ImageMimeType.PNG, ) diff --git a/src/celeste/modalities/images/providers/ollama/client.py b/src/celeste/modalities/images/providers/ollama/client.py index 09518dd4..5dd91754 100644 --- a/src/celeste/modalities/images/providers/ollama/client.py +++ b/src/celeste/modalities/images/providers/ollama/client.py @@ -1,6 +1,5 @@ """Ollama images client.""" -import base64 from typing import Any, Unpack from celeste.artifacts import ImageArtifact @@ -53,9 +52,8 @@ def _parse_chunk(self, event_data: dict[str, Any]) -> ImageChunk | None: metadata=self._parse_chunk_metadata(event_data), ) - image_bytes = base64.b64decode(b64_image) return ImageChunk( - content=ImageArtifact(data=image_bytes), + content=ImageArtifact(data=b64_image), finish_reason=self._parse_chunk_finish_reason(event_data), usage=self._parse_chunk_usage(event_data), metadata=self._parse_chunk_metadata(event_data), @@ -115,8 +113,7 @@ def _parse_content( Ollama returns base64-encoded image in the 'image' field. """ image_b64 = super()._parse_content(response_data) - image_bytes = base64.b64decode(image_b64) - return ImageArtifact(data=image_bytes) + return ImageArtifact(data=image_b64) def _parse_finish_reason(self, response_data: dict[str, Any]) -> ImageFinishReason: """Parse finish reason from response.""" diff --git a/src/celeste/modalities/images/providers/openai/client.py b/src/celeste/modalities/images/providers/openai/client.py index d7b6b35d..bf76de9f 100644 --- a/src/celeste/modalities/images/providers/openai/client.py +++ b/src/celeste/modalities/images/providers/openai/client.py @@ -1,6 +1,5 @@ """OpenAI images client.""" -import base64 from typing import Any, Unpack from celeste.artifacts import ImageArtifact @@ -61,8 +60,7 @@ def _parse_chunk(self, event_data: dict[str, Any]) -> ImageChunk | None: metadata={"event_data": event_data}, ) - image_data = base64.b64decode(b64_json) - artifact = ImageArtifact(data=image_data) + artifact = ImageArtifact(data=b64_json) return ImageChunk( content=artifact, @@ -143,8 +141,7 @@ def _parse_content( b64_json = image_data.get("b64_json") if b64_json: - image_bytes = base64.b64decode(b64_json) - return ImageArtifact(data=image_bytes) + return ImageArtifact(data=b64_json) url = image_data.get("url") if url: diff --git a/src/celeste/modalities/images/providers/xai/client.py b/src/celeste/modalities/images/providers/xai/client.py index 66c19ebc..ba60d685 100644 --- a/src/celeste/modalities/images/providers/xai/client.py +++ b/src/celeste/modalities/images/providers/xai/client.py @@ -82,10 +82,7 @@ def _parse_content( # xAI returns either b64_json or url b64_json = image_data.get("b64_json") if b64_json: - import base64 - - image_bytes = base64.b64decode(b64_json) - return ImageArtifact(data=image_bytes) + return ImageArtifact(data=b64_json) url = image_data.get("url") if url: diff --git a/src/celeste/modalities/videos/providers/openai/client.py b/src/celeste/modalities/videos/providers/openai/client.py index 20863fcc..fe8aefb3 100644 --- a/src/celeste/modalities/videos/providers/openai/client.py +++ b/src/celeste/modalities/videos/providers/openai/client.py @@ -1,6 +1,5 @@ """OpenAI videos client.""" -import base64 from typing import Any, Unpack from celeste.artifacts import VideoArtifact @@ -55,9 +54,8 @@ def _parse_content( ) -> VideoArtifact: """Parse content from response.""" video_data_b64 = super()._parse_content(response_data) - video_data = base64.b64decode(video_data_b64) return VideoArtifact( - data=video_data, + data=video_data_b64, mime_type=VideoMimeType.MP4, ) From 0f85a2759bba5ca2fc6f29f698ca1a540ec80109 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Mon, 9 Feb 2026 13:16:56 +0100 Subject: [PATCH 2/2] feat(artifacts): add field_validator to decode base64 strings on input Add @field_validator("data", mode="before") to Artifact base class that automatically decodes base64 strings to bytes. This is the counterpart to the existing @field_serializer that encodes bytes to base64 for JSON output. Without this validator, passing a base64 string to data= would cause Pydantic to UTF-8 encode it, storing literal base64 text as bytes. Co-Authored-By: Claude Opus 4.6 --- src/celeste/artifacts.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/celeste/artifacts.py b/src/celeste/artifacts.py index 360c1238..aec2b1b4 100644 --- a/src/celeste/artifacts.py +++ b/src/celeste/artifacts.py @@ -3,7 +3,7 @@ import base64 from typing import Any -from pydantic import BaseModel, Field, field_serializer +from pydantic import BaseModel, Field, field_serializer, field_validator from celeste.mime_types import ( AudioMimeType, @@ -30,6 +30,14 @@ class Artifact(BaseModel): mime_type: MimeType | None = None metadata: dict[str, Any] = Field(default_factory=dict) + @field_validator("data", mode="before") + @classmethod + def decode_base64_data(cls, v: bytes | str | None) -> bytes | None: + """Decode base64 string to bytes when constructing from JSON.""" + if isinstance(v, str): + return base64.b64decode(v) + return v + @field_serializer("data", when_used="json") def serialize_data(self, value: bytes | None) -> str | None: """Serialize bytes as base64 string for JSON compatibility."""