Skill: bawerlacher/openrouter-image-generation (version 1.0.0)

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

path: scripts/generate_image.py
content: #!/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()

    [REDACTED_SECRET])
    if not [REDACTED_SECRET]"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())


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: suspicious | SkillSpector: clean (LOW)