Initial release — OpenMontage: the first open-source agentic video production system
11 production pipelines, 47 tools, 124 agent skills. Supports cloud APIs (fal.ai, OpenAI, ElevenLabs, Suno, HeyGen, Runway) and free local providers (diffusers, Piper TTS, WAN 2.1, Hunyuan, CogVideo). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
---
|
||||
name: bfl-api
|
||||
description: BFL FLUX API integration guide covering endpoints, async polling patterns, rate limiting, error handling, webhooks, and regional endpoints with Python and TypeScript code examples.
|
||||
metadata:
|
||||
author: Black Forest Labs
|
||||
version: "1.0.0"
|
||||
tags: flux, bfl, api, integration, webhooks, rate-limiting
|
||||
---
|
||||
|
||||
# BFL API Integration Guide
|
||||
|
||||
Use this skill when integrating BFL FLUX APIs into applications for image generation, editing, and processing.
|
||||
|
||||
## First: Check API Key
|
||||
|
||||
**Before generating images, verify your API key is set:**
|
||||
|
||||
```bash
|
||||
echo $BFL_API_KEY
|
||||
```
|
||||
|
||||
If empty or you see "Not authenticated" errors, see [API Key Setup](#api-key-setup) below.
|
||||
|
||||
## Important: Image URLs Expire in 10 Minutes
|
||||
|
||||
Result URLs from the API are temporary. Download images immediately after generation completes - do not store or cache the URLs themselves.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Setting up BFL API client
|
||||
- Implementing async polling patterns
|
||||
- Handling rate limits and errors
|
||||
- Configuring webhooks for production
|
||||
- Selecting regional endpoints
|
||||
- Building production-ready integrations
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Base Endpoints
|
||||
|
||||
| Region | Endpoint | Use Case |
|
||||
| ------ | ----------------------- | --------------------------- |
|
||||
| Global | `https://api.bfl.ai` | Default, automatic failover |
|
||||
| EU | `https://api.eu.bfl.ai` | GDPR compliance |
|
||||
| US | `https://api.us.bfl.ai` | US data residency |
|
||||
|
||||
### Model Endpoints & Pricing
|
||||
|
||||
> **Credit pricing:** 1 credit = $0.01 USD. FLUX.2 uses megapixel-based pricing (cost scales with resolution).
|
||||
|
||||
#### FLUX.2 Models
|
||||
|
||||
| Model | Path | 1st MP | +MP | 1MP T2I | 1MP I2I | Best For |
|
||||
| ----------------- | --------------------- | ------ | ---- | ------- | ------- | ---------------------------------- |
|
||||
| FLUX.2 [klein] 4B | `/v1/flux-2-klein-4b` | 1.4c | 0.1c | $0.014 | $0.015 | Real-time, high volume |
|
||||
| FLUX.2 [klein] 9B | `/v1/flux-2-klein-9b` | 1.5c | 0.2c | $0.015 | $0.017 | Balanced quality/speed |
|
||||
| FLUX.2 [pro] | `/v1/flux-2-pro` | 3c | 1.5c | $0.03 | $0.045 | Production, fast turnaround |
|
||||
| FLUX.2 [max] | `/v1/flux-2-max` | 7c | 3c | $0.07 | $0.10 | Maximum quality |
|
||||
| FLUX.2 [flex] | `/v1/flux-2-flex` | 5c | 5c | $0.05 | $0.10 | Typography, adjustable controls |
|
||||
| FLUX.2 [dev] | - | - | - | Free | Free | Local development (non-commercial) |
|
||||
|
||||
> **Pricing formula:** `(firstMP + (outputMP-1) * mpPrice) + (inputMP * mpPrice)` in cents
|
||||
|
||||
#### FLUX.1 Models
|
||||
|
||||
| Model | Path | Price/Image | Best For |
|
||||
| -------------------- | ------------------------ | ----------- | ----------------------------- |
|
||||
| FLUX.1 Kontext [pro] | `/v1/flux-kontext` | $0.04 | Image editing with context |
|
||||
| FLUX.1 Kontext [max] | `/v1/flux-kontext-max` | $0.08 | Max quality editing |
|
||||
| FLUX1.1 [pro] | `/v1/flux-pro-1.1` | $0.04 | Standard T2I, fast & reliable |
|
||||
| FLUX1.1 [pro] Ultra | `/v1/flux-pro-1.1-ultra` | $0.06 | Ultra high-resolution |
|
||||
| FLUX1.1 [pro] Raw | `/v1/flux-pro-1.1-raw` | $0.06 | Candid photography feel |
|
||||
| FLUX.1 Fill [pro] | `/v1/flux-pro-1.0-fill` | $0.05 | Inpainting |
|
||||
|
||||
> **Tip:** All FLUX.2 models support image editing via the `input_image` parameter - no separate editing endpoint needed. Use [bfl.ai/pricing](https://bfl.ai/pricing) calculator for exact costs at different resolutions.
|
||||
|
||||
### Image Input for Editing
|
||||
|
||||
**Preferred: Use URLs directly** - simpler and more convenient than base64.
|
||||
|
||||
**Single image editing:**
|
||||
|
||||
```bash
|
||||
curl -X POST "https://api.bfl.ai/v1/flux-2-pro" \
|
||||
-H "x-key: $BFL_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "Change the background to a sunset",
|
||||
"input_image": "https://example.com/photo.jpg"
|
||||
}'
|
||||
```
|
||||
|
||||
**Multi-reference editing:**
|
||||
|
||||
```bash
|
||||
curl -X POST "https://api.bfl.ai/v1/flux-2-pro" \
|
||||
-H "x-key: $BFL_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "The person from image 1 in the environment from image 2",
|
||||
"input_image": "https://example.com/person.jpg",
|
||||
"input_image_2": "https://example.com/background.jpg"
|
||||
}'
|
||||
```
|
||||
|
||||
The API fetches URLs automatically. Both URL and base64 work, but URLs are recommended when available.
|
||||
|
||||
### Multi-Reference I2I
|
||||
|
||||
FLUX.2 models support multiple input images for combining elements, style transfer, and character consistency:
|
||||
|
||||
| Model | Max References |
|
||||
| --------------------- | -------------- |
|
||||
| FLUX.2 [klein] | 4 images |
|
||||
| FLUX.2 [pro/max/flex] | 8 images |
|
||||
|
||||
**Parameters:** `input_image`, `input_image_2`, `input_image_3`, ... `input_image_8`
|
||||
|
||||
**Prompt pattern:** Reference images by number in your prompt:
|
||||
|
||||
- "The subject from image 1 in the environment from image 2"
|
||||
- "Apply the style of image 2 to the scene in image 1"
|
||||
- "The person from image 1 wearing the outfit from image 2, in the pose from image 3"
|
||||
|
||||
> For detailed multi-reference patterns (character consistency, style transfer, pose guidance), see `flux-best-practices/rules/multi-reference-editing.md`
|
||||
|
||||
### Rate Limits
|
||||
|
||||
| Tier | Concurrent Requests |
|
||||
| ------------------------- | ------------------- |
|
||||
| Standard (most endpoints) | 24 |
|
||||
|
||||
### Polling vs Webhooks
|
||||
|
||||
| Approach | Use When |
|
||||
| ------------ | ------------------------------------------------------------------------------------ |
|
||||
| **Polling** | Scripts, CLI tools, local development, single requests, simple integrations |
|
||||
| **Webhooks** | Production apps, high volume, server-to-server, when you need immediate notification |
|
||||
|
||||
**Start with polling** - it's simpler and works everywhere. Switch to webhooks when you need to scale or want event-driven architecture.
|
||||
|
||||
### Key Behaviors
|
||||
|
||||
- **Polling**: Response includes `polling_url` for async results
|
||||
- **URL Expiration**: Result URLs expire after 10 minutes
|
||||
- **Webhook Support**: Configure `webhook_url` for production workloads
|
||||
|
||||
## API Key Setup
|
||||
|
||||
**Required**: The `BFL_API_KEY` environment variable must be set before using the API.
|
||||
|
||||
### Quick Check
|
||||
|
||||
```bash
|
||||
echo $BFL_API_KEY
|
||||
```
|
||||
|
||||
### If Not Set
|
||||
|
||||
1. **Get a key**: Go to https://dashboard.bfl.ai/get-started → Click **"Create Key"** → Select organization
|
||||
2. **Save to `.env`** (recommended for persistence):
|
||||
```bash
|
||||
echo 'BFL_API_KEY=bfl_your_key_here' >> .env
|
||||
echo '.env' >> .gitignore # Don't commit secrets
|
||||
```
|
||||
|
||||
See [references/api-key-setup.md](references/api-key-setup.md) for detailed setup instructions.
|
||||
|
||||
## Authentication
|
||||
|
||||
```bash
|
||||
x-key: YOUR_API_KEY
|
||||
```
|
||||
|
||||
## Basic Request Flow
|
||||
|
||||
```
|
||||
1. POST request to model endpoint
|
||||
└─> Response: { "polling_url": "..." }
|
||||
|
||||
2. GET polling_url (repeat until complete)
|
||||
└─> Response: { "status": "Pending" | "Ready" | "Error", ... }
|
||||
|
||||
3. When Ready, download result URL
|
||||
└─> URL expires in 10 minutes - download immediately
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- **Prompting best practices** (T2I, I2I, typography, colors): see the **flux-best-practices** skill
|
||||
- **Multi-reference patterns** (character consistency, style transfer, pose guidance): see `flux-best-practices/rules/multi-reference-editing.md`
|
||||
|
||||
## References
|
||||
|
||||
- [references/api-key-setup.md](references/api-key-setup.md) - **API key creation and configuration**
|
||||
- [references/endpoints.md](references/endpoints.md) - Complete endpoint documentation
|
||||
- [references/polling-patterns.md](references/polling-patterns.md) - Async polling implementation
|
||||
- [references/rate-limiting.md](references/rate-limiting.md) - Rate limit handling strategies
|
||||
- [references/error-handling.md](references/error-handling.md) - Error codes and recovery
|
||||
- [references/webhook-integration.md](references/webhook-integration.md) - Webhook setup and security
|
||||
|
||||
### Code Examples
|
||||
|
||||
> **Note:** cURL examples are preferred by default as they work universally without requiring Python or Node.js. Use language-specific clients when building production applications.
|
||||
|
||||
- [references/code-examples/curl-examples.sh](references/code-examples/curl-examples.sh) - **cURL examples (recommended)**
|
||||
- [references/code-examples/python-client.py](references/code-examples/python-client.py) - Python client
|
||||
- [references/code-examples/typescript-client.ts](references/code-examples/typescript-client.ts) - TypeScript client
|
||||
|
||||
## Quick Start Example
|
||||
|
||||
### 1. Submit Generation Request
|
||||
|
||||
```bash
|
||||
curl -s -X POST "https://api.bfl.ai/v1/flux-2-pro" \
|
||||
-H "x-key: $BFL_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"prompt": "A serene mountain landscape at sunset", "width": 1024, "height": 1024}'
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{ "id": "abc123", "polling_url": "https://api.bfl.ai/v1/get_result?id=abc123" }
|
||||
```
|
||||
|
||||
### 2. Poll for Result
|
||||
|
||||
```bash
|
||||
curl -s "POLLING_URL" -H "x-key: $BFL_API_KEY"
|
||||
```
|
||||
|
||||
Response when ready:
|
||||
|
||||
```json
|
||||
{ "status": "Ready", "result": { "sample": "https://...", "seed": 1234 } }
|
||||
```
|
||||
|
||||
### 3. Download Image
|
||||
|
||||
```bash
|
||||
curl -s -o output.png "IMAGE_URL"
|
||||
```
|
||||
|
||||
> **Tip:** Result URLs expire in 10 minutes. Download immediately after status becomes `Ready`.
|
||||
|
||||
### 4. Multi-Reference Example
|
||||
|
||||
Combine elements from multiple images:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "https://api.bfl.ai/v1/flux-2-pro" \
|
||||
-H "x-key: $BFL_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "The cat from image 1 sitting in the cozy room from image 2",
|
||||
"input_image": "https://example.com/cat.jpg",
|
||||
"input_image_2": "https://example.com/room.jpg",
|
||||
"width": 1024,
|
||||
"height": 1024
|
||||
}'
|
||||
```
|
||||
|
||||
Reference images by number in your prompt. See [Multi-Reference I2I](#multi-reference-i2i) for limits and patterns.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: api-key-setup
|
||||
description: How to obtain and configure a BFL API key
|
||||
---
|
||||
|
||||
# API Key Setup
|
||||
|
||||
> **Important:** Always verify your API key before attempting image generation. Missing or invalid keys result in "Not authenticated" errors.
|
||||
|
||||
## Quick Validation
|
||||
|
||||
Run this first to check if your key is configured and valid:
|
||||
|
||||
```bash
|
||||
# Check if key is set
|
||||
[ -z "$BFL_API_KEY" ] && echo "Error: BFL_API_KEY not set" || echo "OK: Key configured"
|
||||
```
|
||||
|
||||
If not set, follow the steps below.
|
||||
|
||||
## Get a Key
|
||||
|
||||
1. Go to **https://dashboard.bfl.ai/get-started**
|
||||
2. Click **"Create Key"**
|
||||
3. Select organization (ask user if multiple options)
|
||||
4. Copy the key (starts with `bfl_`)
|
||||
|
||||
## For Agents Making Direct API Calls
|
||||
|
||||
When `BFL_API_KEY` is not set in the current session:
|
||||
|
||||
1. **Check for existing `.env`**:
|
||||
```bash
|
||||
grep BFL_API_KEY .env 2>/dev/null
|
||||
```
|
||||
|
||||
2. **If found, export it**:
|
||||
```bash
|
||||
export BFL_API_KEY=$(grep BFL_API_KEY .env | cut -d '=' -f2)
|
||||
```
|
||||
|
||||
3. **If not found, ask the user** for their key:
|
||||
> "I need a BFL API key to generate images. Please:
|
||||
> 1. Go to https://dashboard.bfl.ai/get-started
|
||||
> 2. Click 'Create Key' and copy it
|
||||
> 3. Paste it here"
|
||||
|
||||
4. **Save and export**:
|
||||
```bash
|
||||
echo 'BFL_API_KEY=bfl_provided_key' >> .env
|
||||
echo '.env' >> .gitignore
|
||||
export BFL_API_KEY=bfl_provided_key
|
||||
```
|
||||
|
||||
Now `$BFL_API_KEY` is available for direct curl/API calls in the session.
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# BFL FLUX API - cURL Examples
|
||||
#
|
||||
# These examples demonstrate how to use the BFL FLUX API with cURL.
|
||||
# Replace YOUR_API_KEY with your actual API key from https://dashboard.bfl.ai
|
||||
#
|
||||
|
||||
API_KEY="${BFL_API_KEY:-YOUR_API_KEY}"
|
||||
BASE_URL="https://api.bfl.ai"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# FIRST: Verify API Key is Set
|
||||
# -----------------------------------------------------------------------------
|
||||
# Always check this before making requests to avoid "Not authenticated" errors
|
||||
|
||||
if [ "$API_KEY" = "YOUR_API_KEY" ] || [ -z "$API_KEY" ]; then
|
||||
echo "Error: BFL_API_KEY not set"
|
||||
echo ""
|
||||
echo "To fix:"
|
||||
echo " 1. Get a key at https://dashboard.bfl.ai/get-started"
|
||||
echo " 2. Run: export BFL_API_KEY=your_key_here"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: API key configured"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Example 1: Basic Image Generation with FLUX.2 Pro
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
echo "=== Submitting generation request ==="
|
||||
|
||||
RESPONSE=$(curl -s -X POST "${BASE_URL}/v1/flux-2-pro" \
|
||||
-H "x-key: ${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "A serene mountain landscape at golden hour, dramatic lighting",
|
||||
"width": 1024,
|
||||
"height": 1024
|
||||
}')
|
||||
|
||||
echo "Response: ${RESPONSE}"
|
||||
|
||||
# Extract polling URL
|
||||
POLLING_URL=$(echo "${RESPONSE}" | grep -o '"polling_url":"[^"]*"' | cut -d'"' -f4)
|
||||
echo "Polling URL: ${POLLING_URL}"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Example 2: Poll for Result
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "=== Polling for result ==="
|
||||
|
||||
while true; do
|
||||
RESULT=$(curl -s "${POLLING_URL}" -H "x-key: ${API_KEY}")
|
||||
STATUS=$(echo "${RESULT}" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
|
||||
|
||||
echo "Status: ${STATUS}"
|
||||
|
||||
if [ "${STATUS}" = "Ready" ]; then
|
||||
IMAGE_URL=$(echo "${RESULT}" | grep -o '"sample":"[^"]*"' | cut -d'"' -f4)
|
||||
echo "Image URL: ${IMAGE_URL}"
|
||||
break
|
||||
elif [ "${STATUS}" = "Error" ]; then
|
||||
echo "Generation failed!"
|
||||
echo "${RESULT}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Example 3: Download the Image
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
echo ""
|
||||
echo "=== Downloading image ==="
|
||||
curl -s -o output.png "${IMAGE_URL}"
|
||||
echo "Saved to output.png"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ONE-LINER EXAMPLES (for quick reference)
|
||||
# =============================================================================
|
||||
|
||||
# Submit request (returns polling_url):
|
||||
# curl -s -X POST "https://api.bfl.ai/v1/flux-2-pro" \
|
||||
# -H "x-key: YOUR_API_KEY" \
|
||||
# -H "Content-Type: application/json" \
|
||||
# -d '{"prompt": "A red apple", "width": 1024, "height": 1024}'
|
||||
|
||||
# Poll for result (replace POLLING_URL):
|
||||
# curl -s "POLLING_URL" -H "x-key: YOUR_API_KEY"
|
||||
|
||||
# Download image (replace IMAGE_URL):
|
||||
# curl -s -o output.png "IMAGE_URL"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# IMAGE-TO-IMAGE EDITING
|
||||
# =============================================================================
|
||||
# Preferred: Pass image URLs directly - simpler and more convenient than base64.
|
||||
# The API fetches URLs automatically. Both URL and base64 work.
|
||||
|
||||
echo ""
|
||||
echo "=== Image-to-Image Edit Example ==="
|
||||
|
||||
# Edit an image using its URL directly
|
||||
I2I_RESPONSE=$(curl -s -X POST "${BASE_URL}/v1/flux-2-pro" \
|
||||
-H "x-key: ${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "Change the background to a sunset beach",
|
||||
"input_image": "https://example.com/photo.jpg"
|
||||
}')
|
||||
|
||||
echo "I2I Response: ${I2I_RESPONSE}"
|
||||
|
||||
# Multi-reference example (combine elements from multiple images)
|
||||
# curl -s -X POST "${BASE_URL}/v1/flux-2-max" \
|
||||
# -H "x-key: ${API_KEY}" \
|
||||
# -H "Content-Type: application/json" \
|
||||
# -d '{
|
||||
# "prompt": "Person from image 1 wearing outfit from image 2 in setting from image 3",
|
||||
# "input_image": "https://example.com/person.jpg",
|
||||
# "input_image_2": "https://example.com/outfit.jpg",
|
||||
# "input_image_3": "https://example.com/location.jpg"
|
||||
# }'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MODEL ENDPOINT EXAMPLES
|
||||
# =============================================================================
|
||||
|
||||
# FLUX.2 [klein] 4B - Fastest
|
||||
# curl -s -X POST "https://api.bfl.ai/v1/flux-2-klein-4b" ...
|
||||
|
||||
# FLUX.2 [klein] 9B - Fast with better quality
|
||||
# curl -s -X POST "https://api.bfl.ai/v1/flux-2-klein-9b" ...
|
||||
|
||||
# FLUX.2 [pro] - Production balanced
|
||||
# curl -s -X POST "https://api.bfl.ai/v1/flux-2-pro" ...
|
||||
|
||||
# FLUX.2 [max] - Highest quality
|
||||
# curl -s -X POST "https://api.bfl.ai/v1/flux-2-max" ...
|
||||
|
||||
# FLUX.2 [flex] - Best for typography
|
||||
# curl -s -X POST "https://api.bfl.ai/v1/flux-2-flex" ...
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# REGIONAL ENDPOINTS
|
||||
# =============================================================================
|
||||
|
||||
# Global (default): https://api.bfl.ai
|
||||
# EU (GDPR): https://api.eu.bfl.ai
|
||||
# US: https://api.us.bfl.ai
|
||||
@@ -0,0 +1,476 @@
|
||||
"""
|
||||
BFL FLUX API Python Client
|
||||
|
||||
A complete, production-ready Python client for the BFL FLUX API.
|
||||
Includes rate limiting, retry logic, webhook support, and async operations.
|
||||
|
||||
Usage:
|
||||
from bfl_client import BFLClient
|
||||
|
||||
client = BFLClient("your-api-key")
|
||||
result = client.generate("flux-2-pro", "A beautiful sunset")
|
||||
print(f"Image URL: {result['url']}")
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import hmac
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List, Callable
|
||||
from dataclasses import dataclass
|
||||
from threading import Semaphore, Lock
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import requests
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# --- Exceptions ---
|
||||
|
||||
class BFLError(Exception):
|
||||
"""Base exception for BFL API errors."""
|
||||
def __init__(self, message: str, status_code: int = None, error_code: str = None):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.error_code = error_code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class AuthenticationError(BFLError):
|
||||
"""API key or authentication issue."""
|
||||
pass
|
||||
|
||||
|
||||
class InsufficientCreditsError(BFLError):
|
||||
"""Account needs more credits."""
|
||||
pass
|
||||
|
||||
|
||||
class RateLimitError(BFLError):
|
||||
"""Too many concurrent requests."""
|
||||
def __init__(self, message: str, retry_after: int = 5):
|
||||
super().__init__(message, 429, "rate_limit_exceeded")
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
class ValidationError(BFLError):
|
||||
"""Invalid request parameters."""
|
||||
pass
|
||||
|
||||
|
||||
class GenerationError(BFLError):
|
||||
"""Generation failed."""
|
||||
pass
|
||||
|
||||
|
||||
# --- Data Classes ---
|
||||
|
||||
@dataclass
|
||||
class GenerationResult:
|
||||
"""Result of a successful generation."""
|
||||
id: str
|
||||
url: str
|
||||
width: int
|
||||
height: int
|
||||
raw: Dict[str, Any]
|
||||
|
||||
|
||||
# --- Client ---
|
||||
|
||||
class BFLClient:
|
||||
"""
|
||||
Production-ready BFL FLUX API client.
|
||||
|
||||
Features:
|
||||
- Rate limiting with semaphore
|
||||
- Automatic retries with exponential backoff
|
||||
- Webhook support
|
||||
- Batch processing
|
||||
- Async operations
|
||||
|
||||
Example:
|
||||
client = BFLClient("your-api-key")
|
||||
result = client.generate("flux-2-pro", "A sunset over mountains")
|
||||
client.download(result.url, "sunset.png")
|
||||
"""
|
||||
|
||||
BASE_URLS = {
|
||||
"global": "https://api.bfl.ai",
|
||||
"eu": "https://api.eu.bfl.ai",
|
||||
"us": "https://api.us.bfl.ai",
|
||||
}
|
||||
|
||||
RATE_LIMITS = {
|
||||
"default": 24,
|
||||
"flux-kontext-max": 6,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
region: str = "global",
|
||||
max_concurrent: int = None,
|
||||
timeout: int = 120,
|
||||
):
|
||||
"""
|
||||
Initialize the BFL client.
|
||||
|
||||
Args:
|
||||
api_key: Your BFL API key
|
||||
region: API region ("global", "eu", "us")
|
||||
max_concurrent: Max concurrent requests (default: 24)
|
||||
timeout: Default polling timeout in seconds
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.base_url = self.BASE_URLS.get(region, self.BASE_URLS["global"])
|
||||
self.timeout = timeout
|
||||
self.max_concurrent = max_concurrent or self.RATE_LIMITS["default"]
|
||||
self.semaphore = Semaphore(self.max_concurrent)
|
||||
|
||||
self.headers = {
|
||||
"x-key": api_key,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def generate(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str,
|
||||
width: int = 1024,
|
||||
height: int = 1024,
|
||||
seed: int = None,
|
||||
safety_tolerance: int = 2,
|
||||
output_format: str = "png",
|
||||
webhook_url: str = None,
|
||||
webhook_secret: str = None,
|
||||
timeout: int = None,
|
||||
**kwargs,
|
||||
) -> GenerationResult:
|
||||
"""
|
||||
Generate an image from a text prompt.
|
||||
|
||||
Args:
|
||||
model: Model to use (e.g., "flux-2-pro", "flux-2-max")
|
||||
prompt: Text description of the image
|
||||
width: Image width (multiple of 16)
|
||||
height: Image height (multiple of 16)
|
||||
seed: Random seed for reproducibility
|
||||
safety_tolerance: 0 (strict) to 5 (permissive)
|
||||
output_format: "png" or "jpeg"
|
||||
webhook_url: URL for async notification
|
||||
webhook_secret: Secret for webhook signature
|
||||
timeout: Polling timeout override
|
||||
**kwargs: Additional model-specific parameters
|
||||
|
||||
Returns:
|
||||
GenerationResult with image URL and metadata
|
||||
"""
|
||||
# Validate dimensions
|
||||
self._validate_dimensions(width, height)
|
||||
|
||||
# Build payload
|
||||
payload = {
|
||||
"prompt": prompt,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"safety_tolerance": safety_tolerance,
|
||||
"output_format": output_format,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
if seed is not None:
|
||||
payload["seed"] = seed
|
||||
if webhook_url:
|
||||
payload["webhook_url"] = webhook_url
|
||||
if webhook_secret:
|
||||
payload["webhook_secret"] = webhook_secret
|
||||
|
||||
# Rate-limited request
|
||||
with self.semaphore:
|
||||
return self._submit_and_poll(model, payload, timeout or self.timeout)
|
||||
|
||||
def generate_i2i(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str,
|
||||
input_image: str,
|
||||
additional_images: List[str] = None,
|
||||
**kwargs,
|
||||
) -> GenerationResult:
|
||||
"""
|
||||
Generate an image from another image (image-to-image).
|
||||
|
||||
Preferred: Pass image URLs directly - simpler and more convenient than base64.
|
||||
The API fetches URLs automatically. Both URL and base64 work.
|
||||
|
||||
Args:
|
||||
model: Model to use (e.g., "flux-2-pro", "flux-2-max")
|
||||
prompt: Edit instructions
|
||||
input_image: Image URL (preferred) or base64
|
||||
additional_images: List of additional reference image URLs or base64
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
GenerationResult with edited image
|
||||
|
||||
Example:
|
||||
result = client.generate_i2i(
|
||||
"flux-2-pro",
|
||||
"Change the background to a sunset",
|
||||
"https://example.com/photo.jpg" # URL is simpler!
|
||||
)
|
||||
"""
|
||||
payload = {
|
||||
"prompt": prompt,
|
||||
"input_image": input_image,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
# Add additional images
|
||||
if additional_images:
|
||||
for i, img in enumerate(additional_images[:7], start=2):
|
||||
payload[f"input_image_{i}"] = img
|
||||
|
||||
with self.semaphore:
|
||||
return self._submit_and_poll(model, payload, self.timeout)
|
||||
|
||||
def generate_batch(
|
||||
self,
|
||||
model: str,
|
||||
prompts: List[str],
|
||||
max_workers: int = None,
|
||||
**kwargs,
|
||||
) -> List[GenerationResult]:
|
||||
"""
|
||||
Generate multiple images concurrently.
|
||||
|
||||
Args:
|
||||
model: Model to use
|
||||
prompts: List of prompts
|
||||
max_workers: Number of concurrent workers
|
||||
**kwargs: Parameters applied to all generations
|
||||
|
||||
Returns:
|
||||
List of GenerationResult objects
|
||||
"""
|
||||
max_workers = max_workers or min(len(prompts), self.max_concurrent)
|
||||
results = []
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = {
|
||||
executor.submit(self.generate, model, prompt, **kwargs): prompt
|
||||
for prompt in prompts
|
||||
}
|
||||
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
result = future.result()
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Generation failed: {e}")
|
||||
results.append(None)
|
||||
|
||||
return results
|
||||
|
||||
def download(self, url: str, output_path: str) -> str:
|
||||
"""
|
||||
Download a generated image.
|
||||
|
||||
Args:
|
||||
url: Image URL (expires in 10 minutes)
|
||||
output_path: Local path to save the image
|
||||
|
||||
Returns:
|
||||
Path to saved file
|
||||
"""
|
||||
response = requests.get(url, timeout=60)
|
||||
response.raise_for_status()
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(response.content)
|
||||
|
||||
return output_path
|
||||
|
||||
def _submit_and_poll(
|
||||
self,
|
||||
model: str,
|
||||
payload: Dict[str, Any],
|
||||
timeout: int,
|
||||
) -> GenerationResult:
|
||||
"""Submit request and poll for result."""
|
||||
endpoint = f"{self.base_url}/v1/{model}"
|
||||
|
||||
# Submit with retry
|
||||
response = self._request_with_retry(
|
||||
"POST",
|
||||
endpoint,
|
||||
json=payload,
|
||||
)
|
||||
|
||||
polling_url = response["polling_url"]
|
||||
generation_id = response.get("id", polling_url.split("=")[-1])
|
||||
|
||||
# Poll for result
|
||||
result = self._poll(polling_url, timeout)
|
||||
|
||||
return GenerationResult(
|
||||
id=generation_id,
|
||||
url=result["sample"],
|
||||
width=result.get("width", payload.get("width")),
|
||||
height=result.get("height", payload.get("height")),
|
||||
raw=result,
|
||||
)
|
||||
|
||||
def _poll(self, polling_url: str, timeout: int) -> Dict[str, Any]:
|
||||
"""Poll until completion or timeout."""
|
||||
start_time = time.time()
|
||||
delay = 1.0
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
response = self._request_with_retry("GET", polling_url)
|
||||
|
||||
status = response.get("status")
|
||||
if status == "Ready":
|
||||
return response.get("result", response)
|
||||
elif status == "Error":
|
||||
error = response.get("error", "Generation failed")
|
||||
raise GenerationError(error)
|
||||
|
||||
# Exponential backoff (cap at 5 seconds)
|
||||
time.sleep(delay)
|
||||
delay = min(delay * 1.5, 5.0)
|
||||
|
||||
raise TimeoutError(f"Generation timed out after {timeout}s")
|
||||
|
||||
def _request_with_retry(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
max_retries: int = 3,
|
||||
**kwargs,
|
||||
) -> Dict[str, Any]:
|
||||
"""Make HTTP request with retry logic."""
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
response = requests.request(
|
||||
method,
|
||||
url,
|
||||
headers=self.headers,
|
||||
timeout=30,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return self._handle_response(response)
|
||||
|
||||
except RateLimitError as e:
|
||||
logger.warning(f"Rate limited, waiting {e.retry_after}s")
|
||||
time.sleep(e.retry_after * (attempt + 1))
|
||||
last_exception = e
|
||||
|
||||
except BFLError as e:
|
||||
if e.status_code and e.status_code >= 500:
|
||||
wait_time = 2 ** attempt
|
||||
logger.warning(f"Server error, retrying in {wait_time}s")
|
||||
time.sleep(wait_time)
|
||||
last_exception = e
|
||||
else:
|
||||
raise
|
||||
|
||||
raise last_exception
|
||||
|
||||
def _handle_response(self, response: requests.Response) -> Dict[str, Any]:
|
||||
"""Process API response and raise appropriate errors."""
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
try:
|
||||
error_data = response.json()
|
||||
except:
|
||||
error_data = {"message": response.text}
|
||||
|
||||
error_code = error_data.get("error", "unknown")
|
||||
message = error_data.get("message", "Unknown error")
|
||||
|
||||
if response.status_code == 401:
|
||||
raise AuthenticationError(message, 401, error_code)
|
||||
elif response.status_code == 402:
|
||||
raise InsufficientCreditsError(message, 402, error_code)
|
||||
elif response.status_code == 429:
|
||||
retry_after = int(response.headers.get("Retry-After", 5))
|
||||
raise RateLimitError(message, retry_after)
|
||||
elif response.status_code == 400:
|
||||
raise ValidationError(message, 400, error_code)
|
||||
else:
|
||||
raise BFLError(message, response.status_code, error_code)
|
||||
|
||||
def _validate_dimensions(self, width: int, height: int):
|
||||
"""Validate image dimensions."""
|
||||
if width % 16 != 0:
|
||||
raise ValidationError(f"Width {width} must be a multiple of 16")
|
||||
if height % 16 != 0:
|
||||
raise ValidationError(f"Height {height} must be a multiple of 16")
|
||||
if width * height > 4_000_000:
|
||||
raise ValidationError(f"Total pixels ({width}x{height}) exceeds 4MP limit")
|
||||
if width < 64 or height < 64:
|
||||
raise ValidationError("Minimum dimension is 64 pixels")
|
||||
|
||||
|
||||
# --- Webhook Verification ---
|
||||
|
||||
def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
|
||||
"""
|
||||
Verify a webhook signature from BFL.
|
||||
|
||||
Args:
|
||||
payload: Raw request body
|
||||
signature: X-BFL-Signature header value
|
||||
secret: Your webhook secret
|
||||
|
||||
Returns:
|
||||
True if signature is valid
|
||||
"""
|
||||
if not signature or not signature.startswith("sha256="):
|
||||
return False
|
||||
|
||||
expected = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
payload,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
provided = signature[7:] # Remove 'sha256=' prefix
|
||||
|
||||
return hmac.compare_digest(expected, provided)
|
||||
|
||||
|
||||
# --- Example Usage ---
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Get API key from environment
|
||||
api_key = os.environ.get("BFL_API_KEY")
|
||||
if not api_key:
|
||||
print("Set BFL_API_KEY environment variable")
|
||||
exit(1)
|
||||
|
||||
# Create client
|
||||
client = BFLClient(api_key)
|
||||
|
||||
# Generate a single image
|
||||
print("Generating image...")
|
||||
result = client.generate(
|
||||
model="flux-2-pro",
|
||||
prompt="A serene mountain landscape at golden hour, dramatic lighting",
|
||||
width=1024,
|
||||
height=1024,
|
||||
)
|
||||
print(f"Generated: {result.url}")
|
||||
|
||||
# Download the image
|
||||
client.download(result.url, "output.png")
|
||||
print("Saved to output.png")
|
||||
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* BFL FLUX API TypeScript Client
|
||||
*
|
||||
* A complete, production-ready TypeScript client for the BFL FLUX API.
|
||||
* Includes rate limiting, retry logic, webhook support, and async operations.
|
||||
*
|
||||
* Usage:
|
||||
* import { BFLClient } from './bfl-client';
|
||||
*
|
||||
* const client = new BFLClient('your-api-key');
|
||||
* const result = await client.generate('flux-2-pro', 'A beautiful sunset');
|
||||
* console.log(`Image URL: ${result.url}`);
|
||||
*/
|
||||
|
||||
import * as crypto from "crypto";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface GenerationResult {
|
||||
id: string;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GenerateOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
seed?: number;
|
||||
safetyTolerance?: number;
|
||||
outputFormat?: "png" | "jpeg";
|
||||
webhookUrl?: string;
|
||||
webhookSecret?: string;
|
||||
timeout?: number;
|
||||
steps?: number; // For flex model
|
||||
guidance?: number; // For flex model
|
||||
}
|
||||
|
||||
export interface I2IOptions extends GenerateOptions {
|
||||
additionalImages?: string[];
|
||||
}
|
||||
|
||||
export type Region = "global" | "eu" | "us";
|
||||
|
||||
// --- Errors ---
|
||||
|
||||
export class BFLError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode?: number,
|
||||
public errorCode?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "BFLError";
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthenticationError extends BFLError {
|
||||
constructor(message: string) {
|
||||
super(message, 401, "authentication_error");
|
||||
this.name = "AuthenticationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InsufficientCreditsError extends BFLError {
|
||||
constructor(message: string) {
|
||||
super(message, 402, "insufficient_credits");
|
||||
this.name = "InsufficientCreditsError";
|
||||
}
|
||||
}
|
||||
|
||||
export class RateLimitError extends BFLError {
|
||||
constructor(
|
||||
message: string,
|
||||
public retryAfter: number = 5
|
||||
) {
|
||||
super(message, 429, "rate_limit_exceeded");
|
||||
this.name = "RateLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends BFLError {
|
||||
constructor(message: string) {
|
||||
super(message, 400, "validation_error");
|
||||
this.name = "ValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class GenerationError extends BFLError {
|
||||
constructor(message: string) {
|
||||
super(message, undefined, "generation_error");
|
||||
this.name = "GenerationError";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rate Limiter ---
|
||||
|
||||
class Semaphore {
|
||||
private permits: number;
|
||||
private waiting: Array<() => void> = [];
|
||||
|
||||
constructor(permits: number) {
|
||||
this.permits = permits;
|
||||
}
|
||||
|
||||
async acquire(): Promise<void> {
|
||||
if (this.permits > 0) {
|
||||
this.permits--;
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.waiting.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
release(): void {
|
||||
if (this.waiting.length > 0) {
|
||||
const next = this.waiting.shift();
|
||||
next?.();
|
||||
} else {
|
||||
this.permits++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Client ---
|
||||
|
||||
export class BFLClient {
|
||||
private static readonly BASE_URLS: Record<Region, string> = {
|
||||
global: "https://api.bfl.ai",
|
||||
eu: "https://api.eu.bfl.ai",
|
||||
us: "https://api.us.bfl.ai",
|
||||
};
|
||||
|
||||
private static readonly RATE_LIMITS: Record<string, number> = {
|
||||
default: 24,
|
||||
"flux-kontext-max": 6,
|
||||
};
|
||||
|
||||
private readonly baseUrl: string;
|
||||
private readonly headers: Record<string, string>;
|
||||
private readonly timeout: number;
|
||||
private readonly semaphore: Semaphore;
|
||||
|
||||
/**
|
||||
* Create a new BFL client.
|
||||
*
|
||||
* @param apiKey - Your BFL API key
|
||||
* @param region - API region ("global", "eu", "us")
|
||||
* @param maxConcurrent - Max concurrent requests (default: 24)
|
||||
* @param timeout - Default polling timeout in milliseconds
|
||||
*/
|
||||
constructor(
|
||||
private readonly apiKey: string,
|
||||
region: Region = "global",
|
||||
maxConcurrent: number = 24,
|
||||
timeout: number = 120000
|
||||
) {
|
||||
this.baseUrl = BFLClient.BASE_URLS[region];
|
||||
this.timeout = timeout;
|
||||
this.semaphore = new Semaphore(maxConcurrent);
|
||||
|
||||
this.headers = {
|
||||
"x-key": apiKey,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an image from a text prompt.
|
||||
*/
|
||||
async generate(
|
||||
model: string,
|
||||
prompt: string,
|
||||
options: GenerateOptions = {}
|
||||
): Promise<GenerationResult> {
|
||||
const {
|
||||
width = 1024,
|
||||
height = 1024,
|
||||
seed,
|
||||
safetyTolerance = 2,
|
||||
outputFormat = "png",
|
||||
webhookUrl,
|
||||
webhookSecret,
|
||||
timeout = this.timeout,
|
||||
steps,
|
||||
guidance,
|
||||
} = options;
|
||||
|
||||
// Validate dimensions
|
||||
this.validateDimensions(width, height);
|
||||
|
||||
// Build payload
|
||||
const payload: Record<string, unknown> = {
|
||||
prompt,
|
||||
width,
|
||||
height,
|
||||
safety_tolerance: safetyTolerance,
|
||||
output_format: outputFormat,
|
||||
};
|
||||
|
||||
if (seed !== undefined) payload.seed = seed;
|
||||
if (webhookUrl) payload.webhook_url = webhookUrl;
|
||||
if (webhookSecret) payload.webhook_secret = webhookSecret;
|
||||
if (steps !== undefined) payload.steps = steps;
|
||||
if (guidance !== undefined) payload.guidance = guidance;
|
||||
|
||||
// Rate-limited request
|
||||
await this.semaphore.acquire();
|
||||
try {
|
||||
return await this.submitAndPoll(model, payload, timeout);
|
||||
} finally {
|
||||
this.semaphore.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an image from another image (image-to-image).
|
||||
*
|
||||
* Preferred: Pass image URLs directly - simpler and more convenient than base64.
|
||||
* The API fetches URLs automatically. Both URL and base64 work.
|
||||
*
|
||||
* @param model - Model to use (e.g., "flux-2-pro", "flux-2-max")
|
||||
* @param prompt - Edit instructions
|
||||
* @param inputImage - Image URL (preferred) or base64
|
||||
* @param options - Additional options including more reference image URLs or base64
|
||||
*
|
||||
* @example
|
||||
* const result = await client.generateI2I(
|
||||
* "flux-2-pro",
|
||||
* "Change the background to a sunset",
|
||||
* "https://example.com/photo.jpg" // URL is simpler!
|
||||
* );
|
||||
*/
|
||||
async generateI2I(
|
||||
model: string,
|
||||
prompt: string,
|
||||
inputImage: string,
|
||||
options: I2IOptions = {}
|
||||
): Promise<GenerationResult> {
|
||||
const { additionalImages = [], ...rest } = options;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
prompt,
|
||||
input_image: inputImage,
|
||||
};
|
||||
|
||||
// Add additional images
|
||||
additionalImages.slice(0, 7).forEach((img, i) => {
|
||||
payload[`input_image_${i + 2}`] = img;
|
||||
});
|
||||
|
||||
await this.semaphore.acquire();
|
||||
try {
|
||||
return await this.submitAndPoll(model, payload, rest.timeout ?? this.timeout);
|
||||
} finally {
|
||||
this.semaphore.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate multiple images concurrently.
|
||||
*/
|
||||
async generateBatch(
|
||||
model: string,
|
||||
prompts: string[],
|
||||
options: GenerateOptions = {}
|
||||
): Promise<Array<GenerationResult | Error>> {
|
||||
const tasks = prompts.map((prompt) =>
|
||||
this.generate(model, prompt, options).catch((e) => e)
|
||||
);
|
||||
|
||||
return Promise.all(tasks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a generated image.
|
||||
*/
|
||||
async download(url: string): Promise<ArrayBuffer> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new BFLError(`Failed to download: ${response.status}`);
|
||||
}
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
|
||||
private async submitAndPoll(
|
||||
model: string,
|
||||
payload: Record<string, unknown>,
|
||||
timeout: number
|
||||
): Promise<GenerationResult> {
|
||||
const endpoint = `${this.baseUrl}/v1/${model}`;
|
||||
|
||||
// Submit request
|
||||
const submitResponse = await this.requestWithRetry("POST", endpoint, payload);
|
||||
|
||||
const pollingUrl = submitResponse.polling_url as string;
|
||||
const generationId =
|
||||
(submitResponse.id as string) ?? pollingUrl.split("=").pop() ?? "unknown";
|
||||
|
||||
// Poll for result
|
||||
const result = await this.poll(pollingUrl, timeout);
|
||||
|
||||
return {
|
||||
id: generationId,
|
||||
url: result.sample as string,
|
||||
width: (result.width as number) ?? (payload.width as number),
|
||||
height: (result.height as number) ?? (payload.height as number),
|
||||
raw: result,
|
||||
};
|
||||
}
|
||||
|
||||
private async poll(
|
||||
pollingUrl: string,
|
||||
timeout: number
|
||||
): Promise<Record<string, unknown>> {
|
||||
const startTime = Date.now();
|
||||
let delay = 1000;
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const response = await this.requestWithRetry("GET", pollingUrl);
|
||||
|
||||
const status = response.status as string;
|
||||
if (status === "Ready") {
|
||||
return (response.result as Record<string, unknown>) ?? response;
|
||||
} else if (status === "Error") {
|
||||
throw new GenerationError((response.error as string) ?? "Generation failed");
|
||||
}
|
||||
|
||||
// Exponential backoff (cap at 5 seconds)
|
||||
await this.sleep(delay);
|
||||
delay = Math.min(delay * 1.5, 5000);
|
||||
}
|
||||
|
||||
throw new Error(`Generation timed out after ${timeout}ms`);
|
||||
}
|
||||
|
||||
private async requestWithRetry(
|
||||
method: string,
|
||||
url: string,
|
||||
body?: Record<string, unknown>,
|
||||
maxRetries: number = 3
|
||||
): Promise<Record<string, unknown>> {
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: this.headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
return await this.handleResponse(response);
|
||||
} catch (e) {
|
||||
if (e instanceof RateLimitError) {
|
||||
console.warn(`Rate limited, waiting ${e.retryAfter}s`);
|
||||
await this.sleep(e.retryAfter * 1000 * (attempt + 1));
|
||||
lastError = e;
|
||||
} else if (e instanceof BFLError && e.statusCode && e.statusCode >= 500) {
|
||||
const waitTime = Math.pow(2, attempt) * 1000;
|
||||
console.warn(`Server error, retrying in ${waitTime}ms`);
|
||||
await this.sleep(waitTime);
|
||||
lastError = e;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("Max retries exceeded");
|
||||
}
|
||||
|
||||
private async handleResponse(response: Response): Promise<Record<string, unknown>> {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
let errorData: Record<string, unknown>;
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch {
|
||||
errorData = { message: await response.text() };
|
||||
}
|
||||
|
||||
const errorCode = (errorData.error as string) ?? "unknown";
|
||||
const message = (errorData.message as string) ?? "Unknown error";
|
||||
|
||||
switch (response.status) {
|
||||
case 401:
|
||||
throw new AuthenticationError(message);
|
||||
case 402:
|
||||
throw new InsufficientCreditsError(message);
|
||||
case 429:
|
||||
const retryAfter = parseInt(response.headers.get("Retry-After") ?? "5", 10);
|
||||
throw new RateLimitError(message, retryAfter);
|
||||
case 400:
|
||||
throw new ValidationError(message);
|
||||
default:
|
||||
throw new BFLError(message, response.status, errorCode);
|
||||
}
|
||||
}
|
||||
|
||||
private validateDimensions(width: number, height: number): void {
|
||||
if (width % 16 !== 0) {
|
||||
throw new ValidationError(`Width ${width} must be a multiple of 16`);
|
||||
}
|
||||
if (height % 16 !== 0) {
|
||||
throw new ValidationError(`Height ${height} must be a multiple of 16`);
|
||||
}
|
||||
if (width * height > 4_000_000) {
|
||||
throw new ValidationError(`Total pixels (${width}x${height}) exceeds 4MP limit`);
|
||||
}
|
||||
if (width < 64 || height < 64) {
|
||||
throw new ValidationError("Minimum dimension is 64 pixels");
|
||||
}
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Webhook Verification ---
|
||||
|
||||
/**
|
||||
* Verify a webhook signature from BFL.
|
||||
*
|
||||
* @param payload - Raw request body as string
|
||||
* @param signature - X-BFL-Signature header value
|
||||
* @param secret - Your webhook secret
|
||||
* @returns True if signature is valid
|
||||
*/
|
||||
export function verifyWebhookSignature(
|
||||
payload: string,
|
||||
signature: string,
|
||||
secret: string
|
||||
): boolean {
|
||||
if (!signature || !signature.startsWith("sha256=")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedSignature = crypto
|
||||
.createHmac("sha256", secret)
|
||||
.update(payload)
|
||||
.digest("hex");
|
||||
|
||||
const providedSignature = signature.slice(7); // Remove 'sha256=' prefix
|
||||
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(expectedSignature),
|
||||
Buffer.from(providedSignature)
|
||||
);
|
||||
}
|
||||
|
||||
// --- Example Usage ---
|
||||
|
||||
async function main() {
|
||||
const apiKey = process.env.BFL_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.error("Set BFL_API_KEY environment variable");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new BFLClient(apiKey);
|
||||
|
||||
console.log("Generating image...");
|
||||
const result = await client.generate("flux-2-pro", "A serene mountain landscape at golden hour", {
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
});
|
||||
|
||||
console.log(`Generated: ${result.url}`);
|
||||
console.log(`Image ID: ${result.id}`);
|
||||
}
|
||||
|
||||
// Run if executed directly
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,286 @@
|
||||
---
|
||||
name: endpoints
|
||||
description: Complete BFL API endpoint documentation
|
||||
---
|
||||
|
||||
# BFL API Endpoints
|
||||
|
||||
Complete reference for all BFL FLUX API endpoints.
|
||||
|
||||
## Base URLs
|
||||
|
||||
| Region | Endpoint | Use Case |
|
||||
| ------ | ----------------------- | ---------------------------------- |
|
||||
| Global | `https://api.bfl.ai` | Default, automatic failover |
|
||||
| EU | `https://api.eu.bfl.ai` | GDPR compliance, EU data residency |
|
||||
| US | `https://api.us.bfl.ai` | US data residency |
|
||||
|
||||
**Recommendation:** Use the global endpoint (`api.bfl.ai`) unless you have specific regional requirements.
|
||||
|
||||
## Authentication
|
||||
|
||||
All requests require the `x-key` header with your API key:
|
||||
|
||||
```bash
|
||||
x-key: YOUR_API_KEY
|
||||
```
|
||||
|
||||
## FLUX.2 Text-to-Image and Image-to-Image Endpoints
|
||||
|
||||
### FLUX.2 [klein] 4B
|
||||
|
||||
```
|
||||
POST /v1/flux-2-klein-4b
|
||||
```
|
||||
|
||||
Fastest generation, 4B parameters.
|
||||
|
||||
### FLUX.2 [klein] 9B
|
||||
|
||||
```
|
||||
POST /v1/flux-2-klein-9b
|
||||
```
|
||||
|
||||
Fast generation with better quality, 9B parameters.
|
||||
|
||||
### FLUX.2 [max]
|
||||
|
||||
```
|
||||
POST /v1/flux-2-max
|
||||
```
|
||||
|
||||
Highest quality, supports grounding search.
|
||||
|
||||
### FLUX.2 [pro]
|
||||
|
||||
```
|
||||
POST /v1/flux-2-pro
|
||||
```
|
||||
|
||||
Production balanced quality and speed.
|
||||
|
||||
### FLUX.2 [flex]
|
||||
|
||||
```
|
||||
POST /v1/flux-2-flex
|
||||
```
|
||||
|
||||
Typography optimized, adjustable steps/guidance.
|
||||
|
||||
## FLUX.1 Endpoints
|
||||
|
||||
### FLUX1.1 [pro]
|
||||
|
||||
```
|
||||
POST /v1/flux-pro-1.1
|
||||
```
|
||||
|
||||
Text-to-image generation.
|
||||
|
||||
### FLUX.1 Kontext
|
||||
|
||||
```
|
||||
POST /v1/flux-kontext
|
||||
```
|
||||
|
||||
### FLUX.1 Kontext Max
|
||||
|
||||
```
|
||||
POST /v1/flux-kontext-max
|
||||
```
|
||||
|
||||
### FLUX.1 Fill
|
||||
|
||||
```
|
||||
POST /v1/flux-fill
|
||||
```
|
||||
|
||||
Inpainting and object removal - you can achieve inpainting and object removal with specific prompting style with FLUX.2 models for better performance.
|
||||
|
||||
## Common Request Parameters
|
||||
|
||||
### Text-to-Image (T2I)
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| ------------------ | ------- | -------- | -------------------------------------------- |
|
||||
| `prompt` | string | Yes | Text description (up to 32K tokens) |
|
||||
| `width` | integer | No | Image width (multiple of 16, max 4MP total) |
|
||||
| `height` | integer | No | Image height (multiple of 16, max 4MP total) |
|
||||
| `seed` | integer | No | Random seed for reproducibility |
|
||||
| `safety_tolerance` | integer | No | 0 (strict) to 5 (permissive), default 2 |
|
||||
| `output_format` | string | No | "jpeg" or "png", default "jpeg" |
|
||||
| `webhook_url` | string | No | URL for async notification |
|
||||
| `webhook_secret` | string | No | Secret for webhook signature |
|
||||
|
||||
### Image-to-Image (I2I)
|
||||
|
||||
> **Important:** All FLUX.2 models (klein, pro, max, flex) support image-to-image editing via the `input_image` parameter. FLUX.2 is recommended over FLUX.1 Kontext for editing.
|
||||
|
||||
> **Preferred: Use URLs directly** - The API fetches URLs automatically, which is simpler and more convenient than downloading and encoding to base64. Both URL and base64 work, but URLs are recommended when available.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------------------------------- | ------- | -------- | -------------------------------------------------------------- |
|
||||
| `prompt` | string | Yes | Edit instruction |
|
||||
| `input_image` | string | Yes | **URL (preferred)** or base64 - API fetches URLs automatically |
|
||||
| `input_image_2` - `input_image_8` | string | No | Additional reference URLs or base64 |
|
||||
| `width` | integer | No | Output width |
|
||||
| `height` | integer | No | Output height |
|
||||
|
||||
### FLUX.2 [flex] Specific
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
| ---------- | ------- | ------- | ----------------------- |
|
||||
| `steps` | integer | 50 | Inference steps (1-50) |
|
||||
| `guidance` | float | 4.5 | Guidance scale (1.5-10) |
|
||||
|
||||
## Resolution Constraints
|
||||
|
||||
- **Minimum:** 64x64 pixels
|
||||
- **Maximum:** 4MP total (width x height)
|
||||
- **Multiple of:** 16 (both dimensions)
|
||||
|
||||
### Common Resolutions
|
||||
|
||||
| Aspect Ratio | Resolution | Megapixels |
|
||||
| --------------- | ---------- | ---------- |
|
||||
| 1:1 (Square) | 1024x1024 | 1.05 MP |
|
||||
| 16:9 (Wide) | 1920x1080 | 2.07 MP |
|
||||
| 9:16 (Portrait) | 1080x1920 | 2.07 MP |
|
||||
| 4:3 (Classic) | 1536x1152 | 1.77 MP |
|
||||
| 2:1 (Panorama) | 2048x1024 | 2.10 MP |
|
||||
|
||||
## Example Requests
|
||||
|
||||
### Basic T2I Request
|
||||
|
||||
```bash
|
||||
curl -X POST "https://api.bfl.ai/v1/flux-2-pro" \
|
||||
-H "x-key: YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "A serene mountain landscape at golden hour",
|
||||
"width": 1024,
|
||||
"height": 1024
|
||||
}'
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "gen_abc123xyz",
|
||||
"polling_url": "https://api.bfl.ai/v1/get_result?id=gen_abc123xyz"
|
||||
}
|
||||
```
|
||||
|
||||
### T2I with All Options
|
||||
|
||||
```bash
|
||||
curl -X POST "https://api.bfl.ai/v1/flux-2-max" \
|
||||
-H "x-key: YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "Professional headshot of a business executive",
|
||||
"width": 1024,
|
||||
"height": 1280,
|
||||
"seed": 42,
|
||||
"safety_tolerance": 2,
|
||||
"output_format": "png",
|
||||
"webhook_url": "https://your-server.com/webhook",
|
||||
"webhook_secret": "your-secret-key"
|
||||
}'
|
||||
```
|
||||
|
||||
### I2I Request (FLUX.2 - Recommended)
|
||||
|
||||
Edit images using any FLUX.2 model by passing the source image URL directly:
|
||||
|
||||
```bash
|
||||
curl -X POST "https://api.bfl.ai/v1/flux-2-klein-9b" \
|
||||
-H "x-key: YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "Change the floor color to light blue",
|
||||
"input_image": "https://example.com/room-photo.jpg"
|
||||
}'
|
||||
```
|
||||
|
||||
For higher quality edits, use FLUX.2 [pro] or [max]:
|
||||
|
||||
```bash
|
||||
curl -X POST "https://api.bfl.ai/v1/flux-2-pro" \
|
||||
-H "x-key: YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "Change the background to a beach sunset",
|
||||
"input_image": "https://example.com/portrait.jpg"
|
||||
}'
|
||||
```
|
||||
|
||||
### Multi-Reference I2I (FLUX.2)
|
||||
|
||||
```bash
|
||||
curl -X POST "https://api.bfl.ai/v1/flux-2-max" \
|
||||
-H "x-key: YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "Person from image 1 wearing outfit from image 2 in setting from image 3",
|
||||
"input_image": "https://example.com/person.jpg",
|
||||
"input_image_2": "https://example.com/outfit.jpg",
|
||||
"input_image_3": "https://example.com/location.jpg"
|
||||
}'
|
||||
```
|
||||
|
||||
### FLUX.2 [flex] with Custom Steps
|
||||
|
||||
```bash
|
||||
curl -X POST "https://api.bfl.ai/v1/flux-2-flex" \
|
||||
-H "x-key: YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "A poster with text \"SUMMER SALE\" in bold typography",
|
||||
"steps": 50,
|
||||
"guidance": 7.0
|
||||
}'
|
||||
```
|
||||
|
||||
## Polling Endpoint
|
||||
|
||||
### Get Result
|
||||
|
||||
```
|
||||
GET /v1/get_result?id={generation_id}
|
||||
```
|
||||
|
||||
### Response States
|
||||
|
||||
```json
|
||||
// Pending
|
||||
{ "status": "Pending" }
|
||||
|
||||
// Ready
|
||||
{
|
||||
"status": "Ready",
|
||||
"result": {
|
||||
"sample": "https://bfldeliveryprod.blob.core.windows.net/results/...",
|
||||
"prompt": "...",
|
||||
"seed": 1234567890
|
||||
}
|
||||
}
|
||||
|
||||
// Error
|
||||
{
|
||||
"status": "Error",
|
||||
"error": "Error description"
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Status Code | Meaning | Action |
|
||||
| ----------- | ---------------- | ------------------ |
|
||||
| 400 | Bad Request | Check parameters |
|
||||
| 401 | Unauthorized | Verify API key |
|
||||
| 402 | Payment Required | Add credits |
|
||||
| 429 | Rate Limited | Implement backoff |
|
||||
| 500 | Server Error | Retry with backoff |
|
||||
@@ -0,0 +1,334 @@
|
||||
---
|
||||
name: error-handling
|
||||
description: Error codes and recovery strategies for BFL API
|
||||
---
|
||||
|
||||
# Error Handling
|
||||
|
||||
Comprehensive guide to handling errors from the BFL API.
|
||||
|
||||
## HTTP Status Codes
|
||||
|
||||
| Code | Meaning | Cause | Action |
|
||||
|------|---------|-------|--------|
|
||||
| 200 | OK | Request successful | Process response |
|
||||
| 400 | Bad Request | Invalid parameters | Check request format |
|
||||
| 401 | Unauthorized | Invalid/missing API key | Verify credentials |
|
||||
| 402 | Payment Required | Insufficient credits | Add credits to account |
|
||||
| 403 | Forbidden | Access denied | Check permissions |
|
||||
| 404 | Not Found | Invalid endpoint | Verify URL |
|
||||
| 429 | Too Many Requests | Rate limited | Implement backoff |
|
||||
| 500 | Internal Server Error | Server issue | Retry with backoff |
|
||||
| 502 | Bad Gateway | Network issue | Retry with backoff |
|
||||
| 503 | Service Unavailable | Temporary outage | Retry with backoff |
|
||||
|
||||
## Error Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "error_code",
|
||||
"message": "Human-readable description",
|
||||
"details": {
|
||||
"field": "specific field info"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Errors and Solutions
|
||||
|
||||
### Authentication Errors (401)
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "invalid_api_key",
|
||||
"message": "The provided API key is invalid or expired"
|
||||
}
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
```python
|
||||
def verify_api_key(api_key):
|
||||
if not api_key:
|
||||
raise ValueError("API key is required")
|
||||
if not api_key.startswith("bfl_"):
|
||||
raise ValueError("Invalid API key format")
|
||||
```
|
||||
|
||||
### Insufficient Credits (402)
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "insufficient_credits",
|
||||
"message": "Your account does not have enough credits"
|
||||
}
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
```python
|
||||
def handle_payment_error(response):
|
||||
if response.status_code == 402:
|
||||
# Log and alert
|
||||
logging.error("Insufficient credits - add funds")
|
||||
# Optionally pause operations
|
||||
raise InsufficientCreditsError("Add credits to continue")
|
||||
```
|
||||
|
||||
### Rate Limiting (429)
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "rate_limit_exceeded",
|
||||
"message": "Too many concurrent requests",
|
||||
"retry_after": 5
|
||||
}
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
```python
|
||||
def handle_rate_limit(response):
|
||||
if response.status_code == 429:
|
||||
retry_after = int(response.headers.get('Retry-After', 5))
|
||||
time.sleep(retry_after)
|
||||
return True # Signal to retry
|
||||
return False
|
||||
```
|
||||
|
||||
### Validation Errors (400)
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "validation_error",
|
||||
"message": "Invalid request parameters",
|
||||
"details": {
|
||||
"width": "Must be a multiple of 16",
|
||||
"prompt": "Cannot be empty"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
```python
|
||||
def validate_request(prompt, width, height):
|
||||
errors = []
|
||||
|
||||
if not prompt or not prompt.strip():
|
||||
errors.append("Prompt cannot be empty")
|
||||
|
||||
if width % 16 != 0:
|
||||
errors.append(f"Width {width} must be multiple of 16")
|
||||
|
||||
if height % 16 != 0:
|
||||
errors.append(f"Height {height} must be multiple of 16")
|
||||
|
||||
if width * height > 4_000_000:
|
||||
errors.append("Total pixels cannot exceed 4MP")
|
||||
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
```
|
||||
|
||||
### Generation Failures
|
||||
|
||||
Failures during polling:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "Error",
|
||||
"error": "content_policy_violation",
|
||||
"message": "The prompt violated content policy"
|
||||
}
|
||||
```
|
||||
|
||||
**Common failure reasons:**
|
||||
- `content_policy_violation` - Prompt/image flagged by safety
|
||||
- `generation_timeout` - Took too long to generate
|
||||
- `internal_error` - Server-side issue
|
||||
- `invalid_image` - Input image couldn't be processed
|
||||
|
||||
## Retry Strategy
|
||||
|
||||
```python
|
||||
import time
|
||||
import random
|
||||
|
||||
class RetryableError(Exception):
|
||||
"""Errors that can be retried."""
|
||||
pass
|
||||
|
||||
class NonRetryableError(Exception):
|
||||
"""Errors that should not be retried."""
|
||||
pass
|
||||
|
||||
def classify_error(status_code, error_code):
|
||||
"""Determine if error is retryable."""
|
||||
# Retryable
|
||||
if status_code in [429, 500, 502, 503]:
|
||||
return RetryableError
|
||||
|
||||
# Non-retryable
|
||||
if status_code in [400, 401, 402, 403]:
|
||||
return NonRetryableError
|
||||
|
||||
# Generation failures
|
||||
if error_code in ['generation_timeout', 'internal_error']:
|
||||
return RetryableError
|
||||
|
||||
if error_code in ['content_policy_violation', 'invalid_image']:
|
||||
return NonRetryableError
|
||||
|
||||
return RetryableError # Default to retryable
|
||||
|
||||
def make_request_with_retry(func, max_retries=3):
|
||||
"""Execute function with retry logic."""
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return func()
|
||||
except RetryableError as e:
|
||||
last_exception = e
|
||||
wait_time = (2 ** attempt) + random.uniform(0, 1)
|
||||
print(f"Attempt {attempt + 1} failed, retrying in {wait_time:.1f}s")
|
||||
time.sleep(wait_time)
|
||||
except NonRetryableError:
|
||||
raise # Don't retry
|
||||
|
||||
raise last_exception
|
||||
```
|
||||
|
||||
## Comprehensive Error Handler
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
class BFLError(Exception):
|
||||
"""Base exception for BFL API errors."""
|
||||
def __init__(self, message, status_code=None, error_code=None):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.error_code = error_code
|
||||
super().__init__(message)
|
||||
|
||||
class AuthenticationError(BFLError):
|
||||
"""API key or authentication issue."""
|
||||
pass
|
||||
|
||||
class InsufficientCreditsError(BFLError):
|
||||
"""Account needs more credits."""
|
||||
pass
|
||||
|
||||
class RateLimitError(BFLError):
|
||||
"""Too many concurrent requests."""
|
||||
def __init__(self, message, retry_after=5):
|
||||
super().__init__(message, 429, "rate_limit_exceeded")
|
||||
self.retry_after = retry_after
|
||||
|
||||
class ValidationError(BFLError):
|
||||
"""Invalid request parameters."""
|
||||
pass
|
||||
|
||||
class GenerationError(BFLError):
|
||||
"""Generation failed."""
|
||||
pass
|
||||
|
||||
def handle_response(response):
|
||||
"""Process API response and raise appropriate errors."""
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
try:
|
||||
error_data = response.json()
|
||||
except:
|
||||
error_data = {"message": response.text}
|
||||
|
||||
error_code = error_data.get("error", "unknown")
|
||||
message = error_data.get("message", "Unknown error")
|
||||
|
||||
if response.status_code == 401:
|
||||
raise AuthenticationError(message, 401, error_code)
|
||||
|
||||
if response.status_code == 402:
|
||||
raise InsufficientCreditsError(message, 402, error_code)
|
||||
|
||||
if response.status_code == 429:
|
||||
retry_after = int(response.headers.get('Retry-After', 5))
|
||||
raise RateLimitError(message, retry_after)
|
||||
|
||||
if response.status_code == 400:
|
||||
raise ValidationError(message, 400, error_code)
|
||||
|
||||
if response.status_code >= 500:
|
||||
raise BFLError(f"Server error: {message}", response.status_code, error_code)
|
||||
|
||||
raise BFLError(message, response.status_code, error_code)
|
||||
```
|
||||
|
||||
## Logging Best Practices
|
||||
|
||||
```python
|
||||
import logging
|
||||
import json
|
||||
|
||||
def setup_logging():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
def log_request(endpoint, payload):
|
||||
logging.info(f"Request: POST {endpoint}")
|
||||
logging.debug(f"Payload: {json.dumps(payload, indent=2)}")
|
||||
|
||||
def log_error(error, context=None):
|
||||
logging.error(f"Error: {error}")
|
||||
if context:
|
||||
logging.error(f"Context: {context}")
|
||||
|
||||
def log_generation_failure(status, error, prompt):
|
||||
logging.warning(f"Generation failed: {error}")
|
||||
logging.debug(f"Failed prompt: {prompt[:100]}...")
|
||||
```
|
||||
|
||||
## Circuit Breaker Pattern
|
||||
|
||||
For production systems:
|
||||
|
||||
```python
|
||||
import time
|
||||
from threading import Lock
|
||||
|
||||
class CircuitBreaker:
|
||||
def __init__(self, failure_threshold=5, reset_timeout=60):
|
||||
self.failure_threshold = failure_threshold
|
||||
self.reset_timeout = reset_timeout
|
||||
self.failures = 0
|
||||
self.last_failure_time = None
|
||||
self.state = "closed" # closed, open, half-open
|
||||
self.lock = Lock()
|
||||
|
||||
def record_success(self):
|
||||
with self.lock:
|
||||
self.failures = 0
|
||||
self.state = "closed"
|
||||
|
||||
def record_failure(self):
|
||||
with self.lock:
|
||||
self.failures += 1
|
||||
self.last_failure_time = time.time()
|
||||
if self.failures >= self.failure_threshold:
|
||||
self.state = "open"
|
||||
|
||||
def can_proceed(self):
|
||||
with self.lock:
|
||||
if self.state == "closed":
|
||||
return True
|
||||
|
||||
if self.state == "open":
|
||||
if time.time() - self.last_failure_time > self.reset_timeout:
|
||||
self.state = "half-open"
|
||||
return True
|
||||
return False
|
||||
|
||||
# half-open: allow one request to test
|
||||
return True
|
||||
```
|
||||
@@ -0,0 +1,240 @@
|
||||
---
|
||||
name: polling-patterns
|
||||
description: Implementing async polling for BFL API responses
|
||||
---
|
||||
|
||||
# Polling Patterns
|
||||
|
||||
BFL API uses asynchronous generation. All requests return a `polling_url` for status checking.
|
||||
|
||||
## Basic Flow
|
||||
|
||||
```
|
||||
1. POST request to model endpoint
|
||||
└─> Immediate response: { "polling_url": "..." }
|
||||
|
||||
2. GET polling_url (repeat until complete)
|
||||
└─> { "status": "Pending" | "Ready" | "Error" }
|
||||
|
||||
3. When "Ready", download result sample URL
|
||||
└─> URL expires in 10 minutes
|
||||
```
|
||||
|
||||
## Response States
|
||||
|
||||
| Status | Description | Action |
|
||||
|--------|-------------|--------|
|
||||
| `Pending` | Request queued/processing | Continue polling |
|
||||
| `Ready` | Generation finished | Download result |
|
||||
| `Error` | Generation Error | Handle error |
|
||||
|
||||
## Polling Strategies
|
||||
|
||||
### Simple Fixed Interval
|
||||
|
||||
```python
|
||||
import time
|
||||
import requests
|
||||
|
||||
def poll_fixed_interval(polling_url, headers, interval=2, timeout=120):
|
||||
"""Simple polling with fixed interval."""
|
||||
start_time = time.time()
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
response = requests.get(polling_url, headers=headers)
|
||||
data = response.json()
|
||||
|
||||
if data["status"] == "Ready":
|
||||
return data["result"]
|
||||
elif data["status"] == "Error":
|
||||
raise Exception(data.get("error", "Generation Error"))
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
raise TimeoutError("Polling timeout exceeded")
|
||||
```
|
||||
|
||||
### Exponential Backoff (Recommended)
|
||||
|
||||
```python
|
||||
import time
|
||||
import random
|
||||
import requests
|
||||
|
||||
def poll_with_backoff(polling_url, headers, max_attempts=30):
|
||||
"""Polling with exponential backoff and jitter."""
|
||||
base_delay = 0.5 # Start with 500ms
|
||||
max_delay = 10 # Cap at 10 seconds
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
response = requests.get(polling_url, headers=headers)
|
||||
data = response.json()
|
||||
|
||||
if data["status"] == "Ready":
|
||||
return data["result"]
|
||||
elif data["status"] == "Error":
|
||||
raise Exception(data.get("error", "Generation Error"))
|
||||
|
||||
# Exponential backoff with jitter
|
||||
delay = min(base_delay * (2 ** attempt), max_delay)
|
||||
jitter = random.uniform(0, delay * 0.1) # 10% jitter
|
||||
time.sleep(delay + jitter)
|
||||
|
||||
raise TimeoutError("Max polling attempts exceeded")
|
||||
```
|
||||
|
||||
### Adaptive Polling
|
||||
|
||||
```python
|
||||
import time
|
||||
import requests
|
||||
|
||||
def poll_adaptive(polling_url, headers, timeout=120):
|
||||
"""Adaptive polling that adjusts based on status."""
|
||||
start_time = time.time()
|
||||
delays = {
|
||||
"Pending": 2.0, # Queue/processing
|
||||
None: 1.5 # Unknown/default
|
||||
}
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
response = requests.get(polling_url, headers=headers)
|
||||
data = response.json()
|
||||
|
||||
status = data.get("status")
|
||||
|
||||
if status == "Ready":
|
||||
return data["result"]
|
||||
elif status == "Error":
|
||||
raise Exception(data.get("error", "Generation Error"))
|
||||
|
||||
delay = delays.get(status, delays[None])
|
||||
time.sleep(delay)
|
||||
|
||||
raise TimeoutError("Polling timeout exceeded")
|
||||
```
|
||||
|
||||
## Complete Example: Submit and Poll
|
||||
|
||||
```python
|
||||
import time
|
||||
import requests
|
||||
|
||||
class BFLClient:
|
||||
def __init__(self, api_key, base_url="https://api.bfl.ai"):
|
||||
self.base_url = base_url
|
||||
self.headers = {
|
||||
"x-key": api_key,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
def generate(self, model, prompt, **kwargs):
|
||||
"""Submit generation request and poll for result."""
|
||||
# Submit request
|
||||
endpoint = f"{self.base_url}/v1/{model}"
|
||||
payload = {"prompt": prompt, **kwargs}
|
||||
|
||||
response = requests.post(endpoint, headers=self.headers, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
polling_url = response.json()["polling_url"]
|
||||
|
||||
# Poll for result
|
||||
return self._poll(polling_url)
|
||||
|
||||
def _poll(self, polling_url, timeout=120):
|
||||
"""Poll until completion or timeout."""
|
||||
start = time.time()
|
||||
delay = 1.0
|
||||
|
||||
while time.time() - start < timeout:
|
||||
response = requests.get(polling_url, headers=self.headers)
|
||||
data = response.json()
|
||||
|
||||
if data["status"] == "Ready":
|
||||
return data["result"]
|
||||
elif data["status"] == "Error":
|
||||
raise Exception(data.get("error"))
|
||||
|
||||
time.sleep(delay)
|
||||
delay = min(delay * 1.5, 5.0) # Gradual backoff
|
||||
|
||||
raise TimeoutError("Generation timed out")
|
||||
|
||||
# Usage
|
||||
client = BFLClient("your-api-key")
|
||||
result = client.generate(
|
||||
model="flux-2-pro",
|
||||
prompt="A beautiful sunset over mountains"
|
||||
)
|
||||
print(f"Image URL: {result['sample']}")
|
||||
```
|
||||
|
||||
## URL Expiration
|
||||
|
||||
**Critical:** Result URLs expire after 10 minutes. Always download immediately.
|
||||
|
||||
```python
|
||||
def download_result(result_url, output_path):
|
||||
"""Download result image before URL expires."""
|
||||
response = requests.get(result_url)
|
||||
response.raise_for_status()
|
||||
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
return output_path
|
||||
```
|
||||
|
||||
## Batch Processing with Polling
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import aiohttp
|
||||
|
||||
async def generate_batch(client, prompts, model="flux-2-pro"):
|
||||
"""Generate multiple images concurrently."""
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# Submit all requests
|
||||
tasks = []
|
||||
for prompt in prompts:
|
||||
task = submit_and_poll(session, client, model, prompt)
|
||||
tasks.append(task)
|
||||
|
||||
# Wait for all to complete
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
return results
|
||||
|
||||
async def submit_and_poll(session, client, model, prompt):
|
||||
"""Async submit and poll for single image."""
|
||||
# Submit
|
||||
async with session.post(
|
||||
f"{client.base_url}/v1/{model}",
|
||||
headers=client.headers,
|
||||
json={"prompt": prompt}
|
||||
) as response:
|
||||
data = await response.json()
|
||||
polling_url = data["polling_url"]
|
||||
|
||||
# Poll
|
||||
while True:
|
||||
async with session.get(polling_url, headers=client.headers) as response:
|
||||
data = await response.json()
|
||||
|
||||
if data["status"] == "Ready":
|
||||
return data["result"]
|
||||
elif data["status"] == "Error":
|
||||
raise Exception(data.get("error"))
|
||||
|
||||
await asyncio.sleep(2)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always implement timeouts** - Never poll indefinitely
|
||||
2. **Use exponential backoff** - Reduces server load, handles congestion
|
||||
3. **Add jitter** - Prevents thundering herd when polling multiple requests
|
||||
4. **Handle all status values** - Including unexpected ones
|
||||
5. **Download immediately** - URLs expire in 10 minutes
|
||||
6. **Log polling attempts** - Useful for debugging and monitoring
|
||||
7. **Respect rate limits** - Implement proper backoff on 429 responses
|
||||
@@ -0,0 +1,246 @@
|
||||
---
|
||||
name: rate-limiting
|
||||
description: Understanding and handling BFL API rate limits
|
||||
---
|
||||
|
||||
# Rate Limiting
|
||||
|
||||
BFL API enforces rate limits to ensure fair usage and system stability.
|
||||
|
||||
## Current Limits
|
||||
|
||||
| Endpoint Category | Concurrent Requests |
|
||||
| ---------------------- | ------------------- |
|
||||
| Standard (most models) | 24 |
|
||||
|
||||
**Concurrent requests** means in-flight requests (submitted but not yet completed).
|
||||
|
||||
## Rate Limit Headers
|
||||
|
||||
Check response headers for rate limit status:
|
||||
|
||||
```
|
||||
X-RateLimit-Limit: 24
|
||||
X-RateLimit-Remaining: 23
|
||||
X-RateLimit-Reset: 1640000000
|
||||
```
|
||||
|
||||
## HTTP 429 Response
|
||||
|
||||
When rate limited, you receive HTTP 429:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "rate_limit_exceeded",
|
||||
"message": "Too many concurrent requests",
|
||||
"retry_after": 5
|
||||
}
|
||||
```
|
||||
|
||||
## Handling Strategies
|
||||
|
||||
### 1. Client-Side Tracking
|
||||
|
||||
Track active requests to stay under limits:
|
||||
|
||||
```python
|
||||
from threading import Lock, Semaphore
|
||||
|
||||
class RateLimitedClient:
|
||||
def __init__(self, api_key, max_concurrent=24):
|
||||
self.api_key = api_key
|
||||
self.semaphore = Semaphore(max_concurrent)
|
||||
|
||||
def generate(self, model, prompt, **kwargs):
|
||||
with self.semaphore: # Blocks if at limit
|
||||
return self._make_request(model, prompt, **kwargs)
|
||||
|
||||
def _make_request(self, model, prompt, **kwargs):
|
||||
# Submit request
|
||||
response = requests.post(...)
|
||||
polling_url = response.json()["polling_url"]
|
||||
|
||||
# Poll until complete (request still "active")
|
||||
return self._poll(polling_url)
|
||||
```
|
||||
|
||||
### 2. Retry with Exponential Backoff
|
||||
|
||||
```python
|
||||
import time
|
||||
|
||||
def request_with_retry(endpoint, payload, headers, max_retries=5):
|
||||
"""Make request with automatic retry on rate limit."""
|
||||
for attempt in range(max_retries):
|
||||
response = requests.post(endpoint, json=payload, headers=headers)
|
||||
|
||||
if response.status_code == 429:
|
||||
retry_after = int(response.headers.get('Retry-After', 5))
|
||||
wait_time = retry_after * (2 ** attempt) # Exponential backoff
|
||||
print(f"Rate limited. Waiting {wait_time}s...")
|
||||
time.sleep(wait_time)
|
||||
continue
|
||||
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
raise Exception("Max retries exceeded due to rate limiting")
|
||||
```
|
||||
|
||||
### 3. Queue-Based Architecture
|
||||
|
||||
For high-volume applications:
|
||||
|
||||
```python
|
||||
from queue import Queue
|
||||
from threading import Thread
|
||||
import time
|
||||
|
||||
class RequestQueue:
|
||||
def __init__(self, api_key, max_concurrent=24):
|
||||
self.api_key = api_key
|
||||
self.queue = Queue()
|
||||
self.active = 0
|
||||
self.max_concurrent = max_concurrent
|
||||
self.lock = Lock()
|
||||
|
||||
# Start worker threads
|
||||
for _ in range(max_concurrent):
|
||||
worker = Thread(target=self._worker, daemon=True)
|
||||
worker.start()
|
||||
|
||||
def submit(self, model, prompt, callback):
|
||||
"""Submit request to queue."""
|
||||
self.queue.put({
|
||||
'model': model,
|
||||
'prompt': prompt,
|
||||
'callback': callback
|
||||
})
|
||||
|
||||
def _worker(self):
|
||||
"""Process queue items."""
|
||||
while True:
|
||||
item = self.queue.get()
|
||||
try:
|
||||
result = self._process(item)
|
||||
item['callback'](result, None)
|
||||
except Exception as e:
|
||||
item['callback'](None, e)
|
||||
finally:
|
||||
self.queue.task_done()
|
||||
|
||||
def _process(self, item):
|
||||
# Make request and poll
|
||||
...
|
||||
|
||||
# Usage
|
||||
queue = RequestQueue("your-api-key")
|
||||
|
||||
def handle_result(result, error):
|
||||
if error:
|
||||
print(f"Error: {error}")
|
||||
else:
|
||||
print(f"Generated: {result['sample']}")
|
||||
|
||||
queue.submit("flux-2-pro", "A sunset", handle_result)
|
||||
```
|
||||
|
||||
### 4. Async with Semaphore
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import aiohttp
|
||||
|
||||
class AsyncRateLimitedClient:
|
||||
def __init__(self, api_key, max_concurrent=24):
|
||||
self.api_key = api_key
|
||||
self.semaphore = asyncio.Semaphore(max_concurrent)
|
||||
self.headers = {"x-key": api_key}
|
||||
|
||||
async def generate(self, model, prompt):
|
||||
async with self.semaphore:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# Submit
|
||||
async with session.post(
|
||||
f"https://api.bfl.ai/v1/{model}",
|
||||
headers=self.headers,
|
||||
json={"prompt": prompt}
|
||||
) as response:
|
||||
data = await response.json()
|
||||
polling_url = data["polling_url"]
|
||||
|
||||
# Poll until complete
|
||||
while True:
|
||||
async with session.get(
|
||||
polling_url,
|
||||
headers=self.headers
|
||||
) as response:
|
||||
data = await response.json()
|
||||
if data["status"] == "Ready":
|
||||
return data["result"]
|
||||
elif data["status"] == "Error":
|
||||
raise Exception(data.get("error"))
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Usage
|
||||
async def main():
|
||||
client = AsyncRateLimitedClient("your-api-key")
|
||||
|
||||
# Generate 50 images with rate limiting
|
||||
prompts = [f"Image {i}" for i in range(50)]
|
||||
tasks = [client.generate("flux-2-pro", p) for p in prompts]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## Monitoring Rate Limits
|
||||
|
||||
```python
|
||||
class RateLimitMonitor:
|
||||
def __init__(self):
|
||||
self.requests_made = 0
|
||||
self.rate_limit_hits = 0
|
||||
self.lock = Lock()
|
||||
|
||||
def record_request(self, response):
|
||||
with self.lock:
|
||||
self.requests_made += 1
|
||||
if response.status_code == 429:
|
||||
self.rate_limit_hits += 1
|
||||
|
||||
def get_stats(self):
|
||||
return {
|
||||
"total_requests": self.requests_made,
|
||||
"rate_limit_hits": self.rate_limit_hits,
|
||||
"hit_rate": self.rate_limit_hits / max(self.requests_made, 1)
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Track active requests** - Know how many are in-flight
|
||||
2. **Implement client-side limits** - Stay under limits proactively
|
||||
3. **Use semaphores** - Clean way to limit concurrency
|
||||
4. **Queue for high volume** - Buffer requests when traffic spikes
|
||||
5. **Monitor headers** - React to remaining quota
|
||||
6. **Graceful degradation** - Queue or delay when near limits
|
||||
7. **Different limits per endpoint** - Remember Kontext Max is 6, not 24
|
||||
|
||||
## Regional Distribution
|
||||
|
||||
For very high volume, consider distributing across regions:
|
||||
|
||||
```python
|
||||
ENDPOINTS = [
|
||||
"https://api.bfl.ai",
|
||||
"https://api.eu.bfl.ai",
|
||||
"https://api.us.bfl.ai"
|
||||
]
|
||||
|
||||
def get_endpoint():
|
||||
"""Round-robin or least-loaded selection."""
|
||||
return random.choice(ENDPOINTS)
|
||||
```
|
||||
|
||||
Note: Verify regional rate limits are independent before relying on this strategy.
|
||||
@@ -0,0 +1,339 @@
|
||||
---
|
||||
name: webhook-integration
|
||||
description: Setting up webhooks for production BFL API integration
|
||||
---
|
||||
|
||||
# Webhook Integration
|
||||
|
||||
For production workloads, use webhooks instead of polling to receive generation results.
|
||||
|
||||
## Benefits Over Polling
|
||||
|
||||
- **Reduced API calls** - No repeated polling requests
|
||||
- **Immediate notification** - Know exactly when generation completes
|
||||
- **Better resource efficiency** - No wasted compute on polling
|
||||
- **Scalable architecture** - Event-driven design
|
||||
|
||||
## Setup
|
||||
|
||||
### Request with Webhook
|
||||
|
||||
Include `webhook_url` and optionally `webhook_secret` in your request:
|
||||
|
||||
```bash
|
||||
curl -X POST "https://api.bfl.ai/v1/flux-2-pro" \
|
||||
-H "x-key: YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"prompt": "A beautiful sunset over mountains",
|
||||
"webhook_url": "https://your-server.com/api/bfl-webhook",
|
||||
"webhook_secret": "your-secret-key-here"
|
||||
}'
|
||||
```
|
||||
|
||||
### Webhook Payload
|
||||
|
||||
When generation completes, BFL sends a POST request to your webhook URL:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "gen_abc123xyz",
|
||||
"status": "Ready",
|
||||
"result": {
|
||||
"sample": "https://bfldeliveryprod.blob.core.windows.net/results/...",
|
||||
"prompt": "...",
|
||||
"seed": 1234567890
|
||||
},
|
||||
"timestamp": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
For failures:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "gen_abc123xyz",
|
||||
"status": "Error",
|
||||
"error": "content_policy_violation",
|
||||
"message": "The prompt violated content policy",
|
||||
"timestamp": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
### Signature Verification
|
||||
|
||||
When `webhook_secret` is provided, BFL signs the payload with HMAC-SHA256:
|
||||
|
||||
```
|
||||
X-BFL-Signature: sha256=<hex-encoded-signature>
|
||||
```
|
||||
|
||||
### Verification Implementation
|
||||
|
||||
```python
|
||||
import hmac
|
||||
import hashlib
|
||||
|
||||
def verify_webhook_signature(payload, signature, secret):
|
||||
"""Verify the webhook came from BFL."""
|
||||
if not signature or not signature.startswith('sha256='):
|
||||
return False
|
||||
|
||||
expected_signature = hmac.new(
|
||||
secret.encode('utf-8'),
|
||||
payload,
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
provided_signature = signature[7:] # Remove 'sha256=' prefix
|
||||
|
||||
return hmac.compare_digest(expected_signature, provided_signature)
|
||||
```
|
||||
|
||||
### Flask Handler with Verification
|
||||
|
||||
```python
|
||||
from flask import Flask, request, jsonify
|
||||
import hmac
|
||||
import hashlib
|
||||
import requests
|
||||
|
||||
app = Flask(__name__)
|
||||
WEBHOOK_SECRET = "your-secret-key-here"
|
||||
|
||||
@app.route('/api/bfl-webhook', methods=['POST'])
|
||||
def handle_webhook():
|
||||
# Verify signature
|
||||
signature = request.headers.get('X-BFL-Signature')
|
||||
if not verify_webhook_signature(request.data, signature, WEBHOOK_SECRET):
|
||||
return jsonify({'error': 'Invalid signature'}), 401
|
||||
|
||||
data = request.json
|
||||
|
||||
if data['status'] == 'Ready':
|
||||
handle_completion(data)
|
||||
elif data['status'] == 'Error':
|
||||
handle_failure(data)
|
||||
|
||||
return jsonify({'status': 'received'}), 200
|
||||
|
||||
def handle_completion(data):
|
||||
generation_id = data['id']
|
||||
result_url = data['result']['sample']
|
||||
|
||||
# Download image immediately (URL expires in 10 min)
|
||||
image_data = requests.get(result_url).content
|
||||
|
||||
# Store to your storage
|
||||
store_image(generation_id, image_data)
|
||||
|
||||
# Update your database
|
||||
update_generation_status(generation_id, 'completed')
|
||||
|
||||
# Notify your application/users
|
||||
notify_completion(generation_id)
|
||||
|
||||
def handle_failure(data):
|
||||
generation_id = data['id']
|
||||
error = data.get('error', 'unknown')
|
||||
|
||||
# Log the failure
|
||||
log_generation_failure(generation_id, error)
|
||||
|
||||
# Update your database
|
||||
update_generation_status(generation_id, 'failed', error)
|
||||
|
||||
# Maybe retry or notify
|
||||
handle_generation_error(generation_id, error)
|
||||
```
|
||||
|
||||
### Express.js Handler
|
||||
|
||||
```javascript
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const axios = require('axios');
|
||||
|
||||
const app = express();
|
||||
app.use(express.raw({ type: 'application/json' }));
|
||||
|
||||
const WEBHOOK_SECRET = 'your-secret-key-here';
|
||||
|
||||
function verifySignature(payload, signature, secret) {
|
||||
if (!signature || !signature.startsWith('sha256=')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
|
||||
const providedSignature = signature.slice(7);
|
||||
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(expectedSignature),
|
||||
Buffer.from(providedSignature)
|
||||
);
|
||||
}
|
||||
|
||||
app.post('/api/bfl-webhook', async (req, res) => {
|
||||
const signature = req.headers['x-bfl-signature'];
|
||||
|
||||
if (!verifySignature(req.body, signature, WEBHOOK_SECRET)) {
|
||||
return res.status(401).json({ error: 'Invalid signature' });
|
||||
}
|
||||
|
||||
const data = JSON.parse(req.body);
|
||||
|
||||
if (data.status === 'Ready') {
|
||||
// Download image (URL expires in 10 min)
|
||||
const imageResponse = await axios.get(data.result.sample, {
|
||||
responseType: 'arraybuffer'
|
||||
});
|
||||
|
||||
// Store the image
|
||||
await storeImage(data.id, imageResponse.data);
|
||||
}
|
||||
|
||||
res.json({ status: 'received' });
|
||||
});
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
### HTTPS Required
|
||||
|
||||
Webhook URLs **must use HTTPS** in production. BFL will not send webhooks to HTTP endpoints.
|
||||
|
||||
### Response Requirements
|
||||
|
||||
- Respond with 2xx status code to acknowledge receipt
|
||||
- Respond within 30 seconds
|
||||
- Keep handler fast - offload heavy processing
|
||||
|
||||
### Retry Policy
|
||||
|
||||
BFL retries failed webhook deliveries:
|
||||
|
||||
| Attempt | Delay |
|
||||
|---------|-------|
|
||||
| 1st retry | 1 second |
|
||||
| 2nd retry | 5 seconds |
|
||||
| 3rd retry | 30 seconds |
|
||||
|
||||
After 3 failed attempts, the webhook is abandoned. Fall back to polling if critical.
|
||||
|
||||
## Idempotency
|
||||
|
||||
Handle duplicate webhook deliveries:
|
||||
|
||||
```python
|
||||
from functools import lru_cache
|
||||
import redis
|
||||
|
||||
redis_client = redis.Redis()
|
||||
|
||||
def is_duplicate_webhook(generation_id):
|
||||
"""Check if we've already processed this webhook."""
|
||||
key = f"webhook:processed:{generation_id}"
|
||||
|
||||
# Try to set with NX (only if not exists)
|
||||
was_set = redis_client.set(key, "1", nx=True, ex=3600) # 1 hour TTL
|
||||
|
||||
return not was_set # If we couldn't set it, it's a duplicate
|
||||
|
||||
@app.route('/api/bfl-webhook', methods=['POST'])
|
||||
def handle_webhook():
|
||||
# ... signature verification ...
|
||||
|
||||
data = request.json
|
||||
generation_id = data['id']
|
||||
|
||||
if is_duplicate_webhook(generation_id):
|
||||
return jsonify({'status': 'already_processed'}), 200
|
||||
|
||||
# Process webhook...
|
||||
```
|
||||
|
||||
## Hybrid Approach
|
||||
|
||||
Combine webhooks with polling fallback:
|
||||
|
||||
```python
|
||||
class HybridClient:
|
||||
def __init__(self, api_key, webhook_url, webhook_secret):
|
||||
self.api_key = api_key
|
||||
self.webhook_url = webhook_url
|
||||
self.webhook_secret = webhook_secret
|
||||
self.pending = {} # Track pending generations
|
||||
|
||||
def generate(self, prompt, timeout=300):
|
||||
"""Generate with webhook, fall back to polling."""
|
||||
response = self._submit(prompt)
|
||||
generation_id = response['id']
|
||||
polling_url = response['polling_url']
|
||||
|
||||
# Wait for webhook (with timeout)
|
||||
result = self._wait_for_webhook(generation_id, timeout=timeout)
|
||||
|
||||
if result is None:
|
||||
# Webhook didn't arrive, fall back to polling
|
||||
result = self._poll(polling_url, timeout=60)
|
||||
|
||||
return result
|
||||
|
||||
def _submit(self, prompt):
|
||||
return requests.post(
|
||||
"https://api.bfl.ai/v1/flux-2-pro",
|
||||
headers={"x-key": self.api_key},
|
||||
json={
|
||||
"prompt": prompt,
|
||||
"webhook_url": self.webhook_url,
|
||||
"webhook_secret": self.webhook_secret
|
||||
}
|
||||
).json()
|
||||
|
||||
def receive_webhook(self, data):
|
||||
"""Called by webhook handler."""
|
||||
generation_id = data['id']
|
||||
if generation_id in self.pending:
|
||||
self.pending[generation_id].set_result(data)
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
Track webhook health:
|
||||
|
||||
```python
|
||||
import time
|
||||
|
||||
class WebhookMetrics:
|
||||
def __init__(self):
|
||||
self.received = 0
|
||||
self.processed = 0
|
||||
self.failed = 0
|
||||
self.avg_latency = 0
|
||||
|
||||
def record_webhook(self, generation_id, submit_time):
|
||||
self.received += 1
|
||||
latency = time.time() - submit_time
|
||||
self.avg_latency = (self.avg_latency * (self.received - 1) + latency) / self.received
|
||||
|
||||
def record_success(self):
|
||||
self.processed += 1
|
||||
|
||||
def record_failure(self):
|
||||
self.failed += 1
|
||||
|
||||
def get_stats(self):
|
||||
return {
|
||||
"received": self.received,
|
||||
"processed": self.processed,
|
||||
"failed": self.failed,
|
||||
"success_rate": self.processed / max(self.received, 1),
|
||||
"avg_latency_seconds": self.avg_latency
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user