Coverage for open_webui/routers/images.py: 37%
559 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
1from __future__ import annotations
3import asyncio
4import base64
5import io
6import logging
7import mimetypes
8import re
9import uuid
10from pathlib import Path
11from types import SimpleNamespace
12from typing import Optional
13from urllib.parse import quote, urlparse
15import aiofiles
16import aiohttp
17from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
18from fastapi.responses import FileResponse
19from open_webui.config import (
20 CACHE_DIR,
21 ENABLE_OPENAI_IMAGE_EDIT_NORMALIZATION,
22 IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN,
23 IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN,
24)
25from open_webui.constants import ERROR_MESSAGES
26from open_webui.env import AIOHTTP_CLIENT_ALLOW_REDIRECTS, AIOHTTP_CLIENT_SESSION_SSL, ENABLE_FORWARD_USER_INFO_HEADERS
27from open_webui.events import EVENTS, publish_event
28from open_webui.internal.db import get_async_session
29from open_webui.models.chats import Chats
30from open_webui.models.config import Config
31from open_webui.retrieval.web.utils import get_ssrf_safe_session, validate_url
32from open_webui.routers.files import get_file_content_by_id, upload_file_handler
33from open_webui.utils.access_control import has_permission
34from open_webui.utils.auth import get_admin_user, get_verified_user
35from open_webui.utils.headers import include_user_info_headers
36from open_webui.utils.images.comfyui import (
37 ComfyUICreateImageForm,
38 ComfyUIEditImageForm,
39 ComfyUIWorkflow,
40 comfyui_create_image,
41 comfyui_edit_image,
42 comfyui_upload_image,
43)
44from open_webui.utils.json_codec import JSONCodec
45from open_webui.utils.session_pool import get_session
46from PIL import Image, ImageOps
47from pydantic import BaseModel
48from sqlalchemy.ext.asyncio import AsyncSession
50log = logging.getLogger(__name__)
52# An image can lie as easily as it can illuminate. Let what
53# is generated here be honest about what it shows.
54IMAGE_CACHE_DIR = CACHE_DIR / 'image' / 'generations'
55IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
57router = APIRouter()
59IMAGE_FILE_EXTENSIONS = {
60 'image/jpeg': '.jpg',
61 'image/jpg': '.jpg',
62 'image/mpo': '.jpg',
63 'image/png': '.png',
64 'image/webp': '.webp',
65}
67IMAGE_CONFIG_KEYS = {
68 'ENABLE_IMAGE_GENERATION': 'image_generation.enable',
69 'ENABLE_IMAGE_PROMPT_GENERATION': 'image_generation.prompt.enable',
70 'IMAGE_GENERATION_ENGINE': 'image_generation.engine',
71 'IMAGE_GENERATION_MODEL': 'image_generation.model',
72 'IMAGE_SIZE': 'image_generation.size',
73 'IMAGE_STEPS': 'image_generation.steps',
74 'IMAGES_OPENAI_API_BASE_URL': 'image_generation.openai.api_base_url',
75 'IMAGES_OPENAI_API_KEY': 'image_generation.openai.api_key',
76 'IMAGES_OPENAI_API_VERSION': 'image_generation.openai.api_version',
77 'IMAGES_OPENAI_API_PARAMS': 'image_generation.openai.params',
78 'AUTOMATIC1111_BASE_URL': 'image_generation.automatic1111.base_url',
79 'AUTOMATIC1111_API_AUTH': 'image_generation.automatic1111.api_auth',
80 'AUTOMATIC1111_PARAMS': 'image_generation.automatic1111.api_params',
81 'COMFYUI_BASE_URL': 'image_generation.comfyui.base_url',
82 'COMFYUI_API_KEY': 'image_generation.comfyui.api_key',
83 'COMFYUI_WORKFLOW': 'image_generation.comfyui.workflow',
84 'COMFYUI_WORKFLOW_NODES': 'image_generation.comfyui.nodes',
85 'IMAGES_GEMINI_API_BASE_URL': 'image_generation.gemini.api_base_url',
86 'IMAGES_GEMINI_API_KEY': 'image_generation.gemini.api_key',
87 'IMAGES_GEMINI_ENDPOINT_METHOD': 'image_generation.gemini.endpoint_method',
88 'ENABLE_IMAGE_EDIT': 'images.edit.enable',
89 'IMAGE_EDIT_ENGINE': 'images.edit.engine',
90 'IMAGE_EDIT_MODEL': 'images.edit.model',
91 'IMAGE_EDIT_SIZE': 'images.edit.size',
92 'IMAGES_EDIT_OPENAI_API_BASE_URL': 'images.edit.openai.api_base_url',
93 'IMAGES_EDIT_OPENAI_API_KEY': 'images.edit.openai.api_key',
94 'IMAGES_EDIT_OPENAI_API_VERSION': 'images.edit.openai.api_version',
95 'IMAGES_EDIT_GEMINI_API_BASE_URL': 'images.edit.gemini.api_base_url',
96 'IMAGES_EDIT_GEMINI_API_KEY': 'images.edit.gemini.api_key',
97 'IMAGES_EDIT_COMFYUI_BASE_URL': 'images.edit.comfyui.base_url',
98 'IMAGES_EDIT_COMFYUI_API_KEY': 'images.edit.comfyui.api_key',
99 'IMAGES_EDIT_COMFYUI_WORKFLOW': 'images.edit.comfyui.workflow',
100 'IMAGES_EDIT_COMFYUI_WORKFLOW_NODES': 'images.edit.comfyui.nodes',
101 'USER_PERMISSIONS': 'user.permissions',
102}
105async def get_config_values(key_map: dict[str, str]) -> dict:
106 values = await Config.get_many(*key_map.values())
107 return {field: values[storage_key] for field, storage_key in key_map.items() if storage_key in values}
110async def get_image_config() -> SimpleNamespace:
111 return SimpleNamespace(**await get_config_values(IMAGE_CONFIG_KEYS))
114def config_updates(data: dict, key_map: dict[str, str]) -> dict:
115 return {key_map[field]: value for field, value in data.items() if field in key_map}
118def normalize_openai_edit_image_data_url(data_url: str) -> str:
119 if not data_url.startswith('data:') or ',' not in data_url:
120 return data_url
122 header, encoded = data_url.split(',', 1)
123 mime_type = header.split(';')[0].lstrip('data:').lower()
124 if mime_type not in {'image/jpeg', 'image/jpg', 'image/mpo'}:
125 return data_url
127 try:
128 image_bytes = base64.b64decode(encoded)
129 with Image.open(io.BytesIO(image_bytes)) as image:
130 orientation = image.getexif().get(274)
131 needs_normalization = (
132 mime_type == 'image/mpo'
133 or image.format == 'MPO'
134 or getattr(image, 'n_frames', 1) > 1
135 or orientation not in (None, 1)
136 or image.mode not in ('RGB', 'L')
137 )
139 if not needs_normalization:
140 return data_url
142 image.seek(0)
143 image = ImageOps.exif_transpose(image)
144 if image.mode != 'RGB':
145 image = image.convert('RGB')
147 output = io.BytesIO()
148 image.save(output, format='JPEG', quality=95)
149 normalized_image = base64.b64encode(output.getvalue()).decode('utf-8')
150 return f'data:image/jpeg;base64,{normalized_image}'
151 except Exception as e:
152 log.debug('Image edit normalization skipped: %s', e)
154 return data_url
157def get_image_file_item(base64_string, param_name='image'):
158 header, encoded = base64_string.split(',', 1)
159 mime_type = header.split(';')[0].lstrip('data:') or 'image/png'
160 image_data = base64.b64decode(encoded)
161 extension = IMAGE_FILE_EXTENSIONS.get(mime_type.lower()) or mimetypes.guess_extension(mime_type) or '.png'
162 return (
163 param_name,
164 (
165 f'{uuid.uuid4()}{extension}',
166 io.BytesIO(image_data),
167 mime_type,
168 ),
169 )
172async def set_image_model(request: Request, model: str):
173 log.info('Setting image model to %s', model)
174 await Config.upsert({'image_generation.model': model})
175 image_config = await get_image_config()
176 if image_config.IMAGE_GENERATION_ENGINE in ['', 'automatic1111']: 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true
177 api_auth = get_automatic1111_api_auth(image_config)
179 try:
180 session = await get_session()
181 async with session.get(
182 url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
183 headers={'authorization': api_auth},
184 ssl=AIOHTTP_CLIENT_SESSION_SSL,
185 ) as r:
186 options = await r.json()
187 if model != options['sd_model_checkpoint']:
188 options['sd_model_checkpoint'] = model
189 async with session.post(
190 url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
191 json=options,
192 headers={'authorization': api_auth},
193 ssl=AIOHTTP_CLIENT_SESSION_SSL,
194 ) as r:
195 r.raise_for_status()
196 except Exception as e:
197 log.debug('%s', e)
199 return image_config.IMAGE_GENERATION_MODEL
202async def get_image_model(request):
203 image_config = await get_image_config()
204 if image_config.IMAGE_GENERATION_ENGINE == 'openai':
205 return image_config.IMAGE_GENERATION_MODEL if image_config.IMAGE_GENERATION_MODEL else 'dall-e-2'
206 elif image_config.IMAGE_GENERATION_ENGINE == 'gemini': 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true
207 return image_config.IMAGE_GENERATION_MODEL if image_config.IMAGE_GENERATION_MODEL else 'imagen-3.0-generate-002'
208 elif image_config.IMAGE_GENERATION_ENGINE == 'comfyui': 208 ↛ 209line 208 didn't jump to line 209 because the condition on line 208 was never true
209 return image_config.IMAGE_GENERATION_MODEL if image_config.IMAGE_GENERATION_MODEL else ''
210 elif image_config.IMAGE_GENERATION_ENGINE == 'automatic1111' or image_config.IMAGE_GENERATION_ENGINE == '': 210 ↛ 211line 210 didn't jump to line 211 because the condition on line 210 was never true
211 try:
212 session = await get_session()
213 async with session.get(
214 url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options',
215 headers={'authorization': get_automatic1111_api_auth(image_config)},
216 ssl=AIOHTTP_CLIENT_SESSION_SSL,
217 ) as r:
218 options = await r.json()
219 return options['sd_model_checkpoint']
220 except Exception as e:
221 log.exception(f'Failed to get default model from automatic1111: {e}')
222 raise HTTPException(
223 status_code=400,
224 detail=ERROR_MESSAGES.DEFAULT(e, 'Failed to connect to the image generation engine'),
225 )
228class ImagesConfig(BaseModel):
229 ENABLE_IMAGE_GENERATION: bool
230 ENABLE_IMAGE_PROMPT_GENERATION: bool
232 IMAGE_GENERATION_ENGINE: str
233 IMAGE_GENERATION_MODEL: str
234 IMAGE_SIZE: str | None
235 IMAGE_STEPS: int | None
237 IMAGES_OPENAI_API_BASE_URL: str
238 IMAGES_OPENAI_API_KEY: str
239 IMAGES_OPENAI_API_VERSION: str
240 IMAGES_OPENAI_API_PARAMS: dict | str | None
242 AUTOMATIC1111_BASE_URL: str
243 AUTOMATIC1111_API_AUTH: dict | str | None
244 AUTOMATIC1111_PARAMS: dict | str | None
246 COMFYUI_BASE_URL: str
247 COMFYUI_API_KEY: str
248 COMFYUI_WORKFLOW: str
249 COMFYUI_WORKFLOW_NODES: list[dict]
251 IMAGES_GEMINI_API_BASE_URL: str
252 IMAGES_GEMINI_API_KEY: str
253 IMAGES_GEMINI_ENDPOINT_METHOD: str
255 ENABLE_IMAGE_EDIT: bool
256 IMAGE_EDIT_ENGINE: str
257 IMAGE_EDIT_MODEL: str
258 IMAGE_EDIT_SIZE: str | None
260 IMAGES_EDIT_OPENAI_API_BASE_URL: str
261 IMAGES_EDIT_OPENAI_API_KEY: str
262 IMAGES_EDIT_OPENAI_API_VERSION: str
263 IMAGES_EDIT_GEMINI_API_BASE_URL: str
264 IMAGES_EDIT_GEMINI_API_KEY: str
265 IMAGES_EDIT_COMFYUI_BASE_URL: str
266 IMAGES_EDIT_COMFYUI_API_KEY: str
267 IMAGES_EDIT_COMFYUI_WORKFLOW: str
268 IMAGES_EDIT_COMFYUI_WORKFLOW_NODES: list[dict]
271@router.get('/config', response_model=ImagesConfig)
272async def get_config(request: Request, user=Depends(get_admin_user)):
273 return await get_config_values(IMAGE_CONFIG_KEYS)
276@router.post('/config/update')
277async def update_config(request: Request, form_data: ImagesConfig, user=Depends(get_admin_user)):
278 if form_data.IMAGE_SIZE == 'auto' and not re.match( 278 ↛ 281line 278 didn't jump to line 281 because the condition on line 278 was never true
279 IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN, form_data.IMAGE_GENERATION_MODEL
280 ):
281 raise HTTPException(
282 status_code=400,
283 detail=ERROR_MESSAGES.INCORRECT_FORMAT(
284 f' (auto is only allowed with models matching {IMAGE_AUTO_SIZE_MODELS_REGEX_PATTERN}).'
285 ),
286 )
288 pattern = r'^\d+x\d+$'
289 if not (form_data.IMAGE_SIZE == 'auto' or form_data.IMAGE_SIZE == '' or re.match(pattern, form_data.IMAGE_SIZE)):
290 raise HTTPException(
291 status_code=400,
292 detail=ERROR_MESSAGES.INCORRECT_FORMAT(' (e.g., 512x512).'),
293 )
295 if form_data.IMAGE_STEPS < 0:
296 raise HTTPException(
297 status_code=400,
298 detail=ERROR_MESSAGES.INCORRECT_FORMAT(' (e.g., 50).'),
299 )
301 updates = config_updates(form_data.model_dump(), IMAGE_CONFIG_KEYS)
302 updates['image_generation.comfyui.base_url'] = form_data.COMFYUI_BASE_URL.strip('/')
303 updates['images.edit.comfyui.base_url'] = form_data.IMAGES_EDIT_COMFYUI_BASE_URL.strip('/')
304 await Config.upsert(updates)
305 await set_image_model(request, form_data.IMAGE_GENERATION_MODEL)
306 values = await get_config_values(IMAGE_CONFIG_KEYS)
307 await publish_event(
308 request,
309 EVENTS.CONFIG_UPDATED,
310 actor=user,
311 subject_id='images',
312 data={
313 'image_generation_enabled': values.get('ENABLE_IMAGE_GENERATION'),
314 'image_edit_enabled': values.get('ENABLE_IMAGE_EDIT'),
315 'image_generation_engine': values.get('IMAGE_GENERATION_ENGINE'),
316 'image_edit_engine': values.get('IMAGE_EDIT_ENGINE'),
317 },
318 )
319 return values
322def get_automatic1111_api_auth(image_config):
323 if image_config.AUTOMATIC1111_API_AUTH is None:
324 return ''
325 else:
326 auth1111_byte_string = image_config.AUTOMATIC1111_API_AUTH.encode('utf-8')
327 auth1111_base64_encoded_bytes = base64.b64encode(auth1111_byte_string)
328 auth1111_base64_encoded_string = auth1111_base64_encoded_bytes.decode('utf-8')
329 return f'Basic {auth1111_base64_encoded_string}'
332class ConnectionVerificationForm(BaseModel):
333 engine: str
334 url: str
335 key: str | None = None
338@router.post('/verify')
339async def verify_connection(form_data: ConnectionVerificationForm, user=Depends(get_admin_user)):
340 url = form_data.url.rstrip('/')
341 headers = {}
342 if form_data.engine == 'automatic1111': 342 ↛ 343line 342 didn't jump to line 343 because the condition on line 342 was never true
343 url = f'{url}/sdapi/v1/options'
344 if form_data.key is not None:
345 headers['Authorization'] = f'Basic {base64.b64encode(form_data.key.encode("utf-8")).decode("utf-8")}'
346 elif form_data.engine == 'comfyui': 346 ↛ 347line 346 didn't jump to line 347 because the condition on line 346 was never true
347 url = f'{url}/object_info'
348 if form_data.key:
349 headers['Authorization'] = f'Bearer {form_data.key}'
350 else:
351 raise HTTPException(status_code=400, detail='Unsupported image engine')
353 try:
354 session = await get_session()
355 async with session.get(url=url, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL) as r:
356 r.raise_for_status()
357 return True
358 except Exception:
359 raise HTTPException(status_code=400, detail=ERROR_MESSAGES.INVALID_URL)
362@router.get('/models')
363async def get_models(request: Request, user=Depends(get_verified_user)):
364 image_config = await get_image_config()
365 try:
366 if image_config.IMAGE_GENERATION_ENGINE == 'openai':
367 return [
368 {'id': 'dall-e-2', 'name': 'DALL·E 2'},
369 {'id': 'dall-e-3', 'name': 'DALL·E 3'},
370 {'id': 'gpt-image-1', 'name': 'GPT-IMAGE 1'},
371 {'id': 'gpt-image-1.5', 'name': 'GPT-IMAGE 1.5'},
372 ]
373 elif image_config.IMAGE_GENERATION_ENGINE == 'gemini': 373 ↛ 374line 373 didn't jump to line 374 because the condition on line 373 was never true
374 return [
375 {'id': 'imagen-3.0-generate-002', 'name': 'imagen-3.0 generate-002'},
376 ]
377 elif image_config.IMAGE_GENERATION_ENGINE == 'comfyui': 377 ↛ 379line 377 didn't jump to line 379 because the condition on line 377 was never true
378 # TODO - get models from comfyui
379 headers = {'Authorization': f'Bearer {image_config.COMFYUI_API_KEY}'}
380 session = await get_session()
381 async with session.get(
382 url=f'{image_config.COMFYUI_BASE_URL}/object_info',
383 headers=headers,
384 ssl=AIOHTTP_CLIENT_SESSION_SSL,
385 ) as r:
386 info = await r.json()
388 workflow = JSONCodec.loads(image_config.COMFYUI_WORKFLOW)
389 model_node_id = None
391 for node in image_config.COMFYUI_WORKFLOW_NODES:
392 if node['type'] == 'model':
393 if node['node_ids']:
394 model_node_id = node['node_ids'][0]
395 break
397 if model_node_id:
398 model_list_key = None
400 log.info(workflow[model_node_id]['class_type'])
401 for key in info[workflow[model_node_id]['class_type']]['input']['required']:
402 if '_name' in key:
403 model_list_key = key
404 break
406 if model_list_key:
407 return list(
408 map(
409 lambda model: {'id': model, 'name': model},
410 info[workflow[model_node_id]['class_type']]['input']['required'][model_list_key][0],
411 )
412 )
413 else:
414 return list(
415 map(
416 lambda model: {'id': model, 'name': model},
417 info['CheckpointLoaderSimple']['input']['required']['ckpt_name'][0],
418 )
419 )
420 elif image_config.IMAGE_GENERATION_ENGINE == 'automatic1111' or image_config.IMAGE_GENERATION_ENGINE == '': 420 ↛ 421line 420 didn't jump to line 421 because the condition on line 420 was never true
421 session = await get_session()
422 async with session.get(
423 url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/sd-models',
424 headers={'authorization': get_automatic1111_api_auth(image_config)},
425 ssl=AIOHTTP_CLIENT_SESSION_SSL,
426 ) as r:
427 models = await r.json()
428 return list(
429 map(
430 lambda model: {'id': model['title'], 'name': model['model_name']},
431 models,
432 )
433 )
434 except Exception as e:
435 log.error(
436 f'Failed to list image generation models: {str(e) or type(e).__name__}',
437 exc_info=not isinstance(e, (aiohttp.ClientConnectionError, TimeoutError)),
438 )
439 raise HTTPException(
440 status_code=400,
441 detail=ERROR_MESSAGES.DEFAULT(e, 'Failed to retrieve image generation models'),
442 )
445class CreateImageForm(BaseModel):
446 model: str | None = None
447 prompt: str
448 size: str | None = None
449 n: int = 1
450 steps: int | None = None
451 negative_prompt: str | None = None
454GenerateImageForm = CreateImageForm # Alias for backward compatibility
457def _is_same_origin(url: str, base_url: str) -> bool:
458 """Compare scheme + hostname + port of two URLs.
460 Pure string-prefix matching (``startswith``) is vulnerable to
461 userinfo injection (``http://host:port@evil.com/``) and suffix
462 confusion (``http://host:portevil.com/``). Parsing both URLs
463 and comparing the three origin components eliminates those
464 attack vectors.
465 """
467 def _default_port(scheme: str) -> int:
468 return 443 if scheme == 'https' else 80
470 parsed = urlparse(url)
471 trusted = urlparse(base_url)
472 return (
473 parsed.scheme == trusted.scheme
474 and parsed.hostname == trusted.hostname
475 and (parsed.port or _default_port(parsed.scheme)) == (trusted.port or _default_port(trusted.scheme))
476 )
479async def get_image_data(data: str, headers=None, trusted_base_url: str | None = None):
480 try:
481 if data.startswith('http://') or data.startswith('https://'):
482 # Defense-in-depth: gate before fetch (mirrors load_url_image).
483 # For URLs originating from an admin-configured backend (e.g.
484 # ComfyUI on a private network), skip SSRF validation only when
485 # the URL shares the exact same origin (scheme + host + port)
486 # as the admin-configured base. This avoids both the global
487 # ENABLE_LOCAL_WEB_FETCH hammer and a blanket trust flag
488 # that would follow arbitrary redirects.
489 if trusted_base_url and _is_same_origin(data, trusted_base_url):
490 log.debug('Skipping URL validation for trusted backend: %s', data)
491 else:
492 await asyncio.to_thread(validate_url, data)
493 session = await get_session()
494 async with session.get(
495 data,
496 headers=headers,
497 ssl=AIOHTTP_CLIENT_SESSION_SSL,
498 ) as r:
499 r.raise_for_status()
500 content_type = r.headers.get('content-type', '')
501 if content_type.split('/')[0] == 'image':
502 return await r.read(), content_type
503 else:
504 log.error('Url does not point to an image.')
505 return None, None
506 else:
507 if ',' in data:
508 header, encoded = data.split(',', 1)
509 mime_type = header.split(';')[0].lstrip('data:')
510 img_data = base64.b64decode(encoded)
511 else:
512 mime_type = 'image/png'
513 img_data = base64.b64decode(data)
514 return img_data, mime_type
515 except Exception as e:
516 log.exception(f'Error loading image data: {e}')
517 return None, None
520async def upload_image(request, image_data, content_type, metadata, user, db=None):
521 if image_data is None or content_type is None: 521 ↛ anywhereline 521 didn't jump anywhere: it always raised an exception.
522 raise ValueError('Failed to retrieve image data from the generation backend')
523 image_format = mimetypes.guess_extension(content_type)
524 file = UploadFile(
525 file=io.BytesIO(image_data),
526 filename=f'generated-image{image_format}', # will be converted to a unique ID on upload_file
527 headers={
528 'content-type': content_type,
529 },
530 )
531 file_item = await upload_file_handler(
532 request,
533 file=file,
534 metadata=metadata,
535 process=False,
536 user=user,
537 )
539 if file_item and file_item.id:
540 # If chat_id and message_id are provided in metadata, link the file to the chat message
541 chat_id = metadata.get('chat_id')
542 message_id = metadata.get('message_id')
544 if chat_id and message_id:
545 await Chats.insert_chat_files(
546 chat_id=chat_id,
547 message_id=message_id,
548 file_ids=[file_item.id],
549 user_id=user.id,
550 db=db,
551 )
553 url = request.app.url_path_for('get_file_content_by_id', id=file_item.id)
554 return file_item, {
555 'id': file_item.id,
556 'url': url,
557 'name': (file_item.meta or {}).get('name') or file_item.filename,
558 'content_type': (file_item.meta or {}).get('content_type'),
559 }
562@router.post('/generations')
563async def generate_images(request: Request, form_data: CreateImageForm, user=Depends(get_verified_user)):
564 image_config = await get_image_config()
565 if not image_config.ENABLE_IMAGE_GENERATION:
566 raise HTTPException(
567 status_code=403,
568 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
569 )
571 if user.role != 'admin' and not await has_permission( 571 ↛ 574line 571 didn't jump to line 574 because the condition on line 571 was never true
572 user.id, 'features.image_generation', image_config.USER_PERMISSIONS
573 ):
574 raise HTTPException(
575 status_code=403,
576 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
577 )
579 result = await image_generations(request, form_data, user=user)
580 await publish_event(
581 request,
582 EVENTS.IMAGE_GENERATED,
583 actor=user,
584 subject_id=None,
585 subject_type='image',
586 data={
587 'model': form_data.model,
588 'size': form_data.size,
589 'n': form_data.n,
590 'prompt_preview': form_data.prompt[:300],
591 },
592 )
593 return result
596async def image_generations(
597 request: Request,
598 form_data: CreateImageForm,
599 metadata: dict | None = None,
600 user=None,
601):
602 image_config = await get_image_config()
603 # if IMAGE_SIZE = 'auto', default WidthxHeight to the 512x512 default
604 # This is only relevant when the user has set IMAGE_SIZE to 'auto' with an
605 # image model other than gpt-image-1, which is warned about on settings save
607 size = '512x512'
608 if image_config.IMAGE_SIZE and 'x' in image_config.IMAGE_SIZE: 608 ↛ 611line 608 didn't jump to line 611 because the condition on line 608 was always true
609 size = image_config.IMAGE_SIZE
611 if form_data.size and 'x' in form_data.size: 611 ↛ 612line 611 didn't jump to line 612 because the condition on line 611 was never true
612 size = form_data.size
614 width, height = tuple(map(int, size.split('x')))
616 metadata = metadata or {}
618 model = await get_image_model(request)
620 try:
621 if image_config.IMAGE_GENERATION_ENGINE == 'openai':
622 headers = {
623 'Authorization': f'Bearer {image_config.IMAGES_OPENAI_API_KEY}',
624 'Content-Type': 'application/json',
625 }
627 if ENABLE_FORWARD_USER_INFO_HEADERS: 627 ↛ 628line 627 didn't jump to line 628 because the condition on line 627 was never true
628 headers = include_user_info_headers(headers, user)
630 url = f'{image_config.IMAGES_OPENAI_API_BASE_URL}/images/generations'
631 if image_config.IMAGES_OPENAI_API_VERSION: 631 ↛ 632line 631 didn't jump to line 632 because the condition on line 631 was never true
632 url = f'{url}?api-version={image_config.IMAGES_OPENAI_API_VERSION}'
634 data = {
635 'model': model,
636 'prompt': form_data.prompt,
637 'n': form_data.n,
638 **(
639 {'size': form_data.size or image_config.IMAGE_SIZE}
640 if (form_data.size or image_config.IMAGE_SIZE)
641 else {}
642 ),
643 **(
644 {}
645 if re.match(
646 IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN,
647 image_config.IMAGE_GENERATION_MODEL,
648 )
649 else {'response_format': 'b64_json'}
650 ),
651 **({} if not image_config.IMAGES_OPENAI_API_PARAMS else image_config.IMAGES_OPENAI_API_PARAMS),
652 }
654 session = await get_session()
655 async with session.post(
656 url=url,
657 json=data,
658 headers=headers,
659 ssl=AIOHTTP_CLIENT_SESSION_SSL,
660 ) as r:
661 r.raise_for_status()
662 res = await r.json(content_type=None)
664 images = []
666 for image in res['data']:
667 if image_url := image.get('url', None):
668 image_data, content_type = await get_image_data(
669 image_url,
670 {k: v for k, v in headers.items() if k != 'Content-Type'}
671 if _is_same_origin(image_url, image_config.IMAGES_OPENAI_API_BASE_URL)
672 else None,
673 )
674 else:
675 image_data, content_type = await get_image_data(image['b64_json'])
677 _, image_file = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
678 images.append(image_file)
679 return images
681 elif image_config.IMAGE_GENERATION_ENGINE == 'gemini': 681 ↛ 682line 681 didn't jump to line 682 because the condition on line 681 was never true
682 headers = {
683 'Content-Type': 'application/json',
684 'x-goog-api-key': image_config.IMAGES_GEMINI_API_KEY,
685 }
687 data = {}
689 if (
690 image_config.IMAGES_GEMINI_ENDPOINT_METHOD == ''
691 or image_config.IMAGES_GEMINI_ENDPOINT_METHOD == 'predict'
692 ):
693 model = f'{model}:predict'
694 data = {
695 'instances': {'prompt': form_data.prompt},
696 'parameters': {
697 'sampleCount': form_data.n,
698 'outputOptions': {'mimeType': 'image/png'},
699 },
700 }
702 elif image_config.IMAGES_GEMINI_ENDPOINT_METHOD == 'generateContent':
703 model = f'{model}:generateContent'
704 data = {'contents': [{'parts': [{'text': form_data.prompt}]}]}
706 session = await get_session()
707 async with session.post(
708 url=f'{image_config.IMAGES_GEMINI_API_BASE_URL}/models/{model}',
709 json=data,
710 headers=headers,
711 ssl=AIOHTTP_CLIENT_SESSION_SSL,
712 ) as r:
713 r.raise_for_status()
714 res = await r.json(content_type=None)
716 images = []
718 if model.endswith(':predict'):
719 for image in res['predictions']:
720 image_data, content_type = await get_image_data(image['bytesBase64Encoded'])
721 _, image_file = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
722 images.append(image_file)
723 elif model.endswith(':generateContent'):
724 for image in res['candidates']:
725 for part in image['content']['parts']:
726 if part.get('inlineData', {}).get('data'):
727 image_data, content_type = await get_image_data(part['inlineData']['data'])
728 _, image_file = await upload_image(
729 request,
730 image_data,
731 content_type,
732 {**data, **metadata},
733 user,
734 )
735 images.append(image_file)
737 return images
739 elif image_config.IMAGE_GENERATION_ENGINE == 'comfyui': 739 ↛ 740line 739 didn't jump to line 740 because the condition on line 739 was never true
740 data = {
741 'prompt': form_data.prompt,
742 'width': width,
743 'height': height,
744 'n': form_data.n,
745 }
747 if image_config.IMAGE_STEPS is not None or form_data.steps is not None:
748 data['steps'] = form_data.steps if form_data.steps is not None else image_config.IMAGE_STEPS
750 if form_data.negative_prompt is not None:
751 data['negative_prompt'] = form_data.negative_prompt
753 form_data = ComfyUICreateImageForm(
754 **{
755 'workflow': ComfyUIWorkflow(
756 **{
757 'workflow': image_config.COMFYUI_WORKFLOW,
758 'nodes': image_config.COMFYUI_WORKFLOW_NODES,
759 }
760 ),
761 **data,
762 }
763 )
764 res = await comfyui_create_image(
765 model,
766 form_data,
767 str(uuid.uuid4()),
768 image_config.COMFYUI_BASE_URL,
769 image_config.COMFYUI_API_KEY,
770 )
771 log.debug('res: %s', res)
773 images = []
775 for image in res['data']:
776 headers = None
777 if image_config.COMFYUI_API_KEY:
778 headers = {'Authorization': f'Bearer {image_config.COMFYUI_API_KEY}'}
780 image_data, content_type = await get_image_data(
781 image['url'],
782 headers,
783 trusted_base_url=image_config.COMFYUI_BASE_URL,
784 )
785 _, image_file = await upload_image(
786 request,
787 image_data,
788 content_type,
789 {**form_data.model_dump(exclude_none=True), **metadata},
790 user,
791 )
792 images.append(image_file)
793 return images
794 elif image_config.IMAGE_GENERATION_ENGINE == 'automatic1111' or image_config.IMAGE_GENERATION_ENGINE == '': 794 ↛ 799line 794 didn't jump to line 799 because the condition on line 794 was never true
795 # Automatic1111 holds one checkpoint instance-wide, so set_image_model
796 # persists the global default and switches the shared backend. Only an
797 # admin may do that; a non-admin generates on the currently configured
798 # checkpoint. The model field is not a per-user selection on this backend.
799 if form_data.model and user.role == 'admin':
800 await set_image_model(request, form_data.model)
802 data = {
803 'prompt': form_data.prompt,
804 'batch_size': form_data.n,
805 'width': width,
806 'height': height,
807 }
809 if image_config.IMAGE_STEPS is not None or form_data.steps is not None:
810 data['steps'] = form_data.steps if form_data.steps is not None else image_config.IMAGE_STEPS
812 if form_data.negative_prompt is not None:
813 data['negative_prompt'] = form_data.negative_prompt
815 if image_config.AUTOMATIC1111_PARAMS:
816 data = {**data, **image_config.AUTOMATIC1111_PARAMS}
818 session = await get_session()
819 async with session.post(
820 url=f'{image_config.AUTOMATIC1111_BASE_URL}/sdapi/v1/txt2img',
821 json=data,
822 headers={'authorization': get_automatic1111_api_auth(image_config)},
823 ssl=AIOHTTP_CLIENT_SESSION_SSL,
824 ) as r:
825 res = await r.json(content_type=None)
826 log.debug('res: %s', res)
828 images = []
830 for image in res['images']:
831 image_data, content_type = await get_image_data(image)
832 _, image_file = await upload_image(
833 request,
834 image_data,
835 content_type,
836 {**data, 'info': res['info'], **metadata},
837 user,
838 )
839 images.append(image_file)
840 return images
841 except Exception as e:
842 error = e
843 if isinstance(e, aiohttp.ClientResponseError):
844 error = e.message
845 raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(error))
848class EditImageForm(BaseModel):
849 image: str | list[str] # base64-encoded image(s) or URL(s)
850 prompt: str
851 model: str | None = None
852 size: str | None = None
853 n: int | None = None
854 negative_prompt: str | None = None
855 background: str | None = None
858@router.post('/edit')
859async def edit_images(request: Request, form_data: EditImageForm, user=Depends(get_verified_user)):
860 # Authorize the direct route like /generations and the edit_image tool: enforce the
861 # global image-edit switch and the per-user image-generation permission. The internal
862 # callers (edit_image tool, chat middleware) gate themselves and call image_edits()
863 # directly, so they are unaffected by this wrapper.
864 image_config = await get_image_config()
865 if not image_config.ENABLE_IMAGE_EDIT:
866 raise HTTPException(
867 status_code=403,
868 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
869 )
871 if user.role != 'admin' and not await has_permission( 871 ↛ 874line 871 didn't jump to line 874 because the condition on line 871 was never true
872 user.id, 'features.image_generation', image_config.USER_PERMISSIONS
873 ):
874 raise HTTPException(
875 status_code=403,
876 detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
877 )
879 result = await image_edits(request, form_data, user=user)
880 await publish_event(
881 request,
882 EVENTS.IMAGE_EDITED,
883 actor=user,
884 subject_id=None,
885 subject_type='image',
886 data={
887 'model': form_data.model,
888 'size': form_data.size,
889 'n': form_data.n,
890 'prompt_preview': form_data.prompt[:300],
891 },
892 )
893 return result
896async def image_edits(
897 request: Request,
898 form_data: EditImageForm,
899 metadata: dict | None = None,
900 user=Depends(get_verified_user),
901):
902 image_config = await get_image_config()
903 size = None
904 width, height = None, None
905 metadata = metadata or {}
907 if (image_config.IMAGE_EDIT_SIZE and 'x' in image_config.IMAGE_EDIT_SIZE) or ( 907 ↛ 910line 907 didn't jump to line 910 because the condition on line 907 was never true
908 form_data.size and 'x' in form_data.size
909 ):
910 size = form_data.size if form_data.size else image_config.IMAGE_EDIT_SIZE
911 width, height = tuple(map(int, size.split('x')))
913 model = image_config.IMAGE_EDIT_MODEL if form_data.model is None else form_data.model
915 try:
917 async def load_url_image(data):
918 if data.startswith('data:'): 918 ↛ 919line 918 didn't jump to line 919 because the condition on line 918 was never true
919 return data
921 if data.startswith('http://') or data.startswith('https://'): 921 ↛ 922line 921 didn't jump to line 922 because the condition on line 921 was never true
922 parsed = urlparse(data)
923 # Fetching /api/v1/files/{id}/content over the network would be unauthenticated.
924 if parsed.path.startswith('/api/v1/files/') and '/content' in parsed.path:
925 return await load_url_image(parsed.path)
927 # Validate URL to prevent SSRF attacks against local/private networks.
928 # allow_redirects=False prevents redirect-based SSRF: validate_url() is
929 # called only on the originally-submitted URL; following 3xx redirects
930 # without re-validation would let an attacker reach private IPs via a
931 # public host that redirects internally (e.g. cloud-metadata exfil).
932 await asyncio.to_thread(validate_url, data)
933 # SSRF-safe session: re-checks the connect-time IP so a rebinding DNS answer
934 # that passed validate_url cannot reach an internal address.
935 async with get_ssrf_safe_session() as session:
936 async with session.get(
937 data, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS
938 ) as r:
939 r.raise_for_status()
941 image_data = base64.b64encode(await r.read()).decode('utf-8')
942 return f'data:{r.headers["content-type"]};base64,{image_data}'
944 else:
945 file_id = None
946 if data.startswith('/api/v1/files'): 946 ↛ 947line 946 didn't jump to line 947 because the condition on line 946 was never true
947 file_id = data.split('/api/v1/files/')[1].split('/content')[0]
948 else:
949 file_id = data
951 file_response = await get_file_content_by_id(file_id, user)
952 if isinstance(file_response, FileResponse):
953 file_path = file_response.path
955 async with aiofiles.open(file_path, 'rb') as f:
956 file_bytes = await f.read()
957 image_data = base64.b64encode(file_bytes).decode('utf-8')
958 mime_type, _ = mimetypes.guess_type(file_path)
960 return f'data:{mime_type};base64,{image_data}'
961 return data
963 # Load image(s) from URL(s) if necessary
964 if isinstance(form_data.image, str):
965 form_data.image = await load_url_image(form_data.image)
966 elif isinstance(form_data.image, list): 966 ↛ 977line 966 didn't jump to line 977 because the condition on line 966 was always true
967 # Load all images in parallel for better performance
968 form_data.image = list(await asyncio.gather(*[load_url_image(img) for img in form_data.image]))
969 except HTTPException:
970 raise
971 except Exception as e:
972 raise HTTPException(
973 status_code=400,
974 detail=ERROR_MESSAGES.DEFAULT(e, 'Error loading image'),
975 )
977 try:
978 if image_config.IMAGE_EDIT_ENGINE == 'openai': 978 ↛ 979line 978 didn't jump to line 979 because the condition on line 978 was never true
979 headers = {
980 'Authorization': f'Bearer {image_config.IMAGES_EDIT_OPENAI_API_KEY}',
981 }
983 if ENABLE_FORWARD_USER_INFO_HEADERS:
984 headers = include_user_info_headers(headers, user)
986 data = {
987 'model': model,
988 'prompt': form_data.prompt,
989 **({'n': form_data.n} if form_data.n else {}),
990 **({'size': size} if size else {}),
991 **({'background': form_data.background} if form_data.background else {}),
992 **(
993 {}
994 if re.match(
995 IMAGE_URL_RESPONSE_MODELS_REGEX_PATTERN,
996 image_config.IMAGE_EDIT_MODEL,
997 )
998 else {'response_format': 'b64_json'}
999 ),
1000 }
1002 files = []
1003 if isinstance(form_data.image, str):
1004 image = form_data.image
1005 if ENABLE_OPENAI_IMAGE_EDIT_NORMALIZATION:
1006 image = normalize_openai_edit_image_data_url(image)
1007 files = [get_image_file_item(image)]
1008 elif isinstance(form_data.image, list):
1009 for img in form_data.image:
1010 if ENABLE_OPENAI_IMAGE_EDIT_NORMALIZATION:
1011 img = normalize_openai_edit_image_data_url(img)
1012 files.append(get_image_file_item(img, 'image[]'))
1014 url_search_params = ''
1015 if image_config.IMAGES_EDIT_OPENAI_API_VERSION:
1016 url_search_params += f'?api-version={image_config.IMAGES_EDIT_OPENAI_API_VERSION}'
1018 # Build multipart form data for aiohttp
1019 form = aiohttp.FormData()
1020 for key, value in data.items():
1021 if isinstance(value, dict):
1022 form.add_field(key, JSONCodec.dumps(value))
1023 else:
1024 form.add_field(key, str(value))
1025 for param_name, (filename, file_obj, content_type_val) in files:
1026 form.add_field(
1027 param_name,
1028 file_obj,
1029 filename=filename,
1030 content_type=content_type_val,
1031 )
1033 session = await get_session()
1034 async with session.post(
1035 url=f'{image_config.IMAGES_EDIT_OPENAI_API_BASE_URL}/images/edits{url_search_params}',
1036 headers=headers,
1037 data=form,
1038 ssl=AIOHTTP_CLIENT_SESSION_SSL,
1039 ) as r:
1040 r.raise_for_status()
1041 res = await r.json(content_type=None)
1043 images = []
1044 for image in res['data']: 1044 ↛ 1057line 1044 didn't jump to line 1057 because the loop on line 1044 didn't complete
1045 if image_url := image.get('url', None): 1045 ↛ 1053line 1045 didn't jump to line 1053 because the condition on line 1045 was always true
1046 image_data, content_type = await get_image_data(
1047 image_url,
1048 {k: v for k, v in headers.items() if k != 'Content-Type'}
1049 if _is_same_origin(image_url, image_config.IMAGES_EDIT_OPENAI_API_BASE_URL)
1050 else None,
1051 )
1052 else:
1053 image_data, content_type = await get_image_data(image['b64_json'])
1055 _, image_file = await upload_image(request, image_data, content_type, {**data, **metadata}, user)
1056 images.append(image_file)
1057 return images
1059 elif image_config.IMAGE_EDIT_ENGINE == 'gemini': 1059 ↛ 1060line 1059 didn't jump to line 1060 because the condition on line 1059 was never true
1060 headers = {
1061 'Content-Type': 'application/json',
1062 'x-goog-api-key': image_config.IMAGES_EDIT_GEMINI_API_KEY,
1063 }
1065 model = f'{model}:generateContent'
1066 data = {'contents': [{'parts': [{'text': form_data.prompt}]}]}
1068 if isinstance(form_data.image, str):
1069 data['contents'][0]['parts'].append(
1070 {
1071 'inline_data': {
1072 'mime_type': 'image/png',
1073 'data': form_data.image.split(',', 1)[1],
1074 }
1075 }
1076 )
1077 elif isinstance(form_data.image, list):
1078 data['contents'][0]['parts'].extend(
1079 [
1080 {
1081 'inline_data': {
1082 'mime_type': 'image/png',
1083 'data': image.split(',', 1)[1],
1084 }
1085 }
1086 for image in form_data.image
1087 ]
1088 )
1090 session = await get_session()
1091 async with session.post(
1092 url=f'{image_config.IMAGES_EDIT_GEMINI_API_BASE_URL}/models/{model}',
1093 json=data,
1094 headers=headers,
1095 ssl=AIOHTTP_CLIENT_SESSION_SSL,
1096 ) as r:
1097 r.raise_for_status()
1098 res = await r.json(content_type=None)
1100 images = []
1101 for image in res['candidates']:
1102 for part in image['content']['parts']:
1103 if part.get('inlineData', {}).get('data'):
1104 image_data, content_type = await get_image_data(part['inlineData']['data'])
1105 _, image_file = await upload_image(
1106 request,
1107 image_data,
1108 content_type,
1109 {**data, **metadata},
1110 user,
1111 )
1112 images.append(image_file)
1114 return images
1116 elif image_config.IMAGE_EDIT_ENGINE == 'comfyui': 1116 ↛ 1117line 1116 didn't jump to line 1117 because the condition on line 1116 was never true
1117 try:
1118 files = []
1119 if isinstance(form_data.image, str):
1120 files = [get_image_file_item(form_data.image)]
1121 elif isinstance(form_data.image, list):
1122 for img in form_data.image:
1123 files.append(get_image_file_item(img))
1125 # Upload images to ComfyUI and get their names
1126 comfyui_images = []
1127 for file_item in files:
1128 res = await comfyui_upload_image(
1129 file_item,
1130 image_config.IMAGES_EDIT_COMFYUI_BASE_URL,
1131 image_config.IMAGES_EDIT_COMFYUI_API_KEY,
1132 )
1133 comfyui_images.append(res.get('name', file_item[1][0]))
1134 except Exception as e:
1135 log.debug('Error uploading images to ComfyUI: %s', e)
1136 raise Exception('Failed to upload images to ComfyUI.')
1138 data = {
1139 'image': comfyui_images,
1140 'prompt': form_data.prompt,
1141 **({'width': width} if width is not None else {}),
1142 **({'height': height} if height is not None else {}),
1143 **({'n': form_data.n} if form_data.n else {}),
1144 }
1146 form_data = ComfyUIEditImageForm(
1147 **{
1148 'workflow': ComfyUIWorkflow(
1149 **{
1150 'workflow': image_config.IMAGES_EDIT_COMFYUI_WORKFLOW,
1151 'nodes': image_config.IMAGES_EDIT_COMFYUI_WORKFLOW_NODES,
1152 }
1153 ),
1154 **data,
1155 }
1156 )
1157 res = await comfyui_edit_image(
1158 model,
1159 form_data,
1160 str(uuid.uuid4()),
1161 image_config.IMAGES_EDIT_COMFYUI_BASE_URL,
1162 image_config.IMAGES_EDIT_COMFYUI_API_KEY,
1163 )
1164 log.debug('res: %s', res)
1166 image_urls = set()
1167 for image in res['data']:
1168 image_urls.add(image['url'])
1169 image_urls = list(image_urls)
1171 # Prioritize output type URLs if available
1172 output_type_urls = [url for url in image_urls if 'type=output' in url]
1173 if output_type_urls:
1174 image_urls = output_type_urls
1176 log.debug('Image URLs: %s', image_urls)
1177 images = []
1179 for image_url in image_urls:
1180 headers = None
1181 if image_config.IMAGES_EDIT_COMFYUI_API_KEY:
1182 headers = {'Authorization': f'Bearer {image_config.IMAGES_EDIT_COMFYUI_API_KEY}'}
1184 image_data, content_type = await get_image_data(
1185 image_url,
1186 headers,
1187 trusted_base_url=image_config.IMAGES_EDIT_COMFYUI_BASE_URL,
1188 )
1189 _, image_file = await upload_image(
1190 request,
1191 image_data,
1192 content_type,
1193 {**form_data.model_dump(exclude_none=True), **metadata},
1194 user,
1195 )
1196 images.append(image_file)
1198 return images
1199 except Exception as e:
1200 error = e
1201 if isinstance(e, aiohttp.ClientResponseError):
1202 error = e.message
1204 raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(error))