Skip to content

Commit e6ee2f9

Browse files
feat(image-generation): add BFL (Black Forest Labs) provider (#54)
* feat(image-generation): add BFL (Black Forest Labs) provider Add support for BFL's FLUX.2 image generation models: - flux-2-pro: Professional model with automatic prompt upsampling - flux-2-flex: Flexible model with configurable steps and guidance Changes: - Add BFL provider with polling-based async generation - Add BFL to Provider enum and credential system - Add new ImageGenerationParameter values (guidance, steps, seed, etc.) - Add billed_units field to ImageGenerationUsage - Add BFL to integration tests and CI workflow - Support BFL_API_KEY environment variable * chore: bump versions for BFL provider release - celeste-ai: 0.2.11 → 0.2.12 - celeste-image-generation: 0.2.9 → 0.2.10 * fix: use content param for httpx.Response construction httpx.Response doesn't accept json parameter - use content with JSON-encoded bytes instead.
1 parent 720b305 commit e6ee2f9

16 files changed

Lines changed: 431 additions & 2 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ OPENAI_API_KEY=your-openai-api-key-here
77
# Anthropic
88
ANTHROPIC_API_KEY=your-anthropic-api-key-here
99

10+
# BFL (Black Forest Labs)
11+
BFL_API_KEY=your-bfl-api-key-here
12+
1013
# Google
1114
GOOGLE_API_KEY=your-google-api-key-here
1215

.github/workflows/publish.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ jobs:
5555
env:
5656
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
5757
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
58+
BFL_API_KEY: ${{ secrets.BFL_API_KEY }}
5859
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
5960
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
6061
COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}

packages/image-generation/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "celeste-image-generation"
3-
version = "0.2.9"
3+
version = "0.2.10"
44
description = "Image generation package for Celeste AI. Unified interface for all providers"
55
authors = [{name = "Kamilbenkirane", email = "kamil@withceleste.ai"}]
66
readme = "README.md"

packages/image-generation/src/celeste_image_generation/io.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class ImageGenerationUsage(Usage):
3131
output_tokens: int | None = None
3232
reasoning_tokens: int | None = None
3333
generated_images: int | None = None
34+
billed_units: float | None = None
3435

3536

3637
class ImageGenerationOutput(Output[ImageArtifact]):

packages/image-generation/src/celeste_image_generation/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
"""Model definitions for image generation."""
22

33
from celeste import Model
4+
from celeste_image_generation.providers.bfl.models import MODELS as BFL_MODELS
45
from celeste_image_generation.providers.bytedance.models import (
56
MODELS as BYTEDANCE_MODELS,
67
)
78
from celeste_image_generation.providers.google.models import MODELS as GOOGLE_MODELS
89
from celeste_image_generation.providers.openai.models import MODELS as OPENAI_MODELS
910

1011
MODELS: list[Model] = [
12+
*BFL_MODELS,
1113
*BYTEDANCE_MODELS,
1214
*GOOGLE_MODELS,
1315
*OPENAI_MODELS,

packages/image-generation/src/celeste_image_generation/parameters.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,27 @@ class ImageGenerationParameter(StrEnum):
99
"""Unified parameter names for image generation capability."""
1010

1111
ASPECT_RATIO = "aspect_ratio"
12+
GUIDANCE = "guidance"
13+
OUTPUT_FORMAT = "output_format"
1214
PARTIAL_IMAGES = "partial_images"
15+
PROMPT_UPSAMPLING = "prompt_upsampling"
1316
QUALITY = "quality"
17+
SAFETY_TOLERANCE = "safety_tolerance"
18+
SEED = "seed"
19+
STEPS = "steps"
1420
WATERMARK = "watermark"
1521

1622

1723
class ImageGenerationParameters(Parameters):
1824
"""Parameters for image generation."""
1925

2026
aspect_ratio: str | None
27+
guidance: float | None
28+
output_format: str | None
2129
partial_images: int | None
30+
prompt_upsampling: bool | None
2231
quality: str | None
32+
safety_tolerance: int | None
33+
seed: int | None
34+
steps: int | None
2335
watermark: bool | None

packages/image-generation/src/celeste_image_generation/providers/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
def _get_providers() -> list[tuple[Provider, type[Client]]]:
99
"""Lazy-load providers."""
1010
# Import clients directly from .client modules to avoid __init__.py imports
11+
from celeste_image_generation.providers.bfl.client import (
12+
BFLImageGenerationClient,
13+
)
1114
from celeste_image_generation.providers.bytedance.client import (
1215
ByteDanceImageGenerationClient,
1316
)
@@ -19,6 +22,7 @@ def _get_providers() -> list[tuple[Provider, type[Client]]]:
1922
)
2023

2124
return [
25+
(Provider.BFL, BFLImageGenerationClient),
2226
(Provider.BYTEDANCE, ByteDanceImageGenerationClient),
2327
(Provider.GOOGLE, GoogleImageGenerationClient),
2428
(Provider.OPENAI, OpenAIImageGenerationClient),
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""BFL (Black Forest Labs) provider for image generation."""
2+
3+
from .client import BFLImageGenerationClient
4+
from .models import MODELS
5+
6+
__all__ = ["MODELS", "BFLImageGenerationClient"]
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""BFL (Black Forest Labs) client implementation for FLUX.2 image generation."""
2+
3+
import asyncio
4+
import json
5+
import time
6+
from typing import Any, Unpack
7+
8+
import httpx
9+
10+
from celeste.artifacts import ImageArtifact
11+
from celeste.mime_types import ApplicationMimeType
12+
from celeste.parameters import ParameterMapper
13+
from celeste_image_generation.client import ImageGenerationClient
14+
from celeste_image_generation.io import (
15+
ImageGenerationFinishReason,
16+
ImageGenerationInput,
17+
ImageGenerationUsage,
18+
)
19+
from celeste_image_generation.parameters import ImageGenerationParameters
20+
21+
from . import config
22+
from .parameters import BFL_PARAMETER_MAPPERS
23+
24+
25+
class BFLImageGenerationClient(ImageGenerationClient):
26+
"""Black Forest Labs client for image generation."""
27+
28+
@classmethod
29+
def parameter_mappers(cls) -> list[ParameterMapper]:
30+
return BFL_PARAMETER_MAPPERS
31+
32+
def _init_request(self, inputs: ImageGenerationInput) -> dict[str, Any]:
33+
"""Initialize request for BFL API format."""
34+
return {
35+
"prompt": inputs.prompt,
36+
}
37+
38+
def _parse_usage(self, response_data: dict[str, Any]) -> ImageGenerationUsage:
39+
"""Parse usage from response."""
40+
submit_metadata = response_data.get("_submit_metadata", {})
41+
cost = submit_metadata.get("cost")
42+
43+
return ImageGenerationUsage(
44+
billed_units=float(cost) if cost is not None else None,
45+
)
46+
47+
def _parse_content(
48+
self,
49+
response_data: dict[str, Any],
50+
**parameters: Unpack[ImageGenerationParameters],
51+
) -> ImageArtifact:
52+
"""Parse content from response."""
53+
result = response_data.get("result", {})
54+
sample_url = result.get("sample")
55+
56+
if not sample_url:
57+
msg = f"No image URL in {self.provider} response"
58+
raise ValueError(msg)
59+
60+
return ImageArtifact(url=sample_url)
61+
62+
def _parse_finish_reason(
63+
self, response_data: dict[str, Any]
64+
) -> ImageGenerationFinishReason | None:
65+
"""Parse finish reason from response."""
66+
status = response_data.get("status")
67+
if status == "Ready":
68+
return ImageGenerationFinishReason(reason="COMPLETE")
69+
elif status in ("Error", "Failed"):
70+
error_msg = response_data.get("error", "Generation failed")
71+
return ImageGenerationFinishReason(reason="ERROR", message=error_msg)
72+
return None
73+
74+
async def _make_request(
75+
self,
76+
request_body: dict[str, Any],
77+
**parameters: Unpack[ImageGenerationParameters],
78+
) -> httpx.Response:
79+
"""Make HTTP request(s) and return response object."""
80+
headers = {
81+
config.AUTH_HEADER_NAME: self.api_key.get_secret_value(),
82+
"Content-Type": ApplicationMimeType.JSON,
83+
"Accept": ApplicationMimeType.JSON,
84+
}
85+
86+
endpoint = config.ENDPOINT.format(model_id=self.model.id)
87+
88+
submit_response = await self.http_client.post(
89+
f"{config.BASE_URL}{endpoint}",
90+
headers=headers,
91+
json_body=request_body,
92+
)
93+
94+
if submit_response.status_code != 200:
95+
return submit_response
96+
97+
submit_data = submit_response.json()
98+
polling_url = submit_data.get("polling_url")
99+
100+
if not polling_url:
101+
msg = f"No polling_url in {self.provider} response"
102+
raise ValueError(msg)
103+
104+
start_time = time.monotonic()
105+
poll_headers = {
106+
config.AUTH_HEADER_NAME: self.api_key.get_secret_value(),
107+
"Accept": ApplicationMimeType.JSON,
108+
}
109+
110+
while True:
111+
elapsed = time.monotonic() - start_time
112+
if elapsed >= config.POLLING_TIMEOUT:
113+
msg = f"{self.provider} polling timed out after {config.POLLING_TIMEOUT} seconds"
114+
raise TimeoutError(msg)
115+
116+
poll_response = await self.http_client.get(
117+
polling_url,
118+
headers=poll_headers,
119+
)
120+
121+
if poll_response.status_code != 200:
122+
return poll_response
123+
124+
poll_data = poll_response.json()
125+
status = poll_data.get("status")
126+
127+
if status == "Ready":
128+
final_data = {
129+
**poll_data,
130+
"_submit_metadata": submit_data,
131+
}
132+
return httpx.Response(
133+
status_code=200,
134+
content=json.dumps(final_data).encode("utf-8"),
135+
headers={"content-type": "application/json"},
136+
request=httpx.Request("GET", polling_url),
137+
)
138+
elif status in ("Error", "Failed"):
139+
return httpx.Response(
140+
status_code=400,
141+
content=json.dumps(poll_data).encode("utf-8"),
142+
headers={"content-type": "application/json"},
143+
request=httpx.Request("GET", polling_url),
144+
)
145+
146+
await asyncio.sleep(config.POLLING_INTERVAL)
147+
148+
149+
__all__ = ["BFLImageGenerationClient"]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""BFL (Black Forest Labs) provider configuration for image generation."""
2+
3+
# HTTP Configuration
4+
BASE_URL = "https://api.bfl.ai"
5+
ENDPOINT = "/v1/{model_id}"
6+
7+
# Authentication
8+
AUTH_HEADER_NAME = "x-key"
9+
AUTH_HEADER_PREFIX = "" # Direct API key, no prefix
10+
11+
# Polling Configuration
12+
POLLING_INTERVAL = 0.5 # seconds between polling attempts
13+
POLLING_TIMEOUT = 120.0 # 2 minutes for image generation

0 commit comments

Comments
 (0)