feat: add Open WebUI as image generation provider

Add native support for Open WebUI's image generation API as a new
image provider option. Open WebUI exposes an OpenAI-like
/v1/images/generations endpoint but with key differences that
require special handling:

- Response is a bare JSON array instead of {"data": [...]}
- Image URLs are relative paths (e.g. /api/v1/files/.../content)
- File downloads require the same Bearer auth token

The implementation uses raw HTTP calls via aiohttp rather than the
OpenAI SDK to handle these differences. No model parameter is sent
since Open WebUI manages the image model in its own admin settings.

Backend changes:
- New OPEN_WEBUI enum value in ImageProvider
- generate_image_open_webui() method in ImageGenerationService
- Environment getters/setters for OPEN_WEBUI_IMAGE_URL and
  OPEN_WEBUI_IMAGE_API_KEY
- UserConfig model and config loading/saving pipeline updated

Frontend changes:
- New "Open WebUI" option in image provider dropdown
- Settings UI with URL and optional API key fields
- Validation, field mappings, and config persistence

Docker:
- OPEN_WEBUI_IMAGE_URL and OPEN_WEBUI_IMAGE_API_KEY added to all
  docker-compose service definitions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christopher Quenneville 2026-04-05 12:27:45 -05:00
parent de7c0930ed
commit f2703ec003
15 changed files with 259 additions and 1 deletions

View file

@ -36,6 +36,8 @@ services:
- DISABLE_ANONYMOUS_TRACKING=${DISABLE_ANONYMOUS_TRACKING}
- COMFYUI_URL=${COMFYUI_URL}
- COMFYUI_WORKFLOW=${COMFYUI_WORKFLOW}
- OPEN_WEBUI_IMAGE_URL=${OPEN_WEBUI_IMAGE_URL}
- OPEN_WEBUI_IMAGE_API_KEY=${OPEN_WEBUI_IMAGE_API_KEY}
production-gpu:
# image: ghcr.io/presenton/presenton:latest
@ -81,7 +83,9 @@ services:
- DISABLE_ANONYMOUS_TRACKING=${DISABLE_ANONYMOUS_TRACKING}
- COMFYUI_URL=${COMFYUI_URL}
- COMFYUI_WORKFLOW=${COMFYUI_WORKFLOW}
- OPEN_WEBUI_IMAGE_URL=${OPEN_WEBUI_IMAGE_URL}
- OPEN_WEBUI_IMAGE_API_KEY=${OPEN_WEBUI_IMAGE_API_KEY}
development:
build:
context: .
@ -118,6 +122,8 @@ services:
- DISABLE_ANONYMOUS_TRACKING=${DISABLE_ANONYMOUS_TRACKING}
- COMFYUI_URL=${COMFYUI_URL}
- COMFYUI_WORKFLOW=${COMFYUI_WORKFLOW}
- OPEN_WEBUI_IMAGE_URL=${OPEN_WEBUI_IMAGE_URL}
- OPEN_WEBUI_IMAGE_API_KEY=${OPEN_WEBUI_IMAGE_API_KEY}
development-gpu:
build:
@ -162,3 +168,5 @@ services:
- DISABLE_ANONYMOUS_TRACKING=${DISABLE_ANONYMOUS_TRACKING}
- COMFYUI_URL=${COMFYUI_URL}
- COMFYUI_WORKFLOW=${COMFYUI_WORKFLOW}
- OPEN_WEBUI_IMAGE_URL=${OPEN_WEBUI_IMAGE_URL}
- OPEN_WEBUI_IMAGE_API_KEY=${OPEN_WEBUI_IMAGE_API_KEY}

View file

@ -9,3 +9,4 @@ class ImageProvider(Enum):
DALLE3 = "dall-e-3"
GPT_IMAGE_1_5 = "gpt-image-1.5"
COMFYUI = "comfyui"
OPEN_WEBUI = "open_webui"

View file

@ -36,6 +36,10 @@ class UserConfig(BaseModel):
COMFYUI_URL: Optional[str] = None
COMFYUI_WORKFLOW: Optional[str] = None
# Open WebUI Image Provider
OPEN_WEBUI_IMAGE_URL: Optional[str] = None
OPEN_WEBUI_IMAGE_API_KEY: Optional[str] = None
# Dalle 3 Quality
DALL_E_3_QUALITY: Optional[str] = None
# Gpt Image 1.5 Quality

View file

@ -12,6 +12,8 @@ from utils.get_env import (
get_dall_e_3_quality_env,
get_gpt_image_1_5_quality_env,
get_pexels_api_key_env,
get_open_webui_image_url_env,
get_open_webui_image_api_key_env,
)
from utils.get_env import get_pixabay_api_key_env
from utils.get_env import get_comfyui_url_env
@ -25,6 +27,7 @@ from utils.image_provider import (
is_nanobanana_pro_selected,
is_dalle3_selected,
is_comfyui_selected,
is_open_webui_selected,
)
import uuid
@ -53,6 +56,8 @@ class ImageGenerationService:
return self.generate_image_openai_gpt_image_1_5
elif is_comfyui_selected():
return self.generate_image_comfyui
elif is_open_webui_selected():
return self.generate_image_open_webui
return None
def is_stock_provider_selected(self):
@ -141,6 +146,88 @@ class ImageGenerationService:
get_gpt_image_1_5_quality_env() or "medium",
)
async def generate_image_open_webui(
self, prompt: str, output_directory: str
) -> str:
base_url = get_open_webui_image_url_env()
if not base_url:
raise ValueError("OPEN_WEBUI_IMAGE_URL environment variable is not set")
base_url = base_url.rstrip("/")
api_key = get_open_webui_image_api_key_env() or ""
from urllib.parse import urlparse
parsed = urlparse(base_url)
origin = f"{parsed.scheme}://{parsed.netloc}"
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload = {
"prompt": prompt,
"n": 1,
"size": "1024x1024",
}
async with aiohttp.ClientSession(trust_env=True) as session:
resp = await session.post(
f"{base_url}/images/generations",
json=payload,
headers=headers,
timeout=aiohttp.ClientTimeout(total=300),
)
if resp.status != 200:
error_text = await resp.text()
raise Exception(
f"Open WebUI image generation returned {resp.status}: {error_text}"
)
body = await resp.json()
# Open WebUI returns a bare [...] array instead of {"data": [...]}.
if isinstance(body, list):
items = body
elif isinstance(body, dict) and "data" in body:
items = body["data"]
else:
raise Exception(f"Unexpected response format: {type(body)}")
if not items:
raise Exception("Open WebUI returned empty results")
item = items[0]
image_path = os.path.join(output_directory, f"{uuid.uuid4()}.png")
if item.get("b64_json"):
with open(image_path, "wb") as f:
f.write(base64.b64decode(item["b64_json"]))
elif item.get("url"):
image_url = item["url"]
# Open WebUI returns relative URLs like /api/v1/files/.../content
if image_url.startswith("/"):
image_url = origin + image_url
dl_headers = {}
if api_key:
dl_headers["Authorization"] = f"Bearer {api_key}"
dl_resp = await session.get(
image_url,
headers=dl_headers,
timeout=aiohttp.ClientTimeout(total=120),
)
if dl_resp.status != 200:
raise Exception(
f"Failed to download image: {dl_resp.status}"
)
with open(image_path, "wb") as f:
f.write(await dl_resp.read())
else:
raise Exception("Open WebUI returned no image data")
return image_path
async def _generate_image_google(
self, prompt: str, output_directory: str, model: str
) -> str:

View file

@ -142,3 +142,12 @@ def get_codex_model_env():
def get_migrate_database_on_startup_env():
return os.getenv("MIGRATE_DATABASE_ON_STARTUP")
# Open WebUI Image Provider
def get_open_webui_image_url_env():
return os.getenv("OPEN_WEBUI_IMAGE_URL")
def get_open_webui_image_api_key_env():
return os.getenv("OPEN_WEBUI_IMAGE_API_KEY")

View file

@ -38,6 +38,10 @@ def is_comfyui_selected() -> bool:
return ImageProvider.COMFYUI == get_selected_image_provider()
def is_open_webui_selected() -> bool:
return ImageProvider.OPEN_WEBUI == get_selected_image_provider()
def get_selected_image_provider() -> ImageProvider | None:
"""
Get the selected image provider from environment variables.

View file

@ -124,3 +124,12 @@ def set_codex_account_id_env(value: str):
def set_codex_model_env(value: str):
os.environ["CODEX_MODEL"] = value
# Open WebUI Image Provider
def set_open_webui_image_url_env(value: str):
os.environ["OPEN_WEBUI_IMAGE_URL"] = value
def set_open_webui_image_api_key_env(value: str):
os.environ["OPEN_WEBUI_IMAGE_API_KEY"] = value

View file

@ -33,6 +33,8 @@ from utils.get_env import (
get_codex_token_expires_env,
get_codex_account_id_env,
get_codex_model_env,
get_open_webui_image_url_env,
get_open_webui_image_api_key_env,
)
from utils.parsers import parse_bool_or_none
from utils.set_env import (
@ -65,6 +67,8 @@ from utils.set_env import (
set_codex_token_expires_env,
set_codex_account_id_env,
set_codex_model_env,
set_open_webui_image_url_env,
set_open_webui_image_api_key_env,
)
@ -133,6 +137,8 @@ def get_user_config():
CODEX_REFRESH_TOKEN=existing_config.CODEX_REFRESH_TOKEN or get_codex_refresh_token_env(),
CODEX_TOKEN_EXPIRES=existing_config.CODEX_TOKEN_EXPIRES or get_codex_token_expires_env(),
CODEX_ACCOUNT_ID=existing_config.CODEX_ACCOUNT_ID or get_codex_account_id_env(),
OPEN_WEBUI_IMAGE_URL=existing_config.OPEN_WEBUI_IMAGE_URL or get_open_webui_image_url_env(),
OPEN_WEBUI_IMAGE_API_KEY=existing_config.OPEN_WEBUI_IMAGE_API_KEY or get_open_webui_image_api_key_env(),
)
@ -196,6 +202,10 @@ def update_env_with_user_config():
set_codex_token_expires_env(user_config.CODEX_TOKEN_EXPIRES)
if user_config.CODEX_ACCOUNT_ID:
set_codex_account_id_env(user_config.CODEX_ACCOUNT_ID)
if user_config.OPEN_WEBUI_IMAGE_URL:
set_open_webui_image_url_env(user_config.OPEN_WEBUI_IMAGE_URL)
if user_config.OPEN_WEBUI_IMAGE_API_KEY:
set_open_webui_image_api_key_env(user_config.OPEN_WEBUI_IMAGE_API_KEY)
def save_codex_tokens_to_user_config() -> None:

View file

@ -262,6 +262,33 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
);
}
// Show Open WebUI configuration
if (provider.value === "open_webui") {
return (
<div className="space-y-4">
<div className='w-[205px]'>
<label className="block text-sm font-medium text-gray-700 mb-2">
Open WebUI URL
</label>
<div className="relative">
<input
type="text"
placeholder="http://localhost:3000/api/v1"
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
value={llmConfig.OPEN_WEBUI_IMAGE_URL || ""}
onChange={(e) => {
input_field_changed(
e.target.value,
"OPEN_WEBUI_IMAGE_URL"
);
}}
/>
</div>
</div>
</div>
);
}
// Show API key input for other providers
return (
<div className=" w-[205px]">
@ -300,6 +327,31 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
{!isImageGenerationDisabled && <div className='flex justify-end items-center mt-[18px]'>
{renderQualitySelector(llmConfig, input_field_changed)}
{llmConfig.IMAGE_PROVIDER === "open_webui" && (
<div className='w-[205px]'>
<label className="block text-sm font-medium text-gray-700 mb-2">
API Key (optional)
</label>
<div className="relative">
<input
type={showApiKey ? 'text' : 'password'}
placeholder="API key"
className="w-full px-4 py-2.5 h-12 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
value={llmConfig.OPEN_WEBUI_IMAGE_API_KEY || ""}
onChange={(e) => {
input_field_changed(e.target.value, "OPEN_WEBUI_IMAGE_API_KEY");
}}
/>
<button
type="button"
onClick={() => setShowApiKey((prev) => !prev)}
className='absolute right-2 top-1/2 -translate-y-1/2 bg-white px-2 py-1 cursor-pointer'
>
{showApiKey ? <Eye className='w-4 h-4 text-gray-500' /> : <EyeOff className='w-4 h-4 text-gray-500' />}
</button>
</div>
</div>
)}
{llmConfig.IMAGE_PROVIDER === "comfyui" && <div className='w-full'>
<label className="block text-sm font-medium text-gray-700 mb-2">
Workflow JSON

View file

@ -91,6 +91,10 @@ export async function POST(request: Request) {
userConfig.USE_CUSTOM_URL === undefined
? existingConfig.USE_CUSTOM_URL
: userConfig.USE_CUSTOM_URL,
OPEN_WEBUI_IMAGE_URL:
userConfig.OPEN_WEBUI_IMAGE_URL || existingConfig.OPEN_WEBUI_IMAGE_URL,
OPEN_WEBUI_IMAGE_API_KEY:
userConfig.OPEN_WEBUI_IMAGE_API_KEY || existingConfig.OPEN_WEBUI_IMAGE_API_KEY,
CODEX_MODEL: userConfig.CODEX_MODEL || existingConfig.CODEX_MODEL,
CODEX_ACCESS_TOKEN: existingConfig.CODEX_ACCESS_TOKEN,
CODEX_REFRESH_TOKEN: existingConfig.CODEX_REFRESH_TOKEN,

View file

@ -266,6 +266,56 @@ const ImageSelectionConfig = ({ isImageGenerationDisabled, openImageProviderSele
return <></>;
}
// Show Open WebUI configuration
if (provider.value === "open_webui") {
return (
<div className="space-y-4 w-[295px]">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Open WebUI URL
</label>
<div className="relative">
<input
type="text"
placeholder="http://localhost:3000/api/v1"
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
value={llmConfig.OPEN_WEBUI_IMAGE_URL || ""}
onChange={(e) => {
input_field_changed(
e.target.value,
"open_webui_image_url"
);
}}
/>
</div>
<p className="mt-2 text-sm text-gray-500 flex items-center gap-2">
<span className="block w-1 h-1 rounded-full bg-gray-400"></span>
Image model is configured in Open WebUI admin settings
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
API Key (optional)
</label>
<div className="relative">
<input
type="text"
placeholder="Open WebUI API key"
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
value={llmConfig.OPEN_WEBUI_IMAGE_API_KEY || ""}
onChange={(e) => {
input_field_changed(
e.target.value,
"open_webui_image_api_key"
);
}}
/>
</div>
</div>
</div>
);
}
// Show ComfyUI configuration
if (provider.value === "comfyui") {
return (

View file

@ -32,6 +32,10 @@ export interface LLMConfig {
COMFYUI_URL?: string;
COMFYUI_WORKFLOW?: string;
// Open WebUI Image Provider
OPEN_WEBUI_IMAGE_URL?: string;
OPEN_WEBUI_IMAGE_API_KEY?: string;
// Dalle 3 Quality
DALL_E_3_QUALITY?: string;
// GPT Image 1.5 Quality

View file

@ -90,6 +90,15 @@ export const IMAGE_PROVIDERS: Record<string, ImageProviderOption> = {
apiKeyField: "COMFYUI_URL",
apiKeyFieldLabel: "ComfyUI Server URL",
},
open_webui: {
value: "open_webui",
label: "Open WebUI",
description: "Use your Open WebUI server for image generation",
icon: "/icons/open-webui.png",
requiresApiKey: false,
apiKeyField: "OPEN_WEBUI_IMAGE_URL",
apiKeyFieldLabel: "Open WebUI URL",
},
};
export const LLM_PROVIDERS: Record<string, LLMProviderOption> = {

View file

@ -52,6 +52,8 @@ export const updateLLMConfig = (
comfyui_workflow: "COMFYUI_WORKFLOW",
dall_e_3_quality: "DALL_E_3_QUALITY",
gpt_image_1_5_quality: "GPT_IMAGE_1_5_QUALITY",
open_webui_image_url: "OPEN_WEBUI_IMAGE_URL",
open_webui_image_api_key: "OPEN_WEBUI_IMAGE_API_KEY",
codex_model: "CODEX_MODEL",
};

View file

@ -102,6 +102,11 @@ export const getLLMConfigValidationError = (
return "ComfyUI server URL is required.";
}
break;
case "open_webui":
if (!isProvided(llmConfig.OPEN_WEBUI_IMAGE_URL)) {
return "Open WebUI URL is required.";
}
break;
default:
return "Select a valid image provider.";
}