-
Notifications
You must be signed in to change notification settings - Fork 332
feat: add OpenRouter provider support #5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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]}" | ||
| ) |
| 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 | ||
| 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( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking suggestion: since |
||
| 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 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unused import
reisn't used anywhere in this file (it's used in the image gen provider but not here). This will failruffcheck with F401.