Skip to content

Commit 8b39005

Browse files
authored
Merge pull request #5 from nicremo/feat/openrouter-provider
feat: add OpenRouter provider support
2 parents afaa20a + 18f74d9 commit 8b39005

4 files changed

Lines changed: 267 additions & 2 deletions

File tree

paperbanana/core/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ class Settings(BaseSettings):
7373

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

7778
# SSL
7879
skip_ssl_verification: bool = Field(default=False, alias="SKIP_SSL_VERIFICATION")
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""OpenRouter image generation provider — uses any image model via the OpenAI-compatible API."""
2+
3+
from __future__ import annotations
4+
5+
import base64
6+
import re
7+
from io import BytesIO
8+
from typing import Optional
9+
10+
import structlog
11+
from PIL import Image
12+
from tenacity import retry, stop_after_attempt, wait_exponential
13+
14+
from paperbanana.providers.base import ImageGenProvider
15+
16+
logger = structlog.get_logger()
17+
18+
19+
class OpenRouterImageGen(ImageGenProvider):
20+
"""Image generation routed through OpenRouter.
21+
22+
Talks to models that support ``modalities: ["image", "text"]``
23+
(e.g. google/gemini-3-pro-image-preview) and returns a PIL Image
24+
decoded from the base64 response.
25+
26+
Get an API key at https://openrouter.ai/keys
27+
"""
28+
29+
def __init__(
30+
self,
31+
api_key: Optional[str] = None,
32+
model: str = "google/gemini-3-pro-image-preview",
33+
):
34+
self._api_key = api_key
35+
self._model = model
36+
self._client = None
37+
38+
@property
39+
def name(self) -> str:
40+
return "openrouter_imagen"
41+
42+
@property
43+
def model_name(self) -> str:
44+
return self._model
45+
46+
def _get_client(self):
47+
"""Lazy-init an async httpx client pointed at the OpenRouter API."""
48+
if self._client is None:
49+
import httpx
50+
51+
self._client = httpx.AsyncClient(
52+
base_url="https://openrouter.ai/api/v1",
53+
headers={
54+
"Authorization": f"Bearer {self._api_key}",
55+
"HTTP-Referer": "https://github.com/llmsresearch/paperbanana",
56+
"X-Title": "PaperBanana",
57+
},
58+
# Image generation can take a while
59+
timeout=180.0,
60+
)
61+
return self._client
62+
63+
def is_available(self) -> bool:
64+
return self._api_key is not None
65+
66+
def _aspect_ratio_hint(self, width: int, height: int) -> str:
67+
"""Turn pixel dimensions into a human-readable aspect ratio hint for the prompt."""
68+
ratio = width / height
69+
if ratio > 1.5:
70+
return "wide landscape format (16:9)"
71+
if ratio > 1.2:
72+
return "landscape format (3:2)"
73+
if ratio < 0.67:
74+
return "tall portrait format (9:16)"
75+
if ratio < 0.83:
76+
return "portrait format (2:3)"
77+
return "square format (1:1)"
78+
79+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=2, max=30))
80+
async def generate(
81+
self,
82+
prompt: str,
83+
negative_prompt: Optional[str] = None,
84+
width: int = 1024,
85+
height: int = 1024,
86+
seed: Optional[int] = None,
87+
) -> Image.Image:
88+
client = self._get_client()
89+
90+
# OpenRouter doesn't have native aspect-ratio params like the Google SDK,
91+
# so we bake the desired format into the prompt itself.
92+
aspect_hint = self._aspect_ratio_hint(width, height)
93+
full_prompt = f"{prompt}\n\nGenerate this as a {aspect_hint} image."
94+
if negative_prompt:
95+
full_prompt += f"\n\nAvoid: {negative_prompt}"
96+
97+
payload = {
98+
"model": self._model,
99+
"messages": [
100+
{"role": "user", "content": full_prompt},
101+
],
102+
# This tells OpenRouter we want an image back, not just text
103+
"modalities": ["image", "text"],
104+
}
105+
106+
if seed is not None:
107+
payload["seed"] = seed
108+
109+
response = await client.post("/chat/completions", json=payload)
110+
response.raise_for_status()
111+
data = response.json()
112+
113+
message = data["choices"][0]["message"]
114+
115+
# Primary path: images come as base64 data-URLs in the "images" array
116+
images = message.get("images", [])
117+
if images:
118+
for img_entry in images:
119+
url = img_entry.get("image_url", {}).get("url", "")
120+
if url.startswith("data:image/"):
121+
b64_data = url.split(",", 1)[1]
122+
image_bytes = base64.b64decode(b64_data)
123+
return Image.open(BytesIO(image_bytes))
124+
125+
# Fallback: some models inline the base64 data directly in the text content
126+
content = message.get("content", "")
127+
if "data:image/" in content:
128+
match = re.search(r"data:image/[^;]+;base64,([A-Za-z0-9+/=]+)", content)
129+
if match:
130+
image_bytes = base64.b64decode(match.group(1))
131+
return Image.open(BytesIO(image_bytes))
132+
133+
logger.error("No image data in OpenRouter response", model=self._model)
134+
raise ValueError(
135+
f"OpenRouter response for {self._model} did not contain image data. "
136+
f"Content preview: {content[:200]}"
137+
)

paperbanana/providers/registry.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,15 @@ def create_vlm(settings: Settings) -> VLMProvider:
2626
api_key=settings.google_api_key,
2727
model=settings.vlm_model,
2828
)
29+
elif provider == "openrouter":
30+
from paperbanana.providers.vlm.openrouter import OpenRouterVLM
31+
32+
return OpenRouterVLM(
33+
api_key=settings.openrouter_api_key,
34+
model=settings.vlm_model,
35+
)
2936
else:
30-
raise ValueError(f"Unknown VLM provider: {provider}. Available: gemini")
37+
raise ValueError(f"Unknown VLM provider: {provider}. Available: gemini, openrouter")
3138

3239
@staticmethod
3340
def create_image_gen(settings: Settings) -> ImageGenProvider:
@@ -42,5 +49,14 @@ def create_image_gen(settings: Settings) -> ImageGenProvider:
4249
api_key=settings.google_api_key,
4350
model=settings.image_model,
4451
)
52+
elif provider == "openrouter_imagen":
53+
from paperbanana.providers.image_gen.openrouter_imagen import OpenRouterImageGen
54+
55+
return OpenRouterImageGen(
56+
api_key=settings.openrouter_api_key,
57+
model=settings.image_model,
58+
)
4559
else:
46-
raise ValueError(f"Unknown image provider: {provider}. Available: google_imagen")
60+
raise ValueError(
61+
f"Unknown image provider: {provider}. Available: google_imagen, openrouter_imagen"
62+
)
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""OpenRouter VLM provider — OpenAI-compatible API for any model."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Optional
6+
7+
import structlog
8+
from PIL import Image
9+
from tenacity import retry, stop_after_attempt, wait_exponential
10+
11+
from paperbanana.core.utils import image_to_base64
12+
from paperbanana.providers.base import VLMProvider
13+
14+
logger = structlog.get_logger()
15+
16+
17+
class OpenRouterVLM(VLMProvider):
18+
"""VLM provider that routes through OpenRouter's OpenAI-compatible API.
19+
20+
Works with any model on OpenRouter (Gemini, Claude, GPT, Llama, etc.).
21+
Get an API key at https://openrouter.ai/keys
22+
"""
23+
24+
def __init__(
25+
self,
26+
api_key: Optional[str] = None,
27+
model: str = "google/gemini-3-flash-preview",
28+
):
29+
self._api_key = api_key
30+
self._model = model
31+
self._client = None
32+
33+
@property
34+
def name(self) -> str:
35+
return "openrouter"
36+
37+
@property
38+
def model_name(self) -> str:
39+
return self._model
40+
41+
def _get_client(self):
42+
"""Lazy-init an async httpx client pointed at the OpenRouter API."""
43+
if self._client is None:
44+
import httpx
45+
46+
self._client = httpx.AsyncClient(
47+
base_url="https://openrouter.ai/api/v1",
48+
headers={
49+
"Authorization": f"Bearer {self._api_key}",
50+
"HTTP-Referer": "https://github.com/llmsresearch/paperbanana",
51+
"X-Title": "PaperBanana",
52+
},
53+
timeout=120.0,
54+
)
55+
return self._client
56+
57+
def is_available(self) -> bool:
58+
return self._api_key is not None
59+
60+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=2, max=30))
61+
async def generate(
62+
self,
63+
prompt: str,
64+
images: Optional[list[Image.Image]] = None,
65+
system_prompt: Optional[str] = None,
66+
temperature: float = 1.0,
67+
max_tokens: int = 4096,
68+
response_format: Optional[str] = None,
69+
) -> str:
70+
client = self._get_client()
71+
72+
messages = []
73+
if system_prompt:
74+
messages.append({"role": "system", "content": system_prompt})
75+
76+
# Build multimodal content array (vision images + text)
77+
content = []
78+
if images:
79+
for img in images:
80+
b64 = image_to_base64(img)
81+
content.append(
82+
{
83+
"type": "image_url",
84+
"image_url": {"url": f"data:image/png;base64,{b64}"},
85+
}
86+
)
87+
content.append({"type": "text", "text": prompt})
88+
messages.append({"role": "user", "content": content})
89+
90+
payload = {
91+
"model": self._model,
92+
"messages": messages,
93+
"temperature": temperature,
94+
"max_tokens": max_tokens,
95+
}
96+
97+
if response_format == "json":
98+
payload["response_format"] = {"type": "json_object"}
99+
100+
response = await client.post("/chat/completions", json=payload)
101+
response.raise_for_status()
102+
103+
data = response.json()
104+
text = data["choices"][0]["message"]["content"]
105+
106+
logger.debug(
107+
"OpenRouter response",
108+
model=self._model,
109+
usage=data.get("usage"),
110+
)
111+
return text

0 commit comments

Comments
 (0)