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
32 changes: 32 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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}"]
17 changes: 17 additions & 0 deletions RENDER_9ROUTER.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 14 additions & 7 deletions backend/agent/providers/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
208 changes: 208 additions & 0 deletions backend/agent/providers/router.py
Original file line number Diff line number Diff line change
@@ -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()
46 changes: 22 additions & 24 deletions backend/config.py
Original file line number Diff line number Diff line change
@@ -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"}
Loading