Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ help:
@echo " make lint - Run Ruff linting"
@echo " make format - Apply Ruff formatting"
@echo " make typecheck - Run mypy type checking"
@echo " make test - Run pytest with coverage"
@echo " make test - Run all tests (core + packages) with coverage"
@echo " make integration-test - Run integration tests (requires API keys)"
@echo " make security - Run Bandit security scan"
@echo " make ci - Run full CI/CD pipeline"
Expand Down Expand Up @@ -37,7 +37,7 @@ typecheck:

# Testing
test:
uv run pytest tests/unit_tests --cov=celeste --cov-report=term-missing --cov-fail-under=90
uv run pytest tests/unit_tests packages/*/tests/unit_tests --cov=celeste --cov-report=term-missing --cov-fail-under=90 -v

# Integration testing (requires API keys)
integration-test:
Expand Down
79 changes: 79 additions & 0 deletions packages/image-generation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<div align="center">

# <img src="../../logo.svg" width="48" height="48" alt="Celeste Logo" style="vertical-align: middle;"> Celeste Image Generation

**Image Generation capability for Celeste AI**

[![Python](https://img.shields.io/badge/Python-3.12+-blue?style=for-the-badge)](https://www.python.org/)
[![License](https://img.shields.io/badge/License-Apache_2.0-red?style=for-the-badge)](../../LICENSE)

[Quick Start](#-quick-start) β€’ [Documentation](https://withceleste.ai/docs) β€’ [Request Provider](https://github.com/withceleste/celeste-python/issues/new)

</div>

---

## πŸš€ Quick Start

```python
from celeste import create_client, Capability, Provider

client = create_client(
capability=Capability.IMAGE_GENERATION,
provider=Provider.OPENAI,
)

response = await client.generate(prompt="A red apple on a white background")
print(response.content)
```

**Install:**
```bash
uv add "celeste-ai[image-generation]"
```

---

## Supported Providers


<div align="center">

<img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="64" height="64" alt="OpenAI" title="OpenAI">
<img src="https://www.google.com/s2/favicons?domain=google.com&sz=64" width="64" height="64" alt="Google" title="Google">
<img src="https://www.google.com/s2/favicons?domain=seed.bytedance.com&sz=64" width="64" height="64" alt="ByteDance" title="ByteDance">


**Missing a provider?** [Request it](https://github.com/withceleste/celeste-python/issues/new) – ⚑ **we ship fast**.

</div>

---

**Streaming**: βœ… Supported

**Parameters**: See [API Documentation](https://withceleste.ai/docs/api) for full parameter reference.

---

## 🀝 Contributing

See [CONTRIBUTING.md](../../CONTRIBUTING.md) for guidelines.

**Request a provider:** [GitHub Issues](https://github.com/withceleste/celeste-python/issues/new)

---

## πŸ“„ License

Apache 2.0 License – see [LICENSE](../../LICENSE) for details.

---

<div align="center">

**[Get Started](https://withceleste.ai/docs/quickstart)** β€’ **[Documentation](https://withceleste.ai/docs)** β€’ **[GitHub](https://github.com/withceleste/celeste-python)**

Made with ❀️ by developers tired of framework lock-in

</div>
39 changes: 39 additions & 0 deletions packages/image-generation/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
[project]
name = "celeste-image-generation"
version = "0.2.1"
description = "Type-safe image generation for Celeste AI. Unified interface for OpenAI, Google, ByteDance, and more"
authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}]
readme = "README.md"
license = {text = "Apache-2.0"}
requires-python = ">=3.12"
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Operating System :: OS Independent",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Typing :: Typed",
]
keywords = ["ai", "image-generation", "dall-e", "imagen", "openai", "google", "bytedance"]

[project.urls]
Homepage = "https://withceleste.ai"
Documentation = "https://withceleste.ai/docs"
Repository = "https://github.com/withceleste/celeste-python"
Issues = "https://github.com/withceleste/celeste-python/issues"

[tool.uv.sources]
celeste-ai = { workspace = true }

[project.entry-points."celeste.packages"]
image-generation = "celeste_image_generation:register_package"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/celeste_image_generation"]
36 changes: 36 additions & 0 deletions packages/image-generation/src/celeste_image_generation/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Celeste image generation capability."""


def register_package() -> None:
"""Register image generation package (client and models)."""
from celeste.client import register_client
from celeste.core import Capability
from celeste.models import register_models
from celeste_image_generation.models import MODELS
from celeste_image_generation.providers import PROVIDERS

# Register provider-specific clients
for provider, client_class in PROVIDERS:
register_client(Capability.IMAGE_GENERATION, provider, client_class)

register_models(MODELS, capability=Capability.IMAGE_GENERATION)


from celeste_image_generation.io import ( # noqa: E402
ImageGenerationChunk,
ImageGenerationFinishReason,
ImageGenerationInput,
ImageGenerationOutput,
ImageGenerationUsage,
)
from celeste_image_generation.streaming import ImageGenerationStream # noqa: E402

__all__ = [
"ImageGenerationChunk",
"ImageGenerationFinishReason",
"ImageGenerationInput",
"ImageGenerationOutput",
"ImageGenerationStream",
"ImageGenerationUsage",
"register_package",
]
87 changes: 87 additions & 0 deletions packages/image-generation/src/celeste_image_generation/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Base client for image generation."""

from abc import abstractmethod
from typing import Any, Unpack

import httpx

from celeste.artifacts import ImageArtifact
from celeste.client import Client
from celeste.exceptions import ValidationError
from celeste_image_generation.io import (
ImageGenerationFinishReason,
ImageGenerationInput,
ImageGenerationOutput,
ImageGenerationUsage,
)
from celeste_image_generation.parameters import ImageGenerationParameters


class ImageGenerationClient(
Client[ImageGenerationInput, ImageGenerationOutput, ImageGenerationParameters]
):
"""Client for image generation operations."""

@abstractmethod
def _init_request(self, inputs: ImageGenerationInput) -> dict[str, Any]:
"""Initialize provider-specific request structure."""
...

@abstractmethod
def _parse_usage(self, response_data: dict[str, Any]) -> ImageGenerationUsage:
"""Parse usage information from provider response."""
...

@abstractmethod
def _parse_content(
self,
response_data: dict[str, Any],
**parameters: Unpack[ImageGenerationParameters],
) -> ImageArtifact:
"""Parse content from provider response."""
...

@abstractmethod
def _parse_finish_reason(
self, response_data: dict[str, Any]
) -> ImageGenerationFinishReason | None:
"""Parse finish reason from provider response."""
...

def _create_inputs(
self, *args: str, **parameters: Unpack[ImageGenerationParameters]
) -> ImageGenerationInput:
"""Map positional arguments to Input type."""
if args:
return ImageGenerationInput(prompt=args[0])
prompt = parameters.get("prompt")
if prompt is None:
msg = (
"prompt is required (either as positional argument or keyword argument)"
)
raise ValidationError(msg)
return ImageGenerationInput(prompt=prompt)

@classmethod
def _output_class(cls) -> type[ImageGenerationOutput]:
"""Return the Output class for this client."""
return ImageGenerationOutput

def _build_metadata(self, response_data: dict[str, Any]) -> dict[str, Any]:
"""Build metadata dictionary from response data."""
metadata = super()._build_metadata(response_data)
# Only parse finish_reason if not already set by provider override
if "finish_reason" not in metadata:
finish_reason = self._parse_finish_reason(response_data)
if finish_reason is not None:
metadata["finish_reason"] = finish_reason
return metadata

@abstractmethod
async def _make_request(
self,
request_body: dict[str, Any],
**parameters: Unpack[ImageGenerationParameters],
) -> httpx.Response:
"""Make HTTP request(s) and return response object."""
...
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Image generation specific constraints."""

from celeste.constraints import Constraint
from celeste.exceptions import ConstraintViolationError


class Dimensions(Constraint):
"""Dimension string constraint with pixel and aspect ratio bounds."""

min_pixels: int
max_pixels: int
min_aspect_ratio: float
max_aspect_ratio: float
presets: dict[str, str] | None = None

def __call__(self, value: str) -> str:
"""Validate dimension string against pixel and aspect ratio bounds."""
if not isinstance(value, str):
msg = f"Must be string, got {type(value).__name__}"
raise ConstraintViolationError(msg)

# Check if value is a preset key
if self.presets and value in self.presets:
actual_value = self.presets[value]
else:
actual_value = value

# Parse dimension format "WIDTHxHEIGHT"
parts = actual_value.lower().split("x")
if len(parts) != 2:
msg = f"Invalid dimension format: {actual_value!r}. Expected 'WIDTHxHEIGHT'"
raise ConstraintViolationError(msg)

# Validate parts are numeric
if not parts[0].isdigit() or not parts[1].isdigit():
msg = (
f"Invalid dimension format: {actual_value!r}. "
f"Width and height must be positive integers"
)
raise ConstraintViolationError(msg)

width = int(parts[0])
height = int(parts[1])

# Validate dimensions are positive
if width <= 0 or height <= 0:
msg = f"Width and height must be positive, got {width}x{height}"
raise ConstraintViolationError(msg)

# Validate total pixels
total_pixels = width * height
if not (self.min_pixels <= total_pixels <= self.max_pixels):
msg = (
f"Total pixels {total_pixels:,} outside valid range "
f"[{self.min_pixels:,}, {self.max_pixels:,}]"
)
raise ConstraintViolationError(msg)

# Validate aspect ratio
aspect_ratio = width / height
if not (self.min_aspect_ratio <= aspect_ratio <= self.max_aspect_ratio):
msg = (
f"Aspect ratio {aspect_ratio:.3f} outside valid range "
f"[{self.min_aspect_ratio:.3f}, {self.max_aspect_ratio:.1f}]"
)
raise ConstraintViolationError(msg)

# Return normalized format
return f"{width}x{height}"


__all__ = ["Dimensions"]
58 changes: 58 additions & 0 deletions packages/image-generation/src/celeste_image_generation/io.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Input and output types for image generation."""

from celeste.artifacts import ImageArtifact
from celeste.io import Chunk, FinishReason, Input, Output, Usage


class ImageGenerationInput(Input):
"""Input for image generation operations."""

prompt: str


class ImageGenerationFinishReason(FinishReason):
"""Image generation finish reason.

Stores raw provider reason. Providers map their values in implementation.
"""

reason: str | None = (
None # Raw provider string (e.g., "STOP", "NO_IMAGE", "PROHIBITED_CONTENT")
)
message: str | None = None # Optional human-readable explanation from provider


class ImageGenerationUsage(Usage):
"""Image generation usage metrics.

Most providers don't report usage metrics for image generation.
OpenAI gpt-image-1 reports usage only in streaming mode.
ByteDance reports tokens_used for billing tracking.
"""

total_tokens: int | None = None
input_tokens: int | None = None
output_tokens: int | None = None
generated_images: int | None = None


class ImageGenerationOutput(Output[ImageArtifact]):
"""Output with ImageArtifact content."""

pass


class ImageGenerationChunk(Chunk[ImageArtifact]):
"""Typed chunk for image generation streaming."""

finish_reason: ImageGenerationFinishReason | None = None
usage: ImageGenerationUsage | None = None


__all__ = [
"ImageGenerationChunk",
"ImageGenerationFinishReason",
"ImageGenerationInput",
"ImageGenerationOutput",
"ImageGenerationUsage",
]
Loading
Loading