openrouter-image-generation
Generate or edit images through OpenRouter's multimodal image generation endpoint (`/api/v1/chat/completions`) using OpenRouter-compatible image models. Use for text-to-image or image-to-image requests when the user wants OpenRouter, `OPENROUTER_API_KEY`, model overrides, or provider-specific `image_config` options.
Packaged view
This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.
Install command
npx @skill-hub/cli install openclaw-skills-openrouter-image-generation
Repository
Skill path: skills/bawerlacher/openrouter-image-generation
Generate or edit images through OpenRouter's multimodal image generation endpoint (`/api/v1/chat/completions`) using OpenRouter-compatible image models. Use for text-to-image or image-to-image requests when the user wants OpenRouter, `OPENROUTER_API_KEY`, model overrides, or provider-specific `image_config` options.
Open repositoryBest for
Primary workflow: Ship Full Stack.
Technical facets: Full Stack, Backend.
Target audience: everyone.
License: Unknown.
Original source
Catalog source: SkillHub Club.
Repository owner: openclaw.
This is still a mirrored public skill entry. Review the repository before installing into production workflows.
What it helps with
- Install openrouter-image-generation into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
- Review https://github.com/openclaw/skills before adding openrouter-image-generation to shared team environments
- Use openrouter-image-generation for development workflows
Works across
Favorites: 0.
Sub-skills: 0.
Aggregator: No.
Original source / Raw SKILL.md
---
name: openrouter-image-generation
description: Generate or edit images through OpenRouter's multimodal image generation endpoint (`/api/v1/chat/completions`) using OpenRouter-compatible image models. Use for text-to-image or image-to-image requests when the user wants OpenRouter, `OPENROUTER_API_KEY`, model overrides, or provider-specific `image_config` options.
---
# OpenRouter Image Generation & Editing
Generate new images or edit existing ones using OpenRouter image-capable models via the Chat Completions API.
## Usage
Run the script using absolute path (do NOT cd to the skill directory first):
**Generate new image:**
```bash
# Ensure outbound directory exists first
mkdir -p ~/.openclaw/media/outbound
uv run ~/.openclaw/workspace/skills/openrouter-image-generation/scripts/generate_image.py \
--prompt "your image description" \
--filename "~/.openclaw/media/outbound/output-name.png" \
--model google/gemini-2.5-flash-image \
[--aspect-ratio 16:9] \
[--image-size 2K]
```
**Edit existing image (image-to-image):**
```bash
# Ensure outbound directory exists first
mkdir -p ~/.openclaw/media/outbound
uv run ~/.openclaw/workspace/skills/openrouter-image-generation/scripts/generate_image.py \
--prompt "editing instructions" \
--filename "~/.openclaw/media/outbound/output-name.png" \
--input-image "path/to/input.png" \
--model google/gemini-2.5-flash-image
```
**Important:** Default OpenClaw delivery path is `~/.openclaw/media/outbound/`. Save generated images there so other OpenClaw flows can pick them up easily.
## API Key
The script checks for API key in this order:
1. `--api-key` argument
2. `OPENROUTER_API_KEY` environment variable
Optional OpenRouter attribution headers:
- `--site-url` or `OPENROUTER_SITE_URL`
- `--app-name` or `OPENROUTER_APP_NAME`
## Model + Image Config
- `--model <openrouter-model-id>` is required (no script default)
- Example model: `google/gemini-2.5-flash-image`
- Use `--aspect-ratio` for `image_config.aspect_ratio` (for example `1:1`, `16:9`)
- Use `--image-size` for `image_config.image_size` (`1K`, `2K`, `4K`)
- Use `--image-config-json '{"key":"value"}'` for advanced/provider-specific extras (merged into `image_config`)
Note: OpenRouter docs show `aspect_ratio` and `image_size` as the common image config fields for image generation. Additional keys may exist for specific providers/models (for example Sourceful features). If a request fails, remove unsupported options or switch models.
Note: The script always sends `modalities: ["image", "text"]`. Image-only models (some FLUX variants) may reject this — if you get an unexpected error with a non-Gemini model, this may be the cause. No workaround is currently exposed via CLI args.
## Default Workflow (draft -> iterate -> final)
Goal: iterate quickly before spending time on higher-quality settings.
- Draft: smaller size / faster model
- `--image-size 1K`
- Iterate: adjust prompt in small diffs and keep a new filename each run
- Final: larger size or higher quality if the selected model supports it
- Example: `--image-size 4K --aspect-ratio 16:9`
## Preflight + Common Failures
- Preflight:
- `command -v uv`
- `test -n "$OPENROUTER_API_KEY"` (or pass `--api-key`)
- `test -d ~/.openclaw/media/outbound || mkdir -p ~/.openclaw/media/outbound`
- If editing: `test -f "path/to/input.png"`
- Common failures:
- `Error: No API key provided.` -> set `OPENROUTER_API_KEY` or pass `--api-key`
- `Error loading input image:` -> bad path or unreadable file
- `HTTP 400` with model/image config error -> unsupported model or invalid `image_config.aspect_ratio` / `image_config.image_size`
- `HTTP 401/403` -> invalid key, no model access, or quota/credits issue
- `No image found in response` -> model may not support image output or request format rejected
## Filename Generation
Generate filenames with the pattern: `~/.openclaw/media/outbound/yyyy-mm-dd-hh-mm-ss-name.png`
Examples:
- `~/.openclaw/media/outbound/2026-02-26-14-23-05-product-shot.png`
- `~/.openclaw/media/outbound/2026-02-26-14-25-30-sky-edit.png`
## Prompt Handling
- For generation: pass the user's description as-is unless it is too vague to be actionable.
- For editing: make the requested change explicit and preserve everything else.
Prompt template for precise edits:
- `Change ONLY: <change>. Keep identical: subject, composition/crop, pose, lighting, color palette, background, text, and overall style. Do not add new objects.`
## Output
- Save the first returned image to `~/.openclaw/media/outbound/output-name.png` by default (pass that full path in `--filename`)
- Supports OpenRouter's base64 data URL image responses (`message.images[0].image_url.url`)
- Prints the saved file path
- Do not read the image back unless the user asks
## Examples
**Generate new image:**
```bash
mkdir -p ~/.openclaw/media/outbound
uv run ~/.openclaw/workspace/skills/openrouter-image-generation/scripts/generate_image.py \
--prompt "A cinematic product photo of a matte black mechanical keyboard on a wooden desk, warm window light" \
--filename "~/.openclaw/media/outbound/2026-02-26-14-23-05-keyboard-product-shot.png" \
--model google/gemini-2.5-flash-image \
--aspect-ratio 16:9 \
--image-size 2K
```
**Edit existing image:**
```bash
mkdir -p ~/.openclaw/media/outbound
uv run ~/.openclaw/workspace/skills/openrouter-image-generation/scripts/generate_image.py \
--prompt "Change ONLY: make the sky dramatic with orange sunset clouds. Keep identical: subject, composition, lighting on foreground, and overall style." \
--filename "~/.openclaw/media/outbound/2026-02-26-14-25-30-sunset-sky-edit.png" \
--model google/gemini-2.5-flash-image \
--input-image "original-photo.jpg"
```
## Reference
- OpenRouter docs: https://openrouter.ai/docs/guides/overview/multimodal/image-generation
---
## Skill Companion Files
> Additional files collected from the skill directory layout.
### _meta.json
```json
{
"owner": "bawerlacher",
"slug": "openrouter-image-generation",
"displayName": "Openrouter Image Generation",
"latest": {
"version": "1.0.0",
"publishedAt": 1772089324231,
"commit": "https://github.com/openclaw/skills/commit/abd245995e0c7233a878978d3dff2078b90bfcfa"
},
"history": []
}
```
### scripts/generate_image.py
```python
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# ///
"""
Generate or edit images using OpenRouter's image generation via /chat/completions.
Examples:
uv run generate_image.py --prompt "A red fox in snow" --filename "fox.png" --aspect-ratio 16:9 --image-size 2K
uv run generate_image.py --prompt "Make it watercolor" --filename "fox-edit.png" --input-image "fox.png"
"""
from __future__ import annotations
import argparse
import base64
import json
import mimetypes
import os
import sys
from pathlib import Path
from urllib import error, request
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
ASPECT_RATIO_CHOICES = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"]
IMAGE_SIZE_CHOICES = ["1K", "2K", "4K"]
def get_api_key(provided_key: str | None) -> str | None:
if provided_key:
return provided_key
return os.environ.get("OPENROUTER_API_KEY")
def load_input_image_data_url(image_path: str) -> str:
path = Path(image_path)
if not path.is_file():
raise FileNotFoundError(f"Input image not found: {image_path}")
data = path.read_bytes()
mime_type, _ = mimetypes.guess_type(path.name)
if not mime_type:
mime_type = "application/octet-stream"
encoded = base64.b64encode(data).decode("ascii")
return f"data:{mime_type};base64,{encoded}"
def build_payload(args: argparse.Namespace) -> dict:
if args.input_image:
data_url = load_input_image_data_url(args.input_image)
content: str | list[dict] = [
{"type": "text", "text": args.prompt},
{"type": "image_url", "image_url": {"url": data_url}},
]
else:
# Match OpenRouter's basic image-generation examples for text-to-image.
content = args.prompt
payload: dict = {
"model": args.model,
"modalities": ["image", "text"],
"messages": [{"role": "user", "content": content}],
}
image_config: dict = {}
if args.aspect_ratio:
image_config["aspect_ratio"] = args.aspect_ratio
if args.image_size:
image_config["image_size"] = args.image_size
if args.image_config_json:
try:
extra = json.loads(args.image_config_json)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid --image-config-json: {exc}") from exc
if not isinstance(extra, dict):
raise ValueError("--image-config-json must decode to a JSON object")
image_config.update(extra)
if image_config:
payload["image_config"] = image_config
return payload
def build_headers(args: argparse.Namespace, api_key: str) -> dict[str, str]:
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
site_url = args.site_url or os.environ.get("OPENROUTER_SITE_URL")
app_name = args.app_name or os.environ.get("OPENROUTER_APP_NAME")
if site_url:
headers["HTTP-Referer"] = site_url
if app_name:
headers["X-Title"] = app_name
return headers
def extract_text_message(message: dict) -> str | None:
content = message.get("content")
if isinstance(content, str) and content.strip():
return content.strip()
if isinstance(content, list):
parts: list[str] = []
for item in content:
if not isinstance(item, dict):
continue
text = item.get("text")
if isinstance(text, str) and text.strip():
parts.append(text.strip())
if parts:
return "\n".join(parts)
return None
def extract_first_image_data(response_json: dict) -> tuple[bytes, str | None]:
choices = response_json.get("choices")
if not isinstance(choices, list) or not choices:
raise ValueError("No choices in response")
first = choices[0] or {}
message = first.get("message") or {}
images = message.get("images")
if not isinstance(images, list) or not images:
raise ValueError("No image found in response (message.images missing)")
first_image = images[0] or {}
image_url_obj = first_image.get("image_url") or {}
url = image_url_obj.get("url")
if not isinstance(url, str) or not url:
raise ValueError("No image URL found in response image object")
if not url.startswith("data:"):
raise ValueError("Image URL is not a data URL; remote URLs are not handled by this script")
header, _, payload = url.partition(",")
if not payload or ";base64" not in header:
raise ValueError("Unsupported data URL format in image response")
mime_type = None
if header.startswith("data:"):
mime_type = header[5:].split(";")[0] or None
try:
image_bytes = base64.b64decode(payload)
except Exception as exc:
raise ValueError(f"Failed to decode base64 image data: {exc}") from exc
return image_bytes, mime_type
def api_request(payload: dict, headers: dict[str, str]) -> dict:
body = json.dumps(payload).encode("utf-8")
req = request.Request(OPENROUTER_URL, data=body, headers=headers, method="POST")
try:
with request.urlopen(req, timeout=180) as resp:
raw = resp.read()
except error.HTTPError as exc:
details = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP {exc.code}: {details}") from exc
except error.URLError as exc:
raise RuntimeError(f"Network error: {exc}") from exc
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
text = raw.decode("utf-8", errors="replace")
raise RuntimeError(f"Invalid JSON response: {text[:500]}") from exc
def main() -> int:
parser = argparse.ArgumentParser(
description="Generate or edit images using OpenRouter image generation"
)
parser.add_argument("--prompt", "-p", required=True, help="Image prompt or edit instructions")
parser.add_argument("--filename", "-f", required=True, help="Output filename/path")
parser.add_argument("--input-image", "-i", help="Optional input image path for editing")
parser.add_argument("--model", "-m", required=True, help="OpenRouter model ID (required)")
parser.add_argument(
"--aspect-ratio",
choices=ASPECT_RATIO_CHOICES,
help="image_config.aspect_ratio (e.g. 1:1, 16:9)",
)
parser.add_argument(
"--image-size",
choices=IMAGE_SIZE_CHOICES,
help="image_config.image_size (OpenRouter docs: 1K, 2K, 4K)",
)
parser.add_argument("--image-config-json", help="JSON object merged into image_config")
parser.add_argument("--api-key", "-k", help="OpenRouter API key (overrides OPENROUTER_API_KEY)")
parser.add_argument("--site-url", help="Optional HTTP-Referer header (or OPENROUTER_SITE_URL env var)")
parser.add_argument("--app-name", help="Optional X-Title header (or OPENROUTER_APP_NAME env var)")
args = parser.parse_args()
api_key = get_api_key(args.api_key)
if not api_key:
print("Error: No API key provided.", file=sys.stderr)
print("Please either:", file=sys.stderr)
print(" 1. Provide --api-key argument", file=sys.stderr)
print(" 2. Set OPENROUTER_API_KEY environment variable", file=sys.stderr)
return 1
try:
payload = build_payload(args)
except FileNotFoundError as exc:
print(f"Error loading input image: {exc}", file=sys.stderr)
return 1
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
headers = build_headers(args, api_key)
output_path = Path(args.filename)
output_path.parent.mkdir(parents=True, exist_ok=True)
mode_label = "Editing image" if args.input_image else "Generating image"
print(f"{mode_label} via OpenRouter...")
print(f"Model: {args.model}")
if "image_config" in payload:
print(f"Image config: {json.dumps(payload['image_config'])}")
try:
response_json = api_request(payload, headers)
except RuntimeError as exc:
print(f"Error generating image: {exc}", file=sys.stderr)
return 1
choices = response_json.get("choices") or []
if choices and isinstance(choices[0], dict):
message = choices[0].get("message") or {}
text = extract_text_message(message)
if text:
print(f"Model response: {text}")
try:
image_bytes, mime_type = extract_first_image_data(response_json)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
output_path.write_bytes(image_bytes)
full_path = output_path.resolve()
print(f"Image saved: {full_path}")
if mime_type:
print(f"Detected MIME type: {mime_type}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
```