Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_).
Expand Down
8 changes: 7 additions & 1 deletion backend/agent/providers/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions backend/openai_compat.py
Original file line number Diff line number Diff line change
@@ -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,
}
7 changes: 5 additions & 2 deletions backend/routes/generate_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down
45 changes: 45 additions & 0 deletions backend/tests/test_openai_compat.py
Original file line number Diff line number Diff line change
@@ -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,
}
36 changes: 36 additions & 0 deletions backend/tests/test_parameter_extraction_stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
70 changes: 70 additions & 0 deletions backend/tests/test_provider_factory.py
Original file line number Diff line number Diff line change
@@ -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
30 changes: 27 additions & 3 deletions frontend/src/components/settings/SettingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -162,13 +166,26 @@ function SettingsTab({ settings, setSettings, appTheme, setAppTheme }: Props) {
OpenAI Base URL (optional)
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-zinc-400">
Replace with a proxy URL if you don't want to use the
default.
Use a proxy, or{" "}
<button
type="button"
className="text-violet-600 hover:text-violet-700 dark:text-violet-400 dark:hover:text-violet-300"
onClick={() =>
setSettings((s) => ({
...s,
openAiBaseURL: OPENROUTER_API_BASE_URL,
}))
}
>
OpenRouter
</button>
. Paste your OpenRouter key in the OpenAI API key field
above.
</p>
<Input
id="openai-base-url"
className="mt-2"
placeholder="OpenAI Base URL"
placeholder={OPENROUTER_API_BASE_URL}
value={settings.openAiBaseURL || ""}
onChange={(e) =>
setSettings((s) => ({
Expand All @@ -177,6 +194,13 @@ function SettingsTab({ settings, setSettings, appTheme, setAppTheme }: Props) {
}))
}
/>
{isOpenRouterBaseUrl(settings.openAiBaseURL) && (
<p className="mt-2 text-xs text-gray-500 dark:text-zinc-400">
OpenAI-compatible requests will go through OpenRouter.
Anthropic and Gemini keys still use those providers
directly.
</p>
)}
</div>
)}

Expand Down
17 changes: 17 additions & 0 deletions frontend/src/lib/openai-base-url.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
15 changes: 15 additions & 0 deletions frontend/src/lib/openai-base-url.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}