Skip to content

Commit 7a7b096

Browse files
committed
feat(api): add /edit endpoint for image editing
New POST /edit endpoint supporting: - Mask-based inpainting (OpenAI/LiteLLM) - Prompt-based editing (Gemini) - Image upload or URL reference - Multiple response formats (url, base64, markdown)
1 parent 01d95ba commit 7a7b096

2 files changed

Lines changed: 198 additions & 1 deletion

File tree

app/api/routes/edit.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import base64
2+
import logging
3+
from pathlib import Path
4+
from typing import Literal
5+
6+
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
7+
8+
from app.core.config import settings
9+
from app.core.security import verify_token
10+
from app.schemas.responses import ImageResponse
11+
from app.services.gemini_service import get_gemini_service
12+
from app.services.litellm_service import get_litellm_service
13+
from app.services.model_registry import model_registry
14+
from app.services.openai_service import get_openai_service
15+
from app.services.storage_service import storage_service
16+
17+
logger = logging.getLogger(__name__)
18+
19+
router = APIRouter(prefix="/edit", tags=["Image Editing"])
20+
21+
22+
@router.post(
23+
"",
24+
response_model=ImageResponse,
25+
operation_id="edit_image",
26+
summary="Edit image",
27+
description=(
28+
"Edit an existing image using mask-based inpainting (OpenAI) or "
29+
"prompt-based editing (Gemini). Provide either an image file upload "
30+
"or a URL to an existing image."
31+
),
32+
)
33+
async def edit_image(
34+
prompt: str = Form(..., description="Description of the edit to make"),
35+
provider: Literal["litellm", "openai", "gemini"] = Form(
36+
"litellm", description="Provider to use for editing"
37+
),
38+
model: str | None = Form(None, description="Model ID (optional, uses default if not set)"),
39+
image: UploadFile | None = File(None, description="Image file to edit"),
40+
image_url: str | None = Form(None, description="URL to existing image (alternative to upload)"),
41+
mask: UploadFile | None = File(None, description="Mask image (transparent areas will be edited)"),
42+
n: int = Form(1, ge=1, le=4, description="Number of variations to generate"),
43+
response_format: Literal["url", "base64", "markdown"] = Form(
44+
"url", description="Response format"
45+
),
46+
_: None = Depends(verify_token),
47+
) -> ImageResponse:
48+
"""
49+
Edit an image using mask-based (OpenAI) or prompt-based (Gemini) editing.
50+
"""
51+
logger.info(f"Edit request: provider={provider}, model={model}")
52+
53+
# Validate: need either image upload or image_url
54+
if not image and not image_url:
55+
raise HTTPException(
56+
status_code=400, detail="Either 'image' file or 'image_url' must be provided"
57+
)
58+
59+
# Load image bytes
60+
try:
61+
if image:
62+
image_bytes = await image.read()
63+
else:
64+
image_bytes = await storage_service.get_image(image_url)
65+
except FileNotFoundError as e:
66+
raise HTTPException(status_code=404, detail=str(e))
67+
except Exception as e:
68+
logger.error(f"Failed to load image: {e}")
69+
raise HTTPException(status_code=400, detail=f"Failed to load image: {str(e)}")
70+
71+
# Load mask bytes if provided
72+
mask_bytes = None
73+
if mask:
74+
mask_bytes = await mask.read()
75+
76+
# Determine model if not specified
77+
if not model:
78+
model = _get_default_edit_model(provider)
79+
80+
# Get service based on provider
81+
try:
82+
service = _get_service(provider)
83+
except ValueError as e:
84+
raise HTTPException(status_code=400, detail=str(e))
85+
86+
# Edit image
87+
try:
88+
if provider == "gemini":
89+
# Gemini uses prompt-based editing (no mask)
90+
urls = await service.edit_image(
91+
image=image_bytes,
92+
prompt=prompt,
93+
model=model,
94+
n=n,
95+
)
96+
else:
97+
# OpenAI/LiteLLM uses mask-based editing
98+
urls = await service.edit_image(
99+
image=image_bytes,
100+
prompt=prompt,
101+
model=model,
102+
mask=mask_bytes,
103+
n=n,
104+
)
105+
except Exception as e:
106+
logger.error(f"Edit failed: {e}", exc_info=True)
107+
raise HTTPException(status_code=500, detail=f"Edit failed: {str(e)}")
108+
109+
if not urls:
110+
raise HTTPException(status_code=500, detail="No images generated")
111+
112+
# Handle response format
113+
if response_format == "base64":
114+
image_filename = urls[0].split("/")[-1]
115+
image_path = Path(settings.STORAGE_PATH) / image_filename
116+
117+
if not image_path.exists():
118+
raise HTTPException(status_code=500, detail="Edited image file not found")
119+
120+
with open(image_path, "rb") as f:
121+
image_data = base64.b64encode(f.read()).decode("utf-8")
122+
123+
ext = image_path.suffix.lower()
124+
mime_types = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}
125+
mime_type = mime_types.get(ext, "image/png")
126+
127+
return ImageResponse(
128+
image_base64=image_data,
129+
mime_type=mime_type,
130+
prompt=prompt,
131+
model=model,
132+
provider=provider,
133+
metadata={"n": len(urls), "edit": True},
134+
)
135+
136+
if response_format == "markdown":
137+
markdown = f"![Edited image]({urls[0]})"
138+
return ImageResponse(
139+
markdown=markdown,
140+
image_url=urls[0],
141+
prompt=prompt,
142+
model=model,
143+
provider=provider,
144+
metadata={"n": len(urls), "edit": True},
145+
)
146+
147+
return ImageResponse(
148+
image_url=urls[0],
149+
prompt=prompt,
150+
model=model,
151+
provider=provider,
152+
metadata={
153+
"all_urls": urls if len(urls) > 1 else None,
154+
"n": len(urls),
155+
"edit": True,
156+
},
157+
)
158+
159+
160+
def _get_service(provider: str):
161+
"""Get service instance based on provider."""
162+
if provider == "litellm":
163+
if not settings.litellm_available:
164+
raise ValueError("LiteLLM not configured. Set LITELLM_BASE_URL")
165+
return get_litellm_service()
166+
elif provider == "openai":
167+
if not settings.openai_available:
168+
raise ValueError("OpenAI not configured. Set OPENAI_API_KEY")
169+
return get_openai_service()
170+
elif provider == "gemini":
171+
if not settings.gemini_available:
172+
raise ValueError("Gemini not configured. Set GEMINI_API_KEY")
173+
return get_gemini_service()
174+
else:
175+
raise ValueError(f"Unknown provider: {provider}")
176+
177+
178+
def _get_default_edit_model(provider: str) -> str:
179+
"""Get default model for editing based on provider."""
180+
# Check if DEFAULT_MODEL supports editing
181+
if settings.DEFAULT_MODEL:
182+
model_info = model_registry.get_model(settings.DEFAULT_MODEL)
183+
if model_info and model_info.capabilities.supports_editing:
184+
return settings.DEFAULT_MODEL
185+
186+
# Find first model that supports editing for this provider
187+
models = model_registry.get_models()
188+
for m in models:
189+
if m.capabilities.supports_editing and (provider == "litellm" or m.provider == provider):
190+
return m.id
191+
192+
# Fallback defaults
193+
if provider == "gemini":
194+
return "gemini-2.0-flash-preview-image-generation"
195+
else:
196+
return "gpt-image-1"

app/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from fastapi.middleware.cors import CORSMiddleware
77
from fastapi.staticfiles import StaticFiles
88

9-
from app.api.routes import generate, health, models
9+
from app.api.routes import edit, generate, health, models
1010
from app.core.config import settings
1111
from app.services.model_registry import model_registry
1212

@@ -75,6 +75,7 @@ async def lifespan(app: FastAPI):
7575

7676
# Register routers
7777
app.include_router(generate.router)
78+
app.include_router(edit.router)
7879
app.include_router(models.router)
7980
app.include_router(health.router)
8081

0 commit comments

Comments
 (0)