Skip to content

Commit 02781c0

Browse files
committed
feat: add JSON-based edit endpoint for LLM tool use
The existing /edit endpoint uses multipart form-data which LLMs cannot use as a tool. This adds /edit/json that accepts a JSON body with image_url, prompt, provider, model, and n parameters. Also adds authentication headers when fetching images from Open WebUI URLs to ensure edited images can be retrieved.
1 parent 4abb5bc commit 02781c0

4 files changed

Lines changed: 119 additions & 2 deletions

File tree

app/api/routes/edit.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from app.core.config import settings
99
from app.core.security import verify_token
10+
from app.schemas.requests import ImageEditRequest
1011
from app.schemas.responses import ImageResponse
1112
from app.services.gemini_service import get_gemini_service
1213
from app.services.litellm_service import get_litellm_service
@@ -161,6 +162,75 @@ async def edit_image(
161162
)
162163

163164

165+
@router.post(
166+
"/json",
167+
response_model=ImageResponse,
168+
operation_id="edit_image_json",
169+
summary="Edit image (JSON)",
170+
description=(
171+
"Edit an existing image using a JSON request body. "
172+
"This endpoint is designed for LLM tool use - provide the image_url "
173+
"from a previous generate_image response. Uses prompt-based editing."
174+
),
175+
)
176+
async def edit_image_json(
177+
request: ImageEditRequest,
178+
_: None = Depends(verify_token),
179+
) -> ImageResponse:
180+
"""
181+
Edit an image using JSON request body (LLM-friendly endpoint).
182+
"""
183+
logger.info(f"Edit JSON request: provider={request.provider}, model={request.model}")
184+
185+
# Load image from URL
186+
try:
187+
image_bytes = await storage_service.get_image(request.image_url)
188+
except FileNotFoundError as e:
189+
raise HTTPException(status_code=404, detail=str(e)) from None
190+
except Exception as e:
191+
logger.error(f"Failed to load image: {e}")
192+
raise HTTPException(status_code=400, detail=f"Failed to load image: {str(e)}") from None
193+
194+
# Determine model if not specified
195+
model = request.model
196+
if not model:
197+
model = _get_default_edit_model(request.provider)
198+
199+
# Get service based on provider
200+
try:
201+
service = _get_service(request.provider)
202+
except ValueError as e:
203+
raise HTTPException(status_code=400, detail=str(e)) from None
204+
205+
# Edit image (prompt-based, no mask for JSON endpoint)
206+
try:
207+
urls = await service.edit_image(
208+
image=image_bytes,
209+
prompt=request.prompt,
210+
model=model,
211+
n=request.n,
212+
)
213+
except Exception as e:
214+
logger.error(f"Edit failed: {e}", exc_info=True)
215+
raise HTTPException(status_code=500, detail=f"Edit failed: {str(e)}") from None
216+
217+
if not urls:
218+
raise HTTPException(status_code=500, detail="No images generated")
219+
220+
return ImageResponse(
221+
image_url=urls[0],
222+
prompt=request.prompt,
223+
model=model,
224+
provider=request.provider,
225+
metadata={
226+
"all_urls": urls if len(urls) > 1 else None,
227+
"n": len(urls),
228+
"edit": True,
229+
"source_image": request.image_url,
230+
},
231+
)
232+
233+
164234
def _get_service(provider: str):
165235
"""Get service instance based on provider."""
166236
if provider == "litellm":

app/schemas/requests.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,43 @@ class ImageRequest(BaseModel):
6767
stream: bool = Field(default=False, description="Enable SSE streaming for progress updates")
6868

6969

70+
class ImageEditRequest(BaseModel):
71+
"""
72+
Request schema for image editing (JSON endpoint for LLM tool use).
73+
"""
74+
75+
image_url: str = Field(
76+
...,
77+
description="URL to the image to edit. Use the image_url from a previous generate_image response.",
78+
examples=["https://chat.example.com/api/v1/files/abc123/content"],
79+
)
80+
81+
prompt: str = Field(
82+
...,
83+
description="Description of the edit to make. Be specific about what should change.",
84+
min_length=1,
85+
max_length=4000,
86+
examples=["Make the sky more dramatic with orange sunset colors"],
87+
)
88+
89+
provider: Literal["litellm", "gemini"] = Field(
90+
default="litellm",
91+
description="Provider for editing. Use 'litellm' (default) or 'gemini' for prompt-based editing.",
92+
)
93+
94+
model: str | None = Field(
95+
default=None,
96+
description="Model ID for editing. Server uses default if not specified.",
97+
)
98+
99+
n: int = Field(
100+
default=1,
101+
ge=1,
102+
le=4,
103+
description="Number of variations to generate",
104+
)
105+
106+
70107
class ModelRefreshRequest(BaseModel):
71108
"""
72109
Request schema for refreshing model registry.

app/services/storage_service.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,16 @@ async def get_image(self, url: str) -> bytes:
8484
return await f.read()
8585

8686
# External URL - fetch via HTTP
87+
headers = {}
88+
89+
# Add authentication for Open WebUI URLs
90+
if settings.openwebui_available and settings.OPENWEBUI_BASE_URL:
91+
openwebui_base = settings.OPENWEBUI_BASE_URL.rstrip("/")
92+
if url.startswith(openwebui_base):
93+
headers["Authorization"] = f"Bearer {settings.OPENWEBUI_API_KEY}"
94+
8795
async with httpx.AsyncClient(timeout=30.0) as client:
88-
response = await client.get(url)
96+
response = await client.get(url, headers=headers)
8997
response.raise_for_status()
9098
return response.content
9199

tests/test_edit.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ async def test_storage_get_image_external():
110110
with patch("app.services.storage_service.settings") as mock_settings:
111111
mock_settings.STORAGE_PATH = "/tmp/test"
112112
mock_settings.IMAGE_BASE_URL = "http://localhost:8000"
113+
mock_settings.openwebui_available = False
114+
mock_settings.OPENWEBUI_BASE_URL = None
113115

114116
service = StorageService()
115117

@@ -127,7 +129,7 @@ async def test_storage_get_image_external():
127129
result = await service.get_image("https://example.com/image.png")
128130

129131
assert result == b"external image data"
130-
mock_client.get.assert_called_once_with("https://example.com/image.png")
132+
mock_client.get.assert_called_once_with("https://example.com/image.png", headers={})
131133

132134

133135
def test_get_default_edit_model():

0 commit comments

Comments
 (0)