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:
parent
de7c0930ed
commit
f2703ec003
15 changed files with 259 additions and 1 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue