Back to skills
SkillHub ClubShip Full StackFull StackBackend

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.

Stars
3,110
Hot score
99
Updated
March 20, 2026
Overall rating
C4.0
Composite score
4.0
Best-practice grade
A92.0

Install command

npx @skill-hub/cli install openclaw-skills-openrouter-image-generation

Repository

openclaw/skills

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 repository

Best 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

Claude CodeCodex CLIGemini CLIOpenCode

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())

```

openrouter-image-generation | SkillHub