"""Serve Harbor tasks: Task API, MCP rollouts, and a human UI. Locally this is two ports: :port env server Task API + MCP + UI (faces the trainer / a browser) :capture_port capture proxy (faces the sandbox, published) Two ports on purpose. The sandbox is off-cluster and must reach the capture proxy over a public URL; the env server has no business being publicly reachable, and sharing one port would expose it as soon as the capture proxy became reachable. On a hosted platform that inverts. A Space gets exactly one public URL and exposes one port, so there is no second port to publish and nothing to forward. The capture app is mounted onto the env server's own app at `CAPTURE_MOUNT` instead, and the sandbox reaches it at `/capture`. The proxy still refuses unregistered callers, which is what keeps a public mount from becoming an open relay. """ from __future__ import annotations import os import threading from typing import Any from .runner import CaptureServer # Where the capture app is mounted when the env server hosts it directly. CAPTURE_MOUNT = "/capture" def space_public_url() -> str: """The public URL of the Space this process is running in, or `""` when it is not on one. Returns: `str`: e.g. `https://owner-name.hf.space`, with no trailing slash. """ host = os.environ.get("SPACE_HOST", "").strip() if host: return "https://" + host.rstrip("/").removeprefix("https://").removeprefix( "http://" ) # SPACE_HOST is the direct answer, but SPACE_ID is the variable that is always set, so derive # the hostname the same way `auto.auto_env` does. space_id = os.environ.get("SPACE_ID", "").strip() if space_id and "/" in space_id: slug = space_id.replace("/", "-").replace("_", "-").replace(".", "-").lower() return f"https://{slug}.hf.space" return "" class HarborService: """Long-lived state for a serving process: capture proxy, forwarding, datasets. Held at module scope by `serve_harbor` so that the environment instances OpenEnv builds per-request can reach it. They must not own it: `/metadata` and `/schema` construct a throwaway environment on every call, so anything expensive on `__init__` would be paid per docs hit. """ _instance: "HarborService | None" = None def __init__( self, *, llm_url: str, model: str, datasets: list[str], capture_port: int = 8100, expose: str = "gradio", ) -> None: self.llm_url = llm_url self.model = model self.datasets = datasets self.capture = CaptureServer(llm_url=llm_url, model=model, port=capture_port) self._expose_kind = expose self.public_url = "" self.mounted = False self._forwarder: Any = None self._lock = threading.Lock() def start(self) -> str: """Make the capture proxy reachable from the sandbox and return its public URL. Two different situations, and conflating them is what makes the hosted case awkward: - **Hosted** (a Space). The platform already gives this process one public URL and exposes exactly one port. So the capture app is mounted onto the env server's own app under `CAPTURE_MOUNT` and reached at `/capture`. No second port, no forwarding, nothing for the platform to object to. - **Local.** The sandbox runs off-cluster and cannot reach `127.0.0.1`, so the capture port is published by whichever forwarder was selected. """ public = space_public_url() if public: # The env server's app serves it; `build_app` performs the mount. self.mounted = True self.public_url = f"{public}{CAPTURE_MOUNT}" return self.public_url from openenv.core.harness.capture.forwarding import make_forwarder self.capture.start() # A half-started service is worse than a failed one: the capture server owns a port and a # background thread, so leaving it up after the forwarder fails makes the next attempt fail # too, on a port conflict that has nothing to do with the real error. try: self._forwarder = make_forwarder(self._expose_kind) self.public_url = self._forwarder.start(self.capture.port) except BaseException: self._forwarder = None self.capture.stop() raise return self.public_url def stop(self) -> None: if self._forwarder is not None: self._forwarder.stop() self.capture.stop() @classmethod def current(cls) -> "HarborService | None": return cls._instance @classmethod def set_current(cls, service: "HarborService") -> None: cls._instance = service def serve_harbor( *, llm_url: str, datasets: list[str], model: str | None = None, host: str = "0.0.0.0", port: int = 8000, capture_port: int = 8100, expose: str = "gradio", env_file: str | None = None, ) -> None: """Boot the capture proxy, then serve the env server with the UI mounted. Args: llm_url (`str`): OpenAI-spec inference endpoint. datasets (`list[str]`): Dataset specs to serve as splits. port (`int`, *optional*, defaults to `8000`): Env server port. capture_port (`int`, *optional*, defaults to `8100`): Capture proxy port. This is the one published to the sandbox. Ignored on a hosted platform, where the proxy is mounted on the env server's own app instead. expose (`str`, *optional*, defaults to `"gradio"`): How the sandbox reaches the capture proxy locally: `gradio`, `cloudflare` or `direct`. """ import uvicorn from .startup import prepare caps = prepare( llm_url=llm_url, model=model, datasets=datasets, env_file=env_file, require_llm=True, quiet=False, ) model = caps.llm.get("model") or model or "" service = HarborService( llm_url=llm_url, model=model, datasets=datasets, capture_port=capture_port, expose=expose, ) public = service.start() HarborService.set_current(service) where = "mounted on this app" if service.mounted else f":{capture_port}" print(f"\ncapture {where} -> {public}") print( f"server http://{host}:{port} (UI at /web, Task API at /{{env}}/splits)" ) print("Ctrl-C to stop\n") # The UI is the whole point of this entry point, so turn it on rather than making the operator # discover an env var. os.environ.setdefault("ENABLE_WEB_INTERFACE", "true") app = build_app(datasets=datasets, llm_url=llm_url, model=model, llm=caps.llm) try: uvicorn.run(app, host=host, port=port, log_level="info") finally: service.stop() def build_app( *, datasets: list[str], llm_url: str = "", model: str = "", llm: dict[str, Any] | None = None, ) -> Any: """The FastAPI app: Task API + MCP + the Gradio UI.""" from openenv.core.env_server.http_server import create_app from openenv.core.env_server.mcp_environment import ( CallToolAction, CallToolObservation, ) from .environment import HarborEnvironment from .ui import harbor_gradio_builder HarborEnvironment.configure( datasets=datasets, llm_url=llm_url, model=model, llm=llm ) def gradio_builder( _web_manager: Any = None, _action_fields: Any = None, _metadata: Any = None, _is_chat: Any = None, display_title: str = "", _quick_start: Any = None, ) -> Any: """OpenEnv calls this positionally with six web-interface arguments. Only the title is useful here: the Harbor UI drives rollouts through its own handlers rather than the generic action-field form, because a rollout is one long tool call, not a step. """ return harbor_gradio_builder(datasets=datasets, title=display_title or "Harbor") app = create_app( HarborEnvironment, CallToolAction, CallToolObservation, env_name="harbor_env", max_concurrent_envs=int(os.getenv("MAX_CONCURRENT_ENVS", "4")), gradio_builder=gradio_builder, custom_tab_name="Harbor", custom_tab_primary=True, show_default_tab=False, ) # When the platform gives us a single port, the capture proxy rides on this app instead of # being published separately. Mounting strips the prefix, so the proxy's own catch-all still # sees `/v1/chat/completions` and every dialect keeps working unchanged. service = HarborService.current() if service is not None and service.mounted: app.mount(CAPTURE_MOUNT, service.capture.app) return app