Skip to content

Commit e3578e4

Browse files
Merge pull request #4 from black-forest-labs/saksham/add-kontext
- Add Kontext Pro API support - Support to use load balanced endpoint `api.bfl.ai`, with use of `polling_url` - Add lazy cleanup of `task_id: polling_url` to reduce memory overhead on local client - Add Kontext unit test case - Updated readme to use dev mode by default
2 parents aa91e90 + eb2484d commit e3578e4

8 files changed

Lines changed: 282 additions & 18 deletions

File tree

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ from blackforest import BFLClient
1818
# or
1919
from blackforestlabs import BFLClient
2020

21+
import os
22+
23+
# For synchronous API call (great for testing, but please use "production" for async calls, for faster throughput)
24+
os.environ["BFL_ENV"] = "dev"
25+
2126
# Initialize the client
2227
client = BFLClient(api_key="your-api-key")
2328

@@ -29,13 +34,39 @@ inputs = {
2934
"output_format": "jpeg"
3035
}
3136
response = client.generate("flux-pro-1.1", inputs)
37+
38+
# For Flux Kontext Pro with reference images
39+
kontext_inputs = {
40+
"prompt": "A beautiful landscape in the style of the reference image",
41+
"input_image": "path/to/reference/image.jpg", # File path (auto-encoded) or base64
42+
"input_image_2": "path/to/another/image.png", # Optional multiref (experimental)
43+
"aspect_ratio": "16:9", # Between 1:4 and 4:1
44+
"output_format": "png",
45+
"seed": 42, # Optional for reproducibility
46+
"safety_tolerance": 2, # 0-6, higher = less strict
47+
"prompt_upsampling": True # Enhanced prompt processing
48+
}
49+
response = client.generate("flux-kontext-pro", kontext_inputs)
3250
```
3351

3452
## Features
3553

3654
- Official Python interface for Black Forest Labs API
3755
- Automatic request handling and response parsing
3856
- Type hints for better IDE support
57+
- Support for all Flux models including Flux Kontext Pro with multi-reference capabilities
58+
59+
## Supported Models
60+
61+
- `flux-dev` - Development model
62+
- `flux-pro` - Professional model
63+
- `flux-pro-1.1` - Enhanced professional model
64+
- `flux-pro-1.1-ultra` - Ultra high-quality model
65+
- `flux-kontext-pro` - Context-aware model with reference image support and experimental multi-reference capabilities
66+
- `flux-pro-1.0-fill` - Image inpainting model
67+
- `flux-pro-1.0-expand` - Image expansion model
68+
- `flux-pro-1.0-canny` - Canny edge-guided model
69+
- `flux-pro-1.0-depth` - Depth-guided model
3970

4071
## Requirements
4172

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "blackforest"
7-
version = "0.1.0"
7+
version = "0.1.1"
88
description = "The official Python library for the Black Forest Labs API"
99
readme = "README.md"
1010
authors = [

src/blackforest/client.py

Lines changed: 115 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ class BFLClient:
3737
def __init__(
3838
self,
3939
api_key: str,
40-
base_url: str = "https://api.us1.bfl.ai",
40+
base_url: str = "https://api.bfl.ai",
4141
timeout: int = 30,
4242
):
4343
"""
@@ -57,6 +57,50 @@ def __init__(
5757
'Content-Type': 'application/json',
5858
'Accept': 'application/json',
5959
})
60+
# Map to store task_id -> (polling_url, timestamp)
61+
self._task_polling_urls = {}
62+
63+
def _cleanup_expired_polling_urls(self) -> None:
64+
"""
65+
Remove polling URL entries that are older than 30 minutes.
66+
"""
67+
current_time = time.time()
68+
expired_keys = []
69+
70+
for task_id, (polling_url, timestamp) in self._task_polling_urls.items():
71+
# Remove entries older than 30 minutes (1800 seconds)
72+
if current_time - timestamp > 1800:
73+
expired_keys.append(task_id)
74+
75+
for key in expired_keys:
76+
del self._task_polling_urls[key]
77+
78+
def clear_polling_urls(self) -> None:
79+
"""
80+
Manually clear all stored polling URLs.
81+
"""
82+
self._task_polling_urls.clear()
83+
84+
def _get_polling_endpoint(self, task_id: str) -> str:
85+
"""
86+
Get the appropriate polling endpoint for a task.
87+
88+
Args:
89+
task_id: The task ID to get the endpoint for
90+
91+
Returns:
92+
The endpoint (relative path or full URL) to use for polling
93+
"""
94+
# Clean up expired entries
95+
self._cleanup_expired_polling_urls()
96+
97+
if task_id in self._task_polling_urls:
98+
polling_url, _ = self._task_polling_urls[task_id] # Extract URL from tuple
99+
# Return the full polling URL as-is since _request() now handles full URLs
100+
return polling_url
101+
else:
102+
# Fallback to constructed URL
103+
return f"/v1/get_result?id={task_id}"
60104

61105
def _request(
62106
self,
@@ -71,7 +115,7 @@ def _request(
71115
72116
Args:
73117
method: HTTP method
74-
endpoint: API endpoint
118+
endpoint: API endpoint (can be a relative path or full URL)
75119
params: URL parameters
76120
data: Form data
77121
json: JSON data
@@ -82,7 +126,11 @@ def _request(
82126
Raises:
83127
BFLError: If the API request fails
84128
"""
85-
url = urljoin(self.base_url, endpoint)
129+
# If endpoint is already a full URL, use it directly
130+
if endpoint.startswith(('http://', 'https://')):
131+
url = endpoint
132+
else:
133+
url = urljoin(self.base_url, endpoint)
86134

87135
try:
88136
response = self.session.request(
@@ -112,6 +160,38 @@ def _encode_image(self, image_path: str) -> str:
112160
with open(image_path, 'rb') as image_file:
113161
return base64.b64encode(image_file.read()).decode('utf-8')
114162

163+
def _is_file_path(self, value: str) -> bool:
164+
"""Check if a string is a file path (not base64 or URL)."""
165+
if value.startswith(('http://', 'https://')):
166+
return False
167+
# Check if it looks like base64 (no path separators, mostly alphanumeric with +/=)
168+
if '/' not in value and '\\' not in value and len(value) > 100:
169+
try:
170+
base64.b64decode(value)
171+
return False # It's valid base64
172+
except Exception:
173+
pass
174+
# Check if file exists
175+
return os.path.exists(value)
176+
177+
def _process_kontext_inputs(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
178+
"""Process flux-kontext-pro inputs to automatically encode image paths."""
179+
processed_inputs = inputs.copy()
180+
181+
# Image fields that might need encoding
182+
image_fields = ['input_image', 'input_image_2', 'input_image_3', 'input_image_4']
183+
184+
for field in image_fields:
185+
if field in processed_inputs and processed_inputs[field]:
186+
value = processed_inputs[field]
187+
if isinstance(value, str) and self._is_file_path(value):
188+
try:
189+
processed_inputs[field] = self._encode_image(value)
190+
except Exception as e:
191+
raise BFLError(f"Error encoding image file '{value}' for field '{field}': {str(e)}")
192+
193+
return processed_inputs
194+
115195
def _process_folder(self, folder_path: str) -> List[str]:
116196
"""Process all images in a folder and return list of base64 encoded images."""
117197
folder = Path(folder_path)
@@ -198,8 +278,15 @@ def process_image(
198278
# Make the API request
199279
response = self._request("POST", endpoint, json=payload)
200280

281+
# Store the polling URL for this task if available
282+
task_id = response.get("id")
283+
if task_id and "polling_url" in response:
284+
# Clean up expired entries before adding new one
285+
self._cleanup_expired_polling_urls()
286+
self._task_polling_urls[task_id] = (response["polling_url"], time.time())
287+
201288
return ImageProcessingResponse(
202-
task_id=response.get("id"),
289+
task_id=task_id,
203290
status="submitted",
204291
result=response
205292
)
@@ -214,7 +301,8 @@ def get_task_status(self, task_id: str) -> ImageProcessingResponse:
214301
Returns:
215302
ImageProcessingResponse containing current status and result if available
216303
"""
217-
response = self._request("GET", f"/v1/get_result?id={task_id}")
304+
endpoint = self._get_polling_endpoint(task_id)
305+
response = self._request("GET", endpoint)
218306

219307
return ImageProcessingResponse(
220308
task_id=task_id,
@@ -250,13 +338,15 @@ def get_polling_result(
250338
while attempts < config.max_retries:
251339
print(f"Polling task {task_id} for result. \
252340
Attempt {attempts + 1} of {config.max_retries}")
253-
response = self._request("GET", f"/v1/get_result?id={task_id}")
341+
342+
endpoint = self._get_polling_endpoint(task_id)
343+
response = self._request("GET", endpoint)
254344

255345
# Check if the task is complete
256346
if response.get("status") in ["Ready", "completed", "failed"]:
257347
if response.get("status") == "failed":
258-
raise BFLError(f"Task failed: {response.get('error', \
259-
'Unknown error')}")
348+
error_msg = response.get('error', 'Unknown error')
349+
raise BFLError(f"Task failed: {error_msg}")
260350
return response.get("result", {})
261351

262352
# Check for timeout
@@ -319,30 +409,42 @@ def generate(
319409
raise BFLError(f"Model {model} not supported. \
320410
Supported models: {list(self.model_input_registry.keys())}")
321411

412+
# Process inputs for automatic image encoding if using flux-kontext-pro
413+
processed_inputs = inputs
414+
if model == "flux-kontext-pro":
415+
processed_inputs = self._process_kontext_inputs(inputs)
416+
322417
# Convert the inputs to the appropriate type
323-
typed_inputs = input_cls(**inputs)
418+
typed_inputs = input_cls(**processed_inputs)
324419

325420
response = self._request("POST", f"{self.api_version}/{model}",
326421
json=typed_inputs.model_dump(exclude_none=True))
327422

423+
# Store the polling URL for this task
424+
task_id = response["id"]
425+
if "polling_url" in response:
426+
# Clean up expired entries before adding new one
427+
self._cleanup_expired_polling_urls()
428+
self._task_polling_urls[task_id] = (response["polling_url"], time.time())
429+
328430
# Track usage if requested
329431
if track_usage:
330432
self.track_usage_via_api(model, 1)
331433

332434
# If sync is True, poll for results
333435
if config.sync:
334436
try:
335-
result = self.get_polling_result(response["id"], config)
437+
result = self.get_polling_result(task_id, config)
336438
return SyncResponse(
337-
id=response["id"],
439+
id=task_id,
338440
result=result
339441
)
340442
except Exception as e:
341443
raise BFLError(f"Error getting synchronous result: {str(e)}")
342444

343445
else:
344446
return AsyncResponse(
345-
id=response["id"],
447+
id=task_id,
346448
polling_url=response["polling_url"]
347449
)
348450

@@ -383,5 +485,4 @@ def track_usage_via_api(self, name: str, n: int = 1) -> None:
383485
self._request("POST", endpoint, json=payload)
384486
print(f"Successfully tracked usage for {name} with {n} generations")
385487
except BFLError as e:
386-
raise BFLError(f"Failed to track usage for {name}: {str(e)}")
387-
q
488+
raise BFLError(f"Failed to track usage for {name}: {str(e)}")

src/blackforest/resources/mapping/model_input_registry.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from blackforest.types.inputs.flux_dev import FluxDevInputs
2+
from blackforest.types.inputs.flux_kontext_pro import FluxKontextProInputs
23
from blackforest.types.inputs.flux_pro import FluxProInputs
34
from blackforest.types.inputs.flux_pro_1_1 import FluxPro11Inputs
45
from blackforest.types.inputs.flux_pro_canny import FluxProCannyInputs
@@ -16,4 +17,5 @@
1617
"flux-pro-1.0-expand": FluxProExpandInputs,
1718
"flux-pro-1.0-canny": FluxProCannyInputs,
1819
"flux-pro-1.0-depth": FluxProDepthInputs,
20+
"flux-kontext-pro": FluxKontextProInputs,
1921
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import re
2+
from typing import Optional
3+
4+
from pydantic import Field, HttpUrl, model_validator
5+
6+
from blackforest.types.base.output_format import OutputFormat
7+
from blackforest.types.inputs.generic import GenericImageInput
8+
9+
10+
class FluxKontextProInputs(GenericImageInput):
11+
"""Inputs for the Flux Kontext Pro model."""
12+
13+
# Override prompt to make it required
14+
prompt: str = Field(
15+
example="ein fantastisches bild",
16+
description="Text prompt for image generation.",
17+
)
18+
19+
# Override output_format default to png
20+
output_format: Optional[OutputFormat] = Field(
21+
default=OutputFormat.png,
22+
description="Output format for the generated image. Can be 'jpeg' or 'png'.",
23+
)
24+
25+
# Kontext-specific input image fields
26+
input_image: Optional[str] = Field(
27+
default=None,
28+
description="Base64 encoded image or URL to use with Kontext.",
29+
)
30+
input_image_2: Optional[str] = Field(
31+
default=None,
32+
description="Base64 encoded image or URL to use with Kontext. *Experimental Multiref*",
33+
)
34+
input_image_3: Optional[str] = Field(
35+
default=None,
36+
description="Base64 encoded image or URL to use with Kontext. *Experimental Multiref*",
37+
)
38+
input_image_4: Optional[str] = Field(
39+
default=None,
40+
description="Base64 encoded image or URL to use with Kontext. *Experimental Multiref*",
41+
)
42+
43+
# Custom aspect ratio field with specific validation
44+
aspect_ratio: Optional[str] = Field(
45+
default=None, description="Aspect ratio of the image between 21:9 and 9:21"
46+
)
47+
48+
@model_validator(mode="after")
49+
def validate_aspect_ratio(self):
50+
try:
51+
if self.aspect_ratio is not None:
52+
# ensure proper format (1:1) and ratio is between 21:9 and 9:21
53+
if not re.match(r"^\d+:\d+$", self.aspect_ratio):
54+
raise ValueError(
55+
"Aspect ratio must be in the format of 'width:height'"
56+
)
57+
width, height = map(int, self.aspect_ratio.split(":"))
58+
ratio = width / height
59+
min_ratio = 1 / 4
60+
max_ratio = 4 / 1
61+
if not (min_ratio <= ratio <= max_ratio):
62+
raise ValueError(
63+
f"Aspect ratio {self.aspect_ratio} ({ratio:.3f}) must be between 1:4 and 4:1"
64+
)
65+
except Exception as e:
66+
raise ValueError(f"Invalid aspect ratio: {e}")
67+
return self

tests/inputs/test_image_1.jpeg

114 KB
Loading

tests/inputs/test_image_2.jpeg

114 KB
Loading

0 commit comments

Comments
 (0)