diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..47f799c3e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# ---------- Frontend build ---------- +FROM node:20-bookworm-slim AS frontend-build +WORKDIR /app/frontend +RUN corepack enable && corepack prepare pnpm@10.32.1 --activate +COPY frontend/package.json frontend/pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile +COPY frontend/ ./ +ENV VITE_IS_DEPLOYED=true +RUN pnpm build-hosted + +# ---------- Backend runtime ---------- +FROM python:3.12-slim-bookworm AS runtime +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + POETRY_VERSION=2.4.1 \ + POETRY_VIRTUALENVS_CREATE=false + +WORKDIR /app +RUN pip install --no-cache-dir "poetry==$POETRY_VERSION" + +COPY backend/pyproject.toml backend/poetry.lock /app/backend/ +WORKDIR /app/backend +RUN poetry install --only main --no-interaction --no-ansi + +# Render free is only 512 MB RAM. Chromium is intentionally not installed; +# render.yaml disables the optional screenshot-preview tool. +COPY backend/ /app/backend/ +COPY --from=frontend-build /app/frontend/dist /app/frontend/dist + +EXPOSE 10000 +WORKDIR /app/backend +CMD ["sh", "-c", "exec uvicorn main:app --host 0.0.0.0 --port ${PORT:-10000}"] diff --git a/RENDER_9ROUTER.md b/RENDER_9ROUTER.md new file mode 100644 index 000000000..81cbe1e2b --- /dev/null +++ b/RENDER_9ROUTER.md @@ -0,0 +1,17 @@ +# Render + 9Router deployment + +This fork can run as one Render Web Service. The React frontend is built into the Docker image and served by FastAPI; `/generate-code` remains a WebSocket endpoint. + +## Required Render environment variables + +- `OPENAI_API_KEY`: your 9Router API key. Keep this in Render Secrets; never commit it. +- `OPENAI_BASE_URL`: `https://9router.com/v1` +- `ROUTER_MODEL`: a 9Router model ID that supports vision/image input and tool calling. + +The included `render.yaml` configures the remaining deployment settings for the free plan. + +## Important + +The free Render instance has 512 MB RAM, so the deployment disables the optional local Chromium screenshot-preview tool. Core screenshot-to-code generation still works through the router. + +Free Render services also sleep after 15 minutes without inbound traffic and have an ephemeral filesystem, so uploaded/local files are not durable across restarts. diff --git a/backend/agent/providers/factory.py b/backend/agent/providers/factory.py index 767bfb473..b195b956f 100644 --- a/backend/agent/providers/factory.py +++ b/backend/agent/providers/factory.py @@ -9,8 +9,9 @@ from agent.providers.base import ProviderSession from agent.providers.gemini import GeminiProviderSession, serialize_gemini_tools from agent.providers.openai import OpenAIProviderSession, serialize_openai_tools +from agent.providers.router import RouterProviderSession from agent.tools import canonical_tool_definitions -from config import REPLICATE_API_KEY +from config import REPLICATE_API_KEY, ROUTER_MODEL from fs_logging.agent_runs import AgentRunRecorder from llm import ANTHROPIC_MODELS, GEMINI_MODELS, OPENAI_MODELS, Llm from preview_screenshot import is_screenshot_preview_available @@ -30,18 +31,26 @@ def create_provider_session( ) -> ProviderSession: canonical_tools = canonical_tool_definitions( image_generation_enabled=should_generate_images, - # The edit_images tool calls Replicate, so don't offer it without a key. image_editing_enabled=bool(replicate_api_key or REPLICATE_API_KEY), - # The extract_assets tool calls Gemini, so don't offer it without a key. asset_extraction_enabled=should_extract_assets and bool(gemini_api_key), - # screenshot_preview needs headless Chromium; skip it if it can't launch. screenshot_enabled=is_screenshot_preview_available(), ) + if ROUTER_MODEL: + if not openai_api_key: + raise Exception("Router API key is missing. Set OPENAI_API_KEY.") + client = AsyncOpenAI(api_key=openai_api_key, base_url=openai_base_url) + return RouterProviderSession( + client=client, + model=model, + prompt_messages=prompt_messages, + tools=canonical_tools, + recorder=recorder, + ) + if model in OPENAI_MODELS: if not openai_api_key: raise Exception("OpenAI API key is missing.") - client = AsyncOpenAI(api_key=openai_api_key, base_url=openai_base_url) return OpenAIProviderSession( client=client, @@ -54,7 +63,6 @@ def create_provider_session( if model in ANTHROPIC_MODELS: if not anthropic_api_key: raise Exception("Anthropic API key is missing.") - client = AsyncAnthropic(api_key=anthropic_api_key) return AnthropicProviderSession( client=client, @@ -67,7 +75,6 @@ def create_provider_session( if model in GEMINI_MODELS: if not gemini_api_key: raise Exception("Gemini API key is missing.") - client = genai.Client(api_key=gemini_api_key) return GeminiProviderSession( client=client, diff --git a/backend/agent/providers/router.py b/backend/agent/providers/router.py new file mode 100644 index 000000000..8b2985f26 --- /dev/null +++ b/backend/agent/providers/router.py @@ -0,0 +1,208 @@ +# pyright: reportUnknownVariableType=false +import base64 +import json +from typing import Any, Dict, List, Optional + +from openai import AsyncOpenAI +from openai.types.chat import ChatCompletionMessageParam + +from agent.providers.base import EventSink, ExecutedToolCall, ProviderSession, ProviderTurn, StreamEvent +from agent.state import ensure_str +from agent.tools import CanonicalToolDefinition, ToolCall, parse_json_arguments +from costs.token_usage import TokenUsage +from fs_logging.agent_runs import AgentRunRecorder +from fs_logging.prompt_reports import PromptReportLogger +from llm import Llm, get_openai_api_name + + +def _chat_tools(tools: List[CanonicalToolDefinition]) -> List[Dict[str, Any]]: + result: List[Dict[str, Any]] = [] + for tool in tools: + result.append({ + "type": "function", + "function": { + "name": tool.name, + "description": tool.description, + "parameters": tool.parameters, + }, + }) + return result + + +def _message_content(message: ChatCompletionMessageParam) -> Any: + content = message.get("content", "") + if isinstance(content, str): + return content + if not isinstance(content, list): + return content + converted: List[Dict[str, Any]] = [] + for part in content: + if not isinstance(part, dict): + continue + if part.get("type") == "text": + converted.append({"type": "text", "text": part.get("text", "")}) + elif part.get("type") == "image_url": + converted.append({"type": "image_url", "image_url": part.get("image_url", {})}) + return converted + + +def _image_data_url(part: Any) -> Optional[str]: + image_url = getattr(part, "image_url", None) + if image_url: + return image_url + data = getattr(part, "data", None) + mime_type = getattr(part, "mime_type", None) + if data is not None and mime_type: + return f"data:{mime_type};base64,{base64.b64encode(data).decode('ascii')}" + return None + + +class RouterProviderSession(ProviderSession): + def __init__( + self, + client: AsyncOpenAI, + model: Llm, + prompt_messages: List[ChatCompletionMessageParam], + tools: List[CanonicalToolDefinition], + recorder: Optional[AgentRunRecorder] = None, + ): + self._client = client + self._model = model + self._messages: List[Dict[str, Any]] = [ + {"role": message.get("role", "user"), "content": _message_content(message)} + for message in prompt_messages + ] + self._tools = _chat_tools(tools) + self._recorder = recorder + self._usage = TokenUsage() + self._logger = PromptReportLogger( + provider="9router", + model=model, + api_model_name=get_openai_api_name(model), + ) + + async def stream_turn(self, on_event: EventSink) -> ProviderTurn: + model_name = get_openai_api_name(self._model) + params: Dict[str, Any] = { + "model": model_name, + "messages": self._messages, + "tools": self._tools, + "tool_choice": "auto", + "stream": True, + "max_tokens": 50000, + "stream_options": {"include_usage": True}, + } + self._logger.record_request(params) + if self._recorder is not None: + self._recorder.record_llm_request("9router", model_name, params) + + stream = await self._client.chat.completions.create(**params) # type: ignore + assistant_text = "" + calls: Dict[int, Dict[str, Any]] = {} + usage: Any = None + + async for chunk in stream: + usage = getattr(chunk, "usage", None) or usage + choices = getattr(chunk, "choices", []) or [] + if not choices: + continue + delta = choices[0].delta + text = getattr(delta, "content", None) + if text: + assistant_text += text + await on_event(StreamEvent(type="assistant_delta", text=text)) + + tool_deltas = getattr(delta, "tool_calls", None) or [] + for tool_delta in tool_deltas: + index = getattr(tool_delta, "index", 0) + entry = calls.setdefault(index, {"id": "", "name": "", "arguments": ""}) + call_id = getattr(tool_delta, "id", None) + if call_id: + entry["id"] = call_id + function = getattr(tool_delta, "function", None) + if function: + name = getattr(function, "name", None) + if name: + entry["name"] = name + arguments = getattr(function, "arguments", None) + if arguments: + entry["arguments"] += arguments + await on_event(StreamEvent( + type="tool_call_delta", + tool_call_id=entry["id"] or f"router-call-{index}", + tool_name=entry["name"] or None, + tool_arguments=entry["arguments"], + )) + + if usage is not None: + input_tokens = getattr(usage, "prompt_tokens", 0) or 0 + output_tokens = getattr(usage, "completion_tokens", 0) or 0 + total_tokens = getattr(usage, "total_tokens", input_tokens + output_tokens) or 0 + turn_usage = TokenUsage(input=input_tokens, output=output_tokens, total=total_tokens) + self._usage.accumulate(turn_usage) + self._logger.record_usage(turn_usage) + else: + turn_usage = None + + tool_calls: List[ToolCall] = [] + assistant_message: Dict[str, Any] = {"role": "assistant", "content": assistant_text or None} + if calls: + serialized_calls = [] + for index in sorted(calls): + entry = calls[index] + call_id = entry["id"] or f"router-call-{index}" + serialized_calls.append({ + "id": call_id, + "type": "function", + "function": {"name": entry["name"], "arguments": entry["arguments"]}, + }) + args, error = parse_json_arguments(entry["arguments"]) + if error: + args = {"INVALID_JSON": ensure_str(entry["arguments"])} + tool_calls.append(ToolCall(id=call_id, name=entry["name"], arguments=args)) + assistant_message["tool_calls"] = serialized_calls + self._messages.append(assistant_message) + + turn = ProviderTurn( + assistant_text=assistant_text, + tool_calls=tool_calls, + assistant_turn=assistant_message, + ) + if self._recorder is not None: + self._recorder.record_llm_response(assistant_text, tool_calls, turn_usage) + return turn + + async def append_tool_results(self, turn: ProviderTurn, executed_tool_calls: list[ExecutedToolCall]) -> None: + for executed in executed_tool_calls: + result_json = json.dumps(executed.result.result) + self._messages.append({ + "role": "tool", + "tool_call_id": executed.tool_call.id, + "content": result_json, + }) + + # Chat Completions tool messages are text-oriented. For tools that + # return screenshots/crops, attach those images as a follow-up user + # message so vision-capable router models can inspect them. + image_parts: List[Dict[str, Any]] = [] + for part in executed.result.multimodal_parts or []: + image_url = _image_data_url(part) + if image_url: + image_parts.append({"type": "image_url", "image_url": {"url": image_url, "detail": "high"}}) + if image_parts: + self._messages.append({ + "role": "user", + "content": [{"type": "text", "text": "Here are the images returned by the tool. Inspect them and continue."}, *image_parts], + }) + + def total_cost_usd(self) -> float | None: + # Router model pricing varies by upstream provider and is not known to + # this application. Return None so the engine does not invent a cost. + return None + + async def close(self) -> None: + print( + f"[TOKEN USAGE] provider=9router model={get_openai_api_name(self._model)} | " + f"input={self._usage.input} output={self._usage.output} total={self._usage.total}" + ) + await self._client.close() diff --git a/backend/config.py b/backend/config.py index 6631629a9..ec21a646c 100644 --- a/backend/config.py +++ b/backend/config.py @@ -1,37 +1,35 @@ import os -NUM_VARIANTS = 4 +NUM_VARIANTS = int(os.environ.get("NUM_VARIANTS", "2")) NUM_VARIANTS_VIDEO = 2 -# LLM-related OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", None) ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", None) GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", None) OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", None) -# Image generation (optional) -REPLICATE_API_KEY = os.environ.get("REPLICATE_API_KEY", None) +# 9Router exposes combos as virtual OpenAI-compatible model IDs. The app sends +# this single combo name for every request; 9Router itself performs the +# configured fallback / round-robin / capability routing between its models. +# ROUTER_MODEL is kept as a backwards-compatible alias for existing Render +# deployments, but new deployments should use ROUTER_COMBO. +ROUTER_COMBO = os.environ.get("ROUTER_COMBO") or os.environ.get("ROUTER_MODEL") +ROUTER_MODEL = ROUTER_COMBO +ROUTER_ONLY = os.environ.get("ROUTER_ONLY", "false").strip().lower() in {"1", "true", "yes", "on"} -# Debugging-related -IS_DEBUG_ENABLED = bool(os.environ.get("IS_DEBUG_ENABLED", False)) +REPLICATE_API_KEY = os.environ.get("REPLICATE_API_KEY", None) +IS_DEBUG_ENABLED = os.environ.get("IS_DEBUG_ENABLED", "").strip().lower() in {"1", "true", "yes", "on"} DEBUG_DIR = os.environ.get("DEBUG_DIR", "") - -# When enabled, every LLM request is written to run_logs/prompt_reports as a -# JSON report viewable at /evals/prompt-reports. -# Hard per-generation spend ceiling; a run that would continue past this is -# aborted. Applies per variant/eval run. Unpriced models are not bounded. -GENERATION_MAX_COST_USD = 3.0 - -PROMPT_REPORTS_ENABLED = os.environ.get( - "PROMPT_REPORTS_ENABLED", "" -).strip().lower() in {"1", "true", "yes", "on"} -LOCAL_ASSET_DIR = os.environ.get( - "LOCAL_ASSET_DIR", os.path.join(os.path.dirname(__file__), "local_assets") -) -# Base URL the backend serves /local-assets from. The live (websocket) path -# infers this per-request; the evals path has no request, so it uses this. +GENERATION_MAX_COST_USD = float(os.environ.get("GENERATION_MAX_COST_USD", "3.0")) +PROMPT_REPORTS_ENABLED = os.environ.get("PROMPT_REPORTS_ENABLED", "").strip().lower() in {"1", "true", "yes", "on"} +LOCAL_ASSET_DIR = os.environ.get("LOCAL_ASSET_DIR", os.path.join(os.path.dirname(__file__), "local_assets")) LOCAL_ASSET_BASE_URL = os.environ.get("LOCAL_ASSET_BASE_URL", "http://127.0.0.1:7001") -# Set to True when running in production (on the hosted version) -# Used as a feature flag to enable or disable certain features -IS_PROD = os.environ.get("IS_PROD", False) +# The upstream app intentionally blocks user-selected OpenAI base URLs in its +# hosted mode. Router deployments are server-controlled, so allow the server's +# configured OpenAI-compatible base URL while still hiding the setting in the UI. +IS_PROD = ( + os.environ.get("IS_PROD", "false").strip().lower() in {"1", "true", "yes", "on"} + and not ROUTER_COMBO +) +DISABLE_SCREENSHOT_PREVIEW = os.environ.get("DISABLE_SCREENSHOT_PREVIEW", "false").strip().lower() in {"1", "true", "yes", "on"} diff --git a/backend/llm.py b/backend/llm.py index b0d600033..7974899a6 100644 --- a/backend/llm.py +++ b/backend/llm.py @@ -1,6 +1,8 @@ from enum import Enum from typing import TypedDict +from config import ROUTER_COMBO + # Actual model versions that are passed to the LLMs and stored in our logs class Llm(Enum): @@ -61,89 +63,29 @@ class Completion(TypedDict): code: str -# Explicitly map each model to the provider backing it. This keeps provider -# groupings authoritative and avoids relying on name conventions when checking -# models elsewhere in the codebase. -MODEL_PROVIDER: dict[Llm, str] = { - # OpenAI models - Llm.GPT_5_4_MINI_LOW: "openai", - Llm.GPT_5_4_2026_03_05_NONE: "openai", - Llm.GPT_5_4_2026_03_05_LOW: "openai", - Llm.GPT_5_4_2026_03_05_MEDIUM: "openai", - Llm.GPT_5_4_2026_03_05_HIGH: "openai", - Llm.GPT_5_4_2026_03_05_XHIGH: "openai", - Llm.GPT_5_5_NONE: "openai", - Llm.GPT_5_5_LOW: "openai", - Llm.GPT_5_5_MEDIUM: "openai", - Llm.GPT_5_5_HIGH: "openai", - Llm.GPT_5_5_XHIGH: "openai", - Llm.GPT_5_6_SOL_NONE: "openai", - Llm.GPT_5_6_SOL_LOW: "openai", - Llm.GPT_5_6_SOL_MEDIUM: "openai", - Llm.GPT_5_6_SOL_HIGH: "openai", - Llm.GPT_5_6_SOL_XHIGH: "openai", - Llm.GPT_5_6_SOL_MAX: "openai", - Llm.GPT_5_6_TERRA_LOW: "openai", - # Anthropic models - Llm.CLAUDE_SONNET_4_6: "anthropic", - Llm.CLAUDE_OPUS_5_LOW: "anthropic", - Llm.CLAUDE_OPUS_5_MEDIUM: "anthropic", - Llm.CLAUDE_OPUS_5_HIGH: "anthropic", - Llm.CLAUDE_OPUS_5_XHIGH: "anthropic", - Llm.CLAUDE_OPUS_5_MAX: "anthropic", - Llm.CLAUDE_OPUS_4_8_LOW: "anthropic", - Llm.CLAUDE_OPUS_4_8_MEDIUM: "anthropic", - Llm.CLAUDE_OPUS_4_8_HIGH: "anthropic", - Llm.CLAUDE_OPUS_4_8_XHIGH: "anthropic", - Llm.CLAUDE_OPUS_4_8_MAX: "anthropic", - Llm.CLAUDE_FABLE_5_LOW: "anthropic", - Llm.CLAUDE_FABLE_5_MEDIUM: "anthropic", - Llm.CLAUDE_FABLE_5_HIGH: "anthropic", - Llm.CLAUDE_FABLE_5_XHIGH: "anthropic", - Llm.CLAUDE_FABLE_5_MAX: "anthropic", - # Gemini models - Llm.GEMINI_3_FLASH_PREVIEW_HIGH: "gemini", - Llm.GEMINI_3_FLASH_PREVIEW_MINIMAL: "gemini", - Llm.GEMINI_3_1_PRO_PREVIEW_HIGH: "gemini", - Llm.GEMINI_3_1_PRO_PREVIEW_MEDIUM: "gemini", - Llm.GEMINI_3_1_PRO_PREVIEW_LOW: "gemini", - Llm.GEMINI_3_5_FLASH_HIGH: "gemini", - Llm.GEMINI_3_5_FLASH_MEDIUM: "gemini", - Llm.GEMINI_3_5_FLASH_LOW: "gemini", - Llm.GEMINI_3_5_FLASH_MINIMAL: "gemini", - Llm.GEMINI_3_6_FLASH_HIGH: "gemini", - Llm.GEMINI_3_6_FLASH_MEDIUM: "gemini", - Llm.GEMINI_3_6_FLASH_LOW: "gemini", - Llm.GEMINI_3_6_FLASH_MINIMAL: "gemini", -} +# Keep the upstream provider groupings intact. When a 9Router combo is +# configured, the provider factory intercepts the request before these sets are +# used and sends the combo name to 9Router as the OpenAI-compatible model ID. +MODEL_PROVIDER: dict[Llm, str] = {} +for _model in Llm: + if _model.value.startswith("gpt-"): + MODEL_PROVIDER[_model] = "openai" + elif _model.value.startswith("claude-"): + MODEL_PROVIDER[_model] = "anthropic" + else: + MODEL_PROVIDER[_model] = "gemini" -# Convenience sets for membership checks -OPENAI_MODELS = {m for m, p in MODEL_PROVIDER.items() if p == "openai"} -ANTHROPIC_MODELS = {m for m, p in MODEL_PROVIDER.items() if p == "anthropic"} -GEMINI_MODELS = {m for m, p in MODEL_PROVIDER.items() if p == "gemini"} +OPENAI_MODELS = {m for m, provider in MODEL_PROVIDER.items() if provider == "openai"} +ANTHROPIC_MODELS = {m for m, provider in MODEL_PROVIDER.items() if provider == "anthropic"} +GEMINI_MODELS = {m for m, provider in MODEL_PROVIDER.items() if provider == "gemini"} OPENAI_MODEL_CONFIG: dict[Llm, dict[str, str]] = { Llm.GPT_5_4_MINI_LOW: {"api_name": "gpt-5.4-mini", "reasoning_effort": "low"}, - Llm.GPT_5_4_2026_03_05_NONE: { - "api_name": "gpt-5.4-2026-03-05", - "reasoning_effort": "none", - }, - Llm.GPT_5_4_2026_03_05_LOW: { - "api_name": "gpt-5.4-2026-03-05", - "reasoning_effort": "low", - }, - Llm.GPT_5_4_2026_03_05_MEDIUM: { - "api_name": "gpt-5.4-2026-03-05", - "reasoning_effort": "medium", - }, - Llm.GPT_5_4_2026_03_05_HIGH: { - "api_name": "gpt-5.4-2026-03-05", - "reasoning_effort": "high", - }, - Llm.GPT_5_4_2026_03_05_XHIGH: { - "api_name": "gpt-5.4-2026-03-05", - "reasoning_effort": "xhigh", - }, + Llm.GPT_5_4_2026_03_05_NONE: {"api_name": "gpt-5.4-2026-03-05", "reasoning_effort": "none"}, + Llm.GPT_5_4_2026_03_05_LOW: {"api_name": "gpt-5.4-2026-03-05", "reasoning_effort": "low"}, + Llm.GPT_5_4_2026_03_05_MEDIUM: {"api_name": "gpt-5.4-2026-03-05", "reasoning_effort": "medium"}, + Llm.GPT_5_4_2026_03_05_HIGH: {"api_name": "gpt-5.4-2026-03-05", "reasoning_effort": "high"}, + Llm.GPT_5_4_2026_03_05_XHIGH: {"api_name": "gpt-5.4-2026-03-05", "reasoning_effort": "xhigh"}, Llm.GPT_5_5_NONE: {"api_name": "gpt-5.5", "reasoning_effort": "none"}, Llm.GPT_5_5_LOW: {"api_name": "gpt-5.5", "reasoning_effort": "low"}, Llm.GPT_5_5_MEDIUM: {"api_name": "gpt-5.5", "reasoning_effort": "medium"}, @@ -160,8 +102,14 @@ class Completion(TypedDict): def get_openai_api_name(model: Llm) -> str: - return OPENAI_MODEL_CONFIG[model]["api_name"] + # A combo is a virtual model name. 9Router owns all downstream model + # selection, so screenshot-to-code must never substitute an individual + # provider model here. + return ROUTER_COMBO or OPENAI_MODEL_CONFIG[model]["api_name"] def get_openai_reasoning_effort(model: Llm) -> str | None: + # Reasoning is controlled by the selected upstream model inside the combo. + if ROUTER_COMBO: + return None return OPENAI_MODEL_CONFIG.get(model, {}).get("reasoning_effort") diff --git a/backend/main.py b/backend/main.py index bc10e81b8..18ad28b09 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,9 +3,11 @@ load_dotenv() - +from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles from config import IS_DEBUG_ENABLED from routes import ( capabilities, @@ -24,22 +26,17 @@ app = FastAPI(openapi_url=None, docs_url=None, redoc_url=None) configure_uploaded_asset_routes(app) - @app.on_event("startup") async def log_debug_mode() -> None: debug_status = "ENABLED" if IS_DEBUG_ENABLED else "DISABLED" print(f"Backend startup complete. Debug mode is {debug_status}.") - @app.on_event("startup") async def probe_screenshot_preview_on_startup() -> None: - # Detect (and warm up) headless Chromium so the screenshot_preview tool is - # only offered when it can actually run. Logs the outcome. from preview_screenshot import probe_screenshot_preview + available = await probe_screenshot_preview() + print(f"Screenshot preview available: {available}") - await probe_screenshot_preview() - -# Configure CORS settings app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -48,7 +45,6 @@ async def probe_screenshot_preview_on_startup() -> None: allow_headers=["*"], ) -# Add routes app.include_router(generate_code.router) app.include_router(screenshot.router) app.include_router(home.router) @@ -59,3 +55,17 @@ async def probe_screenshot_preview_on_startup() -> None: app.include_router(prompt_reports.router) app.include_router(agent_runs.router) app.include_router(eval_sets.router) + +# In the combined Render image, the React build lives beside the backend. +FRONTEND_DIST = Path(__file__).resolve().parents[1] / "frontend" / "dist" +if FRONTEND_DIST.exists(): + assets_dir = FRONTEND_DIST / "assets" + if assets_dir.exists(): + app.mount("/assets", StaticFiles(directory=assets_dir), name="frontend-assets") + + @app.get("/app/{path:path}", include_in_schema=False) + async def frontend_app(path: str): + candidate = FRONTEND_DIST / path + if candidate.is_file(): + return FileResponse(candidate) + return FileResponse(FRONTEND_DIST / "index.html") diff --git a/backend/preview_screenshot/registry.py b/backend/preview_screenshot/registry.py index 104717069..a03b52dc9 100644 --- a/backend/preview_screenshot/registry.py +++ b/backend/preview_screenshot/registry.py @@ -1,39 +1,32 @@ +import os from typing import Optional from babel_cdn import normalize_babel_cdn from preview_screenshot.base import ScreenshotBackend from preview_screenshot.playwright_backend import PlaywrightBackend -# The active backend. Defaults to local Chromium; a deployment can swap in an -# alternative (e.g. an external rendering API) via set_screenshot_backend. _backend: ScreenshotBackend = PlaywrightBackend() - -# Cached result of the startup probe: whether _backend can run here. None until -# the first probe runs. Used to gate the tool so it isn't offered when it can't. _available: Optional[bool] = None def set_screenshot_backend(backend: ScreenshotBackend) -> None: - """Install the screenshot backend (call once, before the startup probe).""" global _backend _backend = backend async def probe_screenshot_preview() -> bool: - """Check (once, cached) whether the active backend can run here.""" global _available + if os.environ.get("DISABLE_SCREENSHOT_PREVIEW", "").strip().lower() in {"1", "true", "yes", "on"}: + _available = False + return False if _available is None: _available = await _backend.available() return _available def is_screenshot_preview_available() -> bool: - """Synchronous accessor for the cached probe result. - - Defaults to True when the probe hasn't run yet so we never wrongly hide the - tool before startup has checked; the runtime still fails safe if a call - errors. In practice the startup probe sets this before any request. - """ + if os.environ.get("DISABLE_SCREENSHOT_PREVIEW", "").strip().lower() in {"1", "true", "yes", "on"}: + return False return _available if _available is not None else True @@ -42,10 +35,4 @@ async def capture_preview_screenshot( device: str = "desktop", full_page: bool = True, ) -> bytes: - """Render HTML to PNG via the active backend. - - The public entry point the screenshot_preview tool calls; the backend choice - is invisible to callers. Normalizes the Babel CDN first so generated React - pages (old and new) actually mount before we capture. - """ return await _backend.capture(normalize_babel_cdn(html), device, full_page) diff --git a/backend/routes/home.py b/backend/routes/home.py index 2f6b3a423..7dab0a8ad 100644 --- a/backend/routes/home.py +++ b/backend/routes/home.py @@ -1,11 +1,16 @@ +from pathlib import Path from fastapi import APIRouter -from fastapi.responses import HTMLResponse +from fastapi.responses import FileResponse, HTMLResponse router = APIRouter() +FRONTEND_DIST = Path(__file__).resolve().parents[2] / "frontend" / "dist" @router.get("/") -async def get_status(): +async def get_home(): + index_file = FRONTEND_DIST / "index.html" + if index_file.is_file(): + return FileResponse(index_file) return HTMLResponse( - content="

Your backend is running correctly. Please open the front-end URL (default is http://localhost:5173) to use screenshot-to-code.

" + content="

Your backend is running correctly. Frontend build not found.

" ) diff --git a/render.yaml b/render.yaml new file mode 100644 index 000000000..e2833e4e4 --- /dev/null +++ b/render.yaml @@ -0,0 +1,31 @@ +services: + - type: web + name: screenshot-to-code-9router + runtime: docker + plan: free + dockerfilePath: ./Dockerfile + dockerContext: . + healthCheckPath: /api/capabilities + envVars: + - key: OPENAI_API_KEY + sync: false + # Your self-hosted 9Router instance. It exposes the OpenAI-compatible + # API under /v1 and resolves combo names such as Hermes-Fallback. + - key: OPENAI_BASE_URL + value: https://ninerouter-g21u.onrender.com/v1 + - key: ROUTER_COMBO + sync: false + - key: ROUTER_ONLY + value: "true" + - key: IS_PROD + value: "true" + - key: VITE_IS_DEPLOYED + value: "true" + - key: NUM_VARIANTS + value: "2" + - key: DISABLE_SCREENSHOT_PREVIEW + value: "true" + - key: PROMPT_REPORTS_ENABLED + value: "false" + - key: IS_DEBUG_ENABLED + value: "false"