Add ComfyUI image generation (/v1/images/generations)

OpenAI-compatible image endpoint that proxies to a ComfyUI server: fills a
workflow template with per-request params (prompt/size/n/seed/steps/checkpoint),
submits to ComfyUI, polls history, fetches images, returns OpenAI image shape.

- router.py: _handle_image + fill_workflow + route; env ROUTER_COMFYUI /
  ROUTER_COMFY_CHECKPOINT / ROUTER_COMFY_WORKFLOW
- comfyui-workflow.json: default SD/SDXL text2img template with {{SENTINELS}}
- docs/IMAGES.md + README: config, usage, custom workflows
- verified: templating + clean error when ComfyUI is down; end-to-end pending
  a live ComfyUI

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Joseph Costa
2026-07-05 03:14:51 -05:00
co-authored by Claude Opus 4.8
parent 5bd8142d30
commit 2f95e6be96
4 changed files with 190 additions and 1 deletions
+4
View File
@@ -98,6 +98,8 @@ Everything is environment variables (read by `run.sh` / the router):
| `ROUTER_AUDIO_MODEL` | `gemma4:e4b` | model used for audio/transcription | | `ROUTER_AUDIO_MODEL` | `gemma4:e4b` | model used for audio/transcription |
| `ROUTER_API_KEY` | *(unset)* | if set, require this Bearer token on /v1 | | `ROUTER_API_KEY` | *(unset)* | if set, require this Bearer token on /v1 |
| `ROUTER_UNCENSORED_KEY` | *(unset)* | Bearer token that forces the uncensored fleet | | `ROUTER_UNCENSORED_KEY` | *(unset)* | Bearer token that forces the uncensored fleet |
| `ROUTER_COMFYUI` | `http://127.0.0.1:8188` | ComfyUI server for image generation |
| `ROUTER_COMFY_CHECKPOINT` | *(unset)* | checkpoint filename for the default image workflow |
> ⚠️ `ROUTER_OLLAMA` is the router's **client** target — keep it `127.0.0.1`. > ⚠️ `ROUTER_OLLAMA` is the router's **client** target — keep it `127.0.0.1`.
> Do **not** confuse it with Ollama's own `OLLAMA_HOST` server-bind variable. > Do **not** confuse it with Ollama's own `OLLAMA_HOST` server-bind variable.
@@ -117,6 +119,7 @@ main thing to customize for your machine. See [docs/ROUTING.md](docs/ROUTING.md)
| POST | `/v1/messages` | Anthropic Messages (Claude Code) → LiteLLM | | POST | `/v1/messages` | Anthropic Messages (Claude Code) → LiteLLM |
| POST | `/v1/audio/transcriptions` | Whisper-shaped speech-to-text | | POST | `/v1/audio/transcriptions` | Whisper-shaped speech-to-text |
| POST | `/v1/audio/translations` | speech → English text | | POST | `/v1/audio/translations` | speech → English text |
| POST | `/v1/images/generations` | OpenAI-shaped image gen → ComfyUI |
| GET | `/v1/models` | lists route targets | | GET | `/v1/models` | lists route targets |
| GET | `/`, `/api/version`, `/api/tags`, `/api/ps`, POST `/api/show` | Ollama-native probes (so `ollama launch` accepts the router) | | GET | `/`, `/api/version`, `/api/tags`, `/api/ps`, POST `/api/show` | Ollama-native probes (so `ollama launch` accepts the router) |
| GET | `/healthz` | liveness | | GET | `/healthz` | liveness |
@@ -126,6 +129,7 @@ main thing to customize for your machine. See [docs/ROUTING.md](docs/ROUTING.md)
- [docs/DEPLOY.md](docs/DEPLOY.md) — run as a service, expose it, tunnels, Caddy - [docs/DEPLOY.md](docs/DEPLOY.md) — run as a service, expose it, tunnels, Caddy
- [docs/ROUTING.md](docs/ROUTING.md) — how routing decides + customizing the map - [docs/ROUTING.md](docs/ROUTING.md) — how routing decides + customizing the map
- [docs/AUDIO.md](docs/AUDIO.md) — voice input & transcription - [docs/AUDIO.md](docs/AUDIO.md) — voice input & transcription
- [docs/IMAGES.md](docs/IMAGES.md) — image generation via ComfyUI
- [docs/BENCHMARKS.md](docs/BENCHMARKS.md) — the benchmark harnesses & sample results - [docs/BENCHMARKS.md](docs/BENCHMARKS.md) — the benchmark harnesses & sample results
## Security ## Security
+41
View File
@@ -0,0 +1,41 @@
{
"3": {
"class_type": "KSampler",
"inputs": {
"seed": "{{SEED}}",
"steps": "{{STEPS}}",
"cfg": 7,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["5", 0]
}
},
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": { "ckpt_name": "{{CHECKPOINT}}" }
},
"5": {
"class_type": "EmptyLatentImage",
"inputs": { "width": "{{WIDTH}}", "height": "{{HEIGHT}}", "batch_size": "{{BATCH}}" }
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": { "text": "{{PROMPT}}", "clip": ["4", 1] }
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": { "text": "{{NEGATIVE}}", "clip": ["4", 1] }
},
"8": {
"class_type": "VAEDecode",
"inputs": { "samples": ["3", 0], "vae": ["4", 2] }
},
"9": {
"class_type": "SaveImage",
"inputs": { "filename_prefix": "router", "images": ["8", 0] }
}
}
+62
View File
@@ -0,0 +1,62 @@
# Image generation (ComfyUI)
The router exposes an OpenAI-compatible **`POST /v1/images/generations`** endpoint
backed by a [ComfyUI](https://github.com/comfyanonymous/ComfyUI) server. It submits
a workflow to ComfyUI, polls until it finishes, fetches the images, and returns them
in OpenAI's image-response shape — so any OpenAI image client works.
## Configure
| Env var | Default | Purpose |
|---|---|---|
| `ROUTER_COMFYUI` | `http://127.0.0.1:8188` | ComfyUI server URL |
| `ROUTER_COMFY_CHECKPOINT` | *(unset)* | your checkpoint filename, e.g. `sd_xl_base_1.0.safetensors` |
| `ROUTER_COMFY_WORKFLOW` | `comfyui-workflow.json` | the workflow template (see below) |
At minimum, **set `ROUTER_COMFY_CHECKPOINT`** to a model that exists in your
ComfyUI (`ComfyUI/models/checkpoints/`), or hardcode it into the template's
`ckpt_name`. Then restart the router.
## Use
```bash
curl http://<host>:8080/v1/images/generations -H 'content-type: application/json' -d '{
"prompt": "a red fox in a snowy forest, cinematic lighting",
"n": 1, "size": "1024x1024"
}'
# -> {"created": ..., "data": [{"b64_json": "<png>"}]}
```
- `response_format`: `b64_json` (default) or `url` (returns a `data:` URL).
- Extra (non-OpenAI) knobs the router honors: `negative_prompt`, `steps`.
- Works with the OpenAI SDK: `images.generate(prompt=..., size=..., n=...)`.
## The workflow template
`comfyui-workflow.json` is a ComfyUI **API-format** workflow with string sentinels
the router substitutes per request:
| Sentinel | Filled with |
|---|---|
| `{{PROMPT}}` | the request prompt |
| `{{NEGATIVE}}` | `negative_prompt` |
| `{{WIDTH}}` / `{{HEIGHT}}` | parsed from `size` |
| `{{BATCH}}` | `n` |
| `{{SEED}}` | a fresh seed |
| `{{STEPS}}` | `steps` (default 25) |
| `{{CHECKPOINT}}` | `ROUTER_COMFY_CHECKPOINT` |
The default is a standard SD/SDXL text-to-image graph. **To use your own** (Flux,
a fancier pipeline, LoRAs, upscalers, etc.): build it in ComfyUI, export via
**Save (API Format)**, then replace the values you want driven by requests with the
sentinels above. Point `ROUTER_COMFY_WORKFLOW` at your file. Anything without a
sentinel stays fixed.
## Notes
- **Not yet verified end-to-end** — it was written before ComfyUI was installed.
Once your ComfyUI is up (`ROUTER_COMFYUI` reachable, checkpoint set), test with
the curl above; if a node name/shape differs, adjust the template.
- Generation can take a while; the router polls up to ~10 min per request.
- Video: ComfyUI video workflows (AnimateDiff, SVD, etc.) also export to API format
and return frames/files — the same endpoint can drive them, but the response
mapping for multi-frame/video output may need a tweak. Ask when you get there.
+83 -1
View File
@@ -9,7 +9,7 @@ forwards straight to Ollama's own OpenAI API. Point every client at
On upstream failure the router retries once with a mapped fallback model. On upstream failure the router retries once with a mapped fallback model.
Response headers x-router-model / x-router-decided-by report the pick. Response headers x-router-model / x-router-decided-by report the pick.
""" """
import os, re, json, time, base64, urllib.request, urllib.error import os, re, json, time, base64, urllib.request, urllib.error, urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
OLLAMA = os.environ.get("ROUTER_OLLAMA", "http://127.0.0.1:11434").rstrip("/") # client target; NOT OLLAMA_HOST (that's the server bind addr, e.g. 0.0.0.0) OLLAMA = os.environ.get("ROUTER_OLLAMA", "http://127.0.0.1:11434").rstrip("/") # client target; NOT OLLAMA_HOST (that's the server bind addr, e.g. 0.0.0.0)
@@ -18,6 +18,11 @@ HOST = os.environ.get("ROUTER_HOST", "0.0.0.0") # 0.0.0.0 = reachable on t
PORT = int(os.environ.get("ROUTER_PORT", "8080")) PORT = int(os.environ.get("ROUTER_PORT", "8080"))
API_KEY = os.environ.get("ROUTER_API_KEY", "").strip() # if set, require Bearer token API_KEY = os.environ.get("ROUTER_API_KEY", "").strip() # if set, require Bearer token
AUDIO_MODEL = os.environ.get("ROUTER_AUDIO_MODEL", "gemma4:e4b") # audio only works on official Gemma 4 AUDIO_MODEL = os.environ.get("ROUTER_AUDIO_MODEL", "gemma4:e4b") # audio only works on official Gemma 4
# ComfyUI image generation (OpenAI /v1/images/generations -> ComfyUI submit/poll/fetch)
COMFYUI = os.environ.get("ROUTER_COMFYUI", "http://127.0.0.1:8188").rstrip("/")
COMFY_WORKFLOW = os.environ.get("ROUTER_COMFY_WORKFLOW",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "comfyui-workflow.json"))
COMFY_CHECKPOINT = os.environ.get("ROUTER_COMFY_CHECKPOINT", "") # your ComfyUI checkpoint filename
M = { M = {
"fast": "qwen3:8b", "fast": "qwen3:8b",
@@ -153,6 +158,17 @@ def parse_multipart(body, boundary):
return fields return fields
def fill_workflow(node, subs):
# recursively replace string sentinels ("{{PROMPT}}" etc.) with typed values
if isinstance(node, dict):
return {k: fill_workflow(v, subs) for k, v in node.items()}
if isinstance(node, list):
return [fill_workflow(x, subs) for x in node]
if isinstance(node, str) and node in subs:
return subs[node]
return node
def total_len(messages): def total_len(messages):
n = 0 n = 0
for m in messages: for m in messages:
@@ -387,6 +403,65 @@ class Handler(BaseHTTPRequestHandler):
return self._text(200, text) return self._text(200, text)
return self._json(200, {"text": text}) return self._json(200, {"text": text})
def _handle_image(self, body):
# OpenAI /v1/images/generations -> ComfyUI (submit workflow, poll, fetch images)
if not self._authed():
return self._json(401, {"error": "unauthorized"})
prompt = (body.get("prompt") or "").strip()
if not prompt:
return self._json(400, {"error": "missing 'prompt'"})
n = max(1, int(body.get("n", 1) or 1))
try:
w, h = (int(x) for x in str(body.get("size", "1024x1024")).lower().split("x"))
except Exception:
w, h = 1024, 1024
fmt = body.get("response_format", "b64_json")
try:
with open(COMFY_WORKFLOW) as f:
wf = json.load(f)
except Exception as e:
return self._json(500, {"error": f"workflow '{COMFY_WORKFLOW}' unreadable: {e}"})
subs = {"{{PROMPT}}": prompt, "{{NEGATIVE}}": body.get("negative_prompt", ""),
"{{WIDTH}}": w, "{{HEIGHT}}": h, "{{BATCH}}": n,
"{{SEED}}": int(time.time() * 1000) % 2_000_000_000,
"{{STEPS}}": int(body.get("steps", 25)),
"{{CHECKPOINT}}": COMFY_CHECKPOINT or "{{CHECKPOINT}}"}
wf = fill_workflow(wf, subs)
print(f"[router] image -> comfyui ({w}x{h} n={n})", flush=True)
try:
req = urllib.request.Request(COMFYUI + "/prompt",
data=json.dumps({"prompt": wf}).encode(),
headers={"content-type": "application/json"})
pid = json.loads(urllib.request.urlopen(req, timeout=30).read()).get("prompt_id")
if not pid:
return self._json(502, {"error": "comfyui returned no prompt_id"})
images, deadline = [], time.time() + 600
while time.time() < deadline:
time.sleep(1.5)
hist = json.loads(urllib.request.urlopen(COMFYUI + "/history/" + pid, timeout=15).read())
entry = hist.get(pid)
if entry and entry.get("outputs"):
for node in entry["outputs"].values():
images.extend(node.get("images", []))
break
if not images:
return self._json(504, {"error": "comfyui timed out producing images"})
out = []
for im in images[:n]:
q = urllib.parse.urlencode({"filename": im.get("filename", ""),
"subfolder": im.get("subfolder", ""),
"type": im.get("type", "output")})
data = urllib.request.urlopen(COMFYUI + "/view?" + q, timeout=60).read()
b64 = base64.b64encode(data).decode()
out.append({"b64_json": b64} if fmt == "b64_json"
else {"url": "data:image/png;base64," + b64})
return self._json(200, {"created": int(time.time()), "data": out},
extra={"x-router-model": "comfyui"})
except urllib.error.HTTPError as e:
return self._json(e.code, {"error": f"comfyui {e.code}: {e.read().decode()[:300]}"})
except Exception as e:
return self._json(502, {"error": f"comfyui unreachable at {COMFYUI}: {e}"})
def _handle_audio(self, body): def _handle_audio(self, body):
# OpenAI request carrying audio -> Ollama native /api/chat (images field), Gemma 4 # OpenAI request carrying audio -> Ollama native /api/chat (images field), Gemma 4
model = AUDIO_MODEL model = AUDIO_MODEL
@@ -442,6 +517,13 @@ class Handler(BaseHTTPRequestHandler):
return self._handle_transcription() return self._handle_transcription()
if path == "/v1/audio/translations": if path == "/v1/audio/translations":
return self._handle_transcription(translate=True) return self._handle_transcription(translate=True)
if path == "/v1/images/generations":
length = int(self.headers.get("content-length", 0) or 0)
try:
ibody = json.loads(self.rfile.read(length) or b"{}")
except Exception as e:
return self._json(400, {"error": f"bad request: {e}"})
return self._handle_image(ibody)
if path.startswith("/v1/messages"): if path.startswith("/v1/messages"):
return self._proxy_anthropic(path) return self._proxy_anthropic(path)
if path == "/api/show": # ollama launch model probe (read-only) if path == "/api/show": # ollama launch model probe (read-only)