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.

setSettings((s) => ({ @@ -177,6 +194,13 @@ function SettingsTab({ settings, setSettings, appTheme, setAppTheme }: Props) { })) } /> + {isOpenRouterBaseUrl(settings.openAiBaseURL) && ( +

+ OpenAI-compatible requests will go through OpenRouter. + Anthropic and Gemini keys still use those providers + directly. +

+ )} )} diff --git a/frontend/src/lib/openai-base-url.test.ts b/frontend/src/lib/openai-base-url.test.ts new file mode 100644 index 000000000..7ce8fdc6a --- /dev/null +++ b/frontend/src/lib/openai-base-url.test.ts @@ -0,0 +1,17 @@ +import { + OPENROUTER_API_BASE_URL, + isOpenRouterBaseUrl, +} from "./openai-base-url"; + +describe("isOpenRouterBaseUrl", () => { + test("recognizes the official OpenRouter API URL", () => { + expect(isOpenRouterBaseUrl(OPENROUTER_API_BASE_URL)).toBe(true); + expect(isOpenRouterBaseUrl("https://www.openrouter.ai")).toBe(true); + }); + + test("rejects other OpenAI-compatible hosts", () => { + expect(isOpenRouterBaseUrl("https://api.openai.com/v1")).toBe(false); + expect(isOpenRouterBaseUrl(null)).toBe(false); + expect(isOpenRouterBaseUrl("")).toBe(false); + }); +}); diff --git a/frontend/src/lib/openai-base-url.ts b/frontend/src/lib/openai-base-url.ts new file mode 100644 index 000000000..b4a21b2df --- /dev/null +++ b/frontend/src/lib/openai-base-url.ts @@ -0,0 +1,15 @@ +export const OPENROUTER_API_BASE_URL = "https://openrouter.ai/api/v1"; + +export function isOpenRouterBaseUrl(url: string | null | undefined): boolean { + if (!url) { + return false; + } + + const trimmed = url.trim(); + try { + const host = new URL(trimmed).hostname.toLowerCase(); + return host === "openrouter.ai" || host.endsWith(".openrouter.ai"); + } catch { + return /(?:^|\.)openrouter\.ai(?:\/|$)/i.test(trimmed); + } +}