From 290d90a1ca19e35bfcaad5f7af6009d07dae9455 Mon Sep 17 00:00:00 2001
From: Jaymin Patel
Date: Tue, 1 Sep 2026 10:14:13 +0530
Subject: [PATCH] Add OpenRouter as an OpenAI-compatible provider option.
People already using a custom OpenAI base URL can now point at OpenRouter without guessing the /api/v1 path, and Settings documents the key + URL setup.
Co-authored-by: Cursor
---
README.md | 1 +
backend/agent/providers/factory.py | 8 ++-
backend/openai_compat.py | 42 +++++++++++
backend/routes/generate_code.py | 7 +-
backend/tests/test_openai_compat.py | 45 ++++++++++++
.../tests/test_parameter_extraction_stage.py | 36 ++++++++++
backend/tests/test_provider_factory.py | 70 +++++++++++++++++++
.../src/components/settings/SettingsTab.tsx | 30 +++++++-
frontend/src/lib/openai-base-url.test.ts | 17 +++++
frontend/src/lib/openai-base-url.ts | 15 ++++
10 files changed, 265 insertions(+), 6 deletions(-)
create mode 100644 backend/openai_compat.py
create mode 100644 backend/tests/test_openai_compat.py
create mode 100644 backend/tests/test_provider_factory.py
create mode 100644 frontend/src/lib/openai-base-url.test.ts
create mode 100644 frontend/src/lib/openai-base-url.ts
diff --git a/README.md b/README.md
index 735c5780a..b555b5566 100644
--- a/README.md
+++ b/README.md
@@ -108,6 +108,7 @@ The app will be up and running at http://localhost:5173. Note that you can't dev
- **I'm running into an error when setting up the backend. How can I fix it?** [Try this](https://github.com/abi/screenshot-to-code/issues/3#issuecomment-1814777959). If that still doesn't work, open an issue.
- **How do I get an OpenAI API key?** See https://github.com/abi/screenshot-to-code/blob/main/Troubleshooting.md
- **How can I configure an OpenAI proxy?** If you're not able to access the OpenAI API directly, for example because of country restrictions, you can try a VPN or configure the OpenAI base URL to use a proxy. Set `OPENAI_BASE_URL` in `backend/.env` or directly in the UI in the settings dialog. Make sure the URL has `v1` in the path, for example: `https://xxx.xxxxx.xxx/v1`.
+- **How can I use OpenRouter?** Paste your [OpenRouter](https://openrouter.ai/) API key into the OpenAI API key field (or `OPENAI_API_KEY` in `backend/.env`) and set the OpenAI Base URL to `https://openrouter.ai/api/v1` in Settings, or as `OPENAI_BASE_URL`. Settings has an OpenRouter shortcut that fills that URL. OpenAI-compatible variants then go through OpenRouter; Anthropic and Gemini keys still call those providers directly.
- **How can I update the backend host that my frontend connects to?** Configure `VITE_HTTP_BACKEND_URL` and `VITE_WS_BACKEND_URL` in `frontend/.env.local`. For example, set `VITE_HTTP_BACKEND_URL=http://124.10.20.1:7001`.
- **Seeing UTF-8 errors when running the backend?** On Windows, open the `.env` file with Notepad++, then go to Encoding and select UTF-8.
- **How can I provide feedback?** For feedback, feature requests, and bug reports, open an issue or ping me on [Twitter](https://twitter.com/_abi_).
diff --git a/backend/agent/providers/factory.py b/backend/agent/providers/factory.py
index 767bfb473..b896d84da 100644
--- a/backend/agent/providers/factory.py
+++ b/backend/agent/providers/factory.py
@@ -13,6 +13,7 @@
from config import REPLICATE_API_KEY
from fs_logging.agent_runs import AgentRunRecorder
from llm import ANTHROPIC_MODELS, GEMINI_MODELS, OPENAI_MODELS, Llm
+from openai_compat import normalize_openai_base_url, openai_client_headers
from preview_screenshot import is_screenshot_preview_available
@@ -42,7 +43,12 @@ def create_provider_session(
if not openai_api_key:
raise Exception("OpenAI API key is missing.")
- client = AsyncOpenAI(api_key=openai_api_key, base_url=openai_base_url)
+ normalized_base_url = normalize_openai_base_url(openai_base_url)
+ client = AsyncOpenAI(
+ api_key=openai_api_key,
+ base_url=normalized_base_url,
+ default_headers=openai_client_headers(normalized_base_url),
+ )
return OpenAIProviderSession(
client=client,
model=model,
diff --git a/backend/openai_compat.py b/backend/openai_compat.py
new file mode 100644
index 000000000..ad74500ab
--- /dev/null
+++ b/backend/openai_compat.py
@@ -0,0 +1,42 @@
+from urllib.parse import urlparse, urlunparse
+
+OPENROUTER_API_BASE_URL = "https://openrouter.ai/api/v1"
+OPENROUTER_HTTP_REFERER = "https://github.com/abi/screenshot-to-code"
+OPENROUTER_APP_TITLE = "screenshot-to-code"
+
+
+def is_openrouter_base_url(url: str | None) -> bool:
+ if not url:
+ return False
+ host = urlparse(url.strip()).hostname or ""
+ return host == "openrouter.ai" or host.endswith(".openrouter.ai")
+
+
+def normalize_openai_base_url(url: str | None) -> str | None:
+ """Trim a custom OpenAI-compatible base URL and complete OpenRouter paths."""
+ if url is None:
+ return None
+
+ trimmed = url.strip()
+ if not trimmed:
+ return None
+
+ if is_openrouter_base_url(trimmed):
+ parsed = urlparse(trimmed)
+ path = parsed.path.rstrip("/")
+ if path in {"", "/api"}:
+ parsed = parsed._replace(path="/api/v1")
+ return urlunparse(parsed).rstrip("/")
+ return trimmed.rstrip("/")
+
+ return trimmed.rstrip("/")
+
+
+def openai_client_headers(base_url: str | None) -> dict[str, str] | None:
+ """OpenRouter ranks apps that send a referer and title."""
+ if not is_openrouter_base_url(base_url):
+ return None
+ return {
+ "HTTP-Referer": OPENROUTER_HTTP_REFERER,
+ "X-Title": OPENROUTER_APP_TITLE,
+ }
diff --git a/backend/routes/generate_code.py b/backend/routes/generate_code.py
index 091874ba6..f18835f8b 100644
--- a/backend/routes/generate_code.py
+++ b/backend/routes/generate_code.py
@@ -20,6 +20,7 @@
OPENAI_BASE_URL,
REPLICATE_API_KEY,
)
+from openai_compat import normalize_openai_base_url
from custom_types import InputMode
from llm import (
Llm,
@@ -318,8 +319,10 @@ async def extract_and_validate(self, params: Dict[str, Any]) -> ExtractedParams:
openai_base_url: str | None = None
# Disable user-specified OpenAI Base URL in prod
if not IS_PROD:
- openai_base_url = self._get_from_settings_dialog_or_env(
- params, "openAiBaseURL", OPENAI_BASE_URL
+ openai_base_url = normalize_openai_base_url(
+ self._get_from_settings_dialog_or_env(
+ params, "openAiBaseURL", OPENAI_BASE_URL
+ )
)
if not openai_base_url:
print("Using official OpenAI URL")
diff --git a/backend/tests/test_openai_compat.py b/backend/tests/test_openai_compat.py
new file mode 100644
index 000000000..f8a4c81c3
--- /dev/null
+++ b/backend/tests/test_openai_compat.py
@@ -0,0 +1,45 @@
+from openai_compat import (
+ OPENROUTER_API_BASE_URL,
+ OPENROUTER_APP_TITLE,
+ OPENROUTER_HTTP_REFERER,
+ is_openrouter_base_url,
+ normalize_openai_base_url,
+ openai_client_headers,
+)
+
+
+def test_detects_openrouter_hosts() -> None:
+ assert is_openrouter_base_url("https://openrouter.ai/api/v1")
+ assert is_openrouter_base_url("https://www.openrouter.ai")
+ assert not is_openrouter_base_url("https://api.openai.com/v1")
+ assert not is_openrouter_base_url(None)
+ assert not is_openrouter_base_url("")
+
+
+def test_normalize_completes_openrouter_paths() -> None:
+ assert normalize_openai_base_url("https://openrouter.ai") == OPENROUTER_API_BASE_URL
+ assert (
+ normalize_openai_base_url("https://openrouter.ai/api") == OPENROUTER_API_BASE_URL
+ )
+ assert (
+ normalize_openai_base_url(" https://openrouter.ai/api/v1/ ")
+ == OPENROUTER_API_BASE_URL
+ )
+
+
+def test_normalize_leaves_other_proxies_intact() -> None:
+ assert (
+ normalize_openai_base_url("https://proxy.example/v1")
+ == "https://proxy.example/v1"
+ )
+ assert normalize_openai_base_url(" ") is None
+ assert normalize_openai_base_url(None) is None
+
+
+def test_openrouter_headers_only_for_openrouter() -> None:
+ assert openai_client_headers("https://api.openai.com/v1") is None
+ headers = openai_client_headers(OPENROUTER_API_BASE_URL)
+ assert headers == {
+ "HTTP-Referer": OPENROUTER_HTTP_REFERER,
+ "X-Title": OPENROUTER_APP_TITLE,
+ }
diff --git a/backend/tests/test_parameter_extraction_stage.py b/backend/tests/test_parameter_extraction_stage.py
index 291f66cc4..b7799623b 100644
--- a/backend/tests/test_parameter_extraction_stage.py
+++ b/backend/tests/test_parameter_extraction_stage.py
@@ -95,6 +95,42 @@ async def test_extracts_replicate_api_key_from_env_when_not_in_request(
assert extracted.replicate_api_key == "replicate-from-env"
+@pytest.mark.asyncio
+async def test_normalizes_openrouter_base_url_from_settings() -> None:
+ stage = ParameterExtractionStage(AsyncMock())
+
+ extracted = await stage.extract_and_validate(
+ {
+ "generatedCodeConfig": "html_tailwind",
+ "inputMode": "text",
+ "openAiBaseURL": "https://openrouter.ai",
+ "prompt": {"text": "hello"},
+ }
+ )
+
+ assert extracted.openai_base_url == "https://openrouter.ai/api/v1"
+
+
+@pytest.mark.asyncio
+async def test_normalizes_openrouter_base_url_from_env(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(
+ "routes.generate_code.OPENAI_BASE_URL", "https://openrouter.ai/api"
+ )
+ stage = ParameterExtractionStage(AsyncMock())
+
+ extracted = await stage.extract_and_validate(
+ {
+ "generatedCodeConfig": "html_tailwind",
+ "inputMode": "text",
+ "prompt": {"text": "hello"},
+ }
+ )
+
+ assert extracted.openai_base_url == "https://openrouter.ai/api/v1"
+
+
@pytest.mark.asyncio
async def test_extracts_design_system_from_request() -> None:
stage = ParameterExtractionStage(AsyncMock())
diff --git a/backend/tests/test_provider_factory.py b/backend/tests/test_provider_factory.py
new file mode 100644
index 000000000..c2a3fcd2b
--- /dev/null
+++ b/backend/tests/test_provider_factory.py
@@ -0,0 +1,70 @@
+from typing import Any
+
+import pytest
+
+from agent.providers.factory import create_provider_session
+from llm import Llm
+from openai_compat import OPENROUTER_API_BASE_URL, OPENROUTER_HTTP_REFERER
+
+
+def test_factory_normalizes_openrouter_url_and_adds_headers(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ captured: dict[str, Any] = {}
+
+ class FakeClient:
+ def __init__(self, **kwargs: Any) -> None:
+ captured.update(kwargs)
+
+ monkeypatch.setattr("agent.providers.factory.AsyncOpenAI", FakeClient)
+ monkeypatch.setattr(
+ "agent.providers.factory.is_screenshot_preview_available",
+ lambda: False,
+ )
+
+ create_provider_session(
+ model=Llm.GPT_5_5_HIGH,
+ prompt_messages=[],
+ should_generate_images=False,
+ openai_api_key="or-key",
+ openai_base_url="https://openrouter.ai",
+ anthropic_api_key=None,
+ gemini_api_key=None,
+ replicate_api_key=None,
+ should_extract_assets=False,
+ )
+
+ assert captured["api_key"] == "or-key"
+ assert captured["base_url"] == OPENROUTER_API_BASE_URL
+ assert captured["default_headers"]["HTTP-Referer"] == OPENROUTER_HTTP_REFERER
+
+
+def test_factory_skips_headers_for_official_openai(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ captured: dict[str, Any] = {}
+
+ class FakeClient:
+ def __init__(self, **kwargs: Any) -> None:
+ captured.update(kwargs)
+
+ monkeypatch.setattr("agent.providers.factory.AsyncOpenAI", FakeClient)
+ monkeypatch.setattr(
+ "agent.providers.factory.is_screenshot_preview_available",
+ lambda: False,
+ )
+
+ create_provider_session(
+ model=Llm.GPT_5_5_HIGH,
+ prompt_messages=[],
+ should_generate_images=False,
+ openai_api_key="sk-test",
+ openai_base_url=None,
+ anthropic_api_key=None,
+ gemini_api_key=None,
+ replicate_api_key=None,
+ should_extract_assets=False,
+ )
+
+ assert captured["base_url"] is None
+ assert captured["default_headers"] is None
diff --git a/frontend/src/components/settings/SettingsTab.tsx b/frontend/src/components/settings/SettingsTab.tsx
index 6f9a50302..3143a4181 100644
--- a/frontend/src/components/settings/SettingsTab.tsx
+++ b/frontend/src/components/settings/SettingsTab.tsx
@@ -11,6 +11,10 @@ import {
import { Input } from "../ui/input";
import { Switch } from "../ui/switch";
import { HTTP_BACKEND_URL, IS_RUNNING_ON_CLOUD } from "../../config";
+import {
+ OPENROUTER_API_BASE_URL,
+ isOpenRouterBaseUrl,
+} from "../../lib/openai-base-url";
interface Props {
settings: Settings;
@@ -162,13 +166,26 @@ function SettingsTab({ settings, setSettings, appTheme, setAppTheme }: Props) {
OpenAI Base URL (optional)
- Replace with a proxy URL if you don't want to use the
- default.
+ Use a proxy, or{" "}
+
+ . Paste your OpenRouter key in the OpenAI API key field
+ above.