Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions paperbanana/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class Settings(BaseSettings):

# API Keys (loaded from environment)
google_api_key: Optional[str] = Field(default=None, alias="GOOGLE_API_KEY")
openrouter_api_key: Optional[str] = Field(default=None, alias="OPENROUTER_API_KEY")

# SSL
skip_ssl_verification: bool = Field(default=False, alias="SKIP_SSL_VERIFICATION")
Expand Down
137 changes: 137 additions & 0 deletions paperbanana/providers/image_gen/openrouter_imagen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""OpenRouter image generation provider — uses any image model via the OpenAI-compatible API."""

from __future__ import annotations

import base64
import re
from io import BytesIO
from typing import Optional

import structlog
from PIL import Image
from tenacity import retry, stop_after_attempt, wait_exponential

from paperbanana.providers.base import ImageGenProvider

logger = structlog.get_logger()


class OpenRouterImageGen(ImageGenProvider):
"""Image generation routed through OpenRouter.

Talks to models that support ``modalities: ["image", "text"]``
(e.g. google/gemini-3-pro-image-preview) and returns a PIL Image
decoded from the base64 response.

Get an API key at https://openrouter.ai/keys
"""

def __init__(
self,
api_key: Optional[str] = None,
model: str = "google/gemini-3-pro-image-preview",
):
self._api_key = api_key
self._model = model
self._client = None

@property
def name(self) -> str:
return "openrouter_imagen"

@property
def model_name(self) -> str:
return self._model

def _get_client(self):
"""Lazy-init an httpx client pointed at the OpenRouter API."""
if self._client is None:
import httpx

self._client = httpx.Client(
base_url="https://openrouter.ai/api/v1",
headers={
"Authorization": f"Bearer {self._api_key}",
"HTTP-Referer": "https://github.com/llmsresearch/paperbanana",
"X-Title": "PaperBanana",
},
# Image generation can take a while
timeout=180.0,
)
return self._client

def is_available(self) -> bool:
return self._api_key is not None

def _aspect_ratio_hint(self, width: int, height: int) -> str:
"""Turn pixel dimensions into a human-readable aspect ratio hint for the prompt."""
ratio = width / height
if ratio > 1.5:
return "wide landscape format (16:9)"
if ratio > 1.2:
return "landscape format (3:2)"
if ratio < 0.67:
return "tall portrait format (9:16)"
if ratio < 0.83:
return "portrait format (2:3)"
return "square format (1:1)"

@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=2, max=30))
async def generate(
self,
prompt: str,
negative_prompt: Optional[str] = None,
width: int = 1024,
height: int = 1024,
seed: Optional[int] = None,
) -> Image.Image:
client = self._get_client()

# OpenRouter doesn't have native aspect-ratio params like the Google SDK,
# so we bake the desired format into the prompt itself.
aspect_hint = self._aspect_ratio_hint(width, height)
full_prompt = f"{prompt}\n\nGenerate this as a {aspect_hint} image."
if negative_prompt:
full_prompt += f"\n\nAvoid: {negative_prompt}"

payload = {
"model": self._model,
"messages": [
{"role": "user", "content": full_prompt},
],
# This tells OpenRouter we want an image back, not just text
"modalities": ["image", "text"],
}

if seed is not None:
payload["seed"] = seed

response = client.post("/chat/completions", json=payload)
response.raise_for_status()
data = response.json()

message = data["choices"][0]["message"]

# Primary path: images come as base64 data-URLs in the "images" array
images = message.get("images", [])
if images:
for img_entry in images:
url = img_entry.get("image_url", {}).get("url", "")
if url.startswith("data:image/"):
b64_data = url.split(",", 1)[1]
image_bytes = base64.b64decode(b64_data)
return Image.open(BytesIO(image_bytes))

# Fallback: some models inline the base64 data directly in the text content
content = message.get("content", "")
if "data:image/" in content:
match = re.search(r"data:image/[^;]+;base64,([A-Za-z0-9+/=]+)", content)
if match:
image_bytes = base64.b64decode(match.group(1))
return Image.open(BytesIO(image_bytes))

logger.error("No image data in OpenRouter response", model=self._model)
raise ValueError(
f"OpenRouter response for {self._model} did not contain image data. "
f"Content preview: {content[:200]}"
)
20 changes: 18 additions & 2 deletions paperbanana/providers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,15 @@ def create_vlm(settings: Settings) -> VLMProvider:
api_key=settings.google_api_key,
model=settings.vlm_model,
)
elif provider == "openrouter":
from paperbanana.providers.vlm.openrouter import OpenRouterVLM

return OpenRouterVLM(
api_key=settings.openrouter_api_key,
model=settings.vlm_model,
)
else:
raise ValueError(f"Unknown VLM provider: {provider}. Available: gemini")
raise ValueError(f"Unknown VLM provider: {provider}. Available: gemini, openrouter")

@staticmethod
def create_image_gen(settings: Settings) -> ImageGenProvider:
Expand All @@ -42,5 +49,14 @@ def create_image_gen(settings: Settings) -> ImageGenProvider:
api_key=settings.google_api_key,
model=settings.image_model,
)
elif provider == "openrouter_imagen":
from paperbanana.providers.image_gen.openrouter_imagen import OpenRouterImageGen

return OpenRouterImageGen(
api_key=settings.openrouter_api_key,
model=settings.image_model,
)
else:
raise ValueError(f"Unknown image provider: {provider}. Available: google_imagen")
raise ValueError(
f"Unknown image provider: {provider}. Available: google_imagen, openrouter_imagen"
)
112 changes: 112 additions & 0 deletions paperbanana/providers/vlm/openrouter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""OpenRouter VLM provider — OpenAI-compatible API for any model."""

from __future__ import annotations

import re

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import

  • re isn't used anywhere in this file (it's used in the image gen provider but not here). This will fail ruff check with F401.

from typing import Optional

import structlog
from PIL import Image
from tenacity import retry, stop_after_attempt, wait_exponential

from paperbanana.core.utils import image_to_base64
from paperbanana.providers.base import VLMProvider

logger = structlog.get_logger()


class OpenRouterVLM(VLMProvider):
"""VLM provider that routes through OpenRouter's OpenAI-compatible API.

Works with any model on OpenRouter (Gemini, Claude, GPT, Llama, etc.).
Get an API key at https://openrouter.ai/keys
"""

def __init__(
self,
api_key: Optional[str] = None,
model: str = "google/gemini-3-flash-preview",
):
self._api_key = api_key
self._model = model
self._client = None

@property
def name(self) -> str:
return "openrouter"

@property
def model_name(self) -> str:
return self._model

def _get_client(self):
"""Lazy-init an httpx client pointed at the OpenRouter API."""
if self._client is None:
import httpx

self._client = httpx.Client(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking suggestion: since generate() is an async method and httpx has a native async API, it would be cleaner to use httpx.AsyncClient with await client.post(...) here and in the image gen provider. The current synchronous client works but will block the event loop during requests. Not a hard requirement since the existing Gemini providers also use synchronous SDK calls inside async methods, but httpx makes async easy so it would be a nice improvement.

base_url="https://openrouter.ai/api/v1",
headers={
"Authorization": f"Bearer {self._api_key}",
"HTTP-Referer": "https://github.com/llmsresearch/paperbanana",
"X-Title": "PaperBanana",
},
timeout=120.0,
)
return self._client

def is_available(self) -> bool:
return self._api_key is not None

@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=2, max=30))
async def generate(
self,
prompt: str,
images: Optional[list[Image.Image]] = None,
system_prompt: Optional[str] = None,
temperature: float = 1.0,
max_tokens: int = 4096,
response_format: Optional[str] = None,
) -> str:
client = self._get_client()

messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})

# Build multimodal content array (vision images + text)
content = []
if images:
for img in images:
b64 = image_to_base64(img)
content.append(
{
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
}
)
content.append({"type": "text", "text": prompt})
messages.append({"role": "user", "content": content})

payload = {
"model": self._model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}

if response_format == "json":
payload["response_format"] = {"type": "json_object"}

response = client.post("/chat/completions", json=payload)
response.raise_for_status()

data = response.json()
text = data["choices"][0]["message"]["content"]

logger.debug(
"OpenRouter response",
model=self._model,
usage=data.get("usage"),
)
return text