Praneshrajan15's picture
Deploy DataForge playground API
13fe504 verified
Raw
History Blame Contribute Delete
5.94 kB
"""Remote model backend for the DataForge verified agent (torch-free).
Drives a hosted Gradio ZeroGPU Space (see ``playground-model/app.py``) over
HTTP, one round-trip per agent step, and exposes the same synchronous
completion signature the hosted and local backends use. This lets a CPU-only
deployment (the playground API) run the real multi-step agent loop against the
trained checkpoint without importing ``torch`` or ``transformers``: the model
lives on the Space, while the safety constitution and SMT verifier run locally
on the caller.
The transport speaks Gradio's REST protocol directly (submit -> poll the
Server-Sent-Events stream) using ``httpx`` (already a core dependency), so no
``gradio_client`` install is required. The Space's ``generate`` endpoint takes
``(messages_json, temperature, max_new_tokens)`` and returns the assistant text.
Environment variables:
DATAFORGE_REMOTE_MODEL_URL Base URL of the hosted model Space
(required; e.g. an HF Space URL).
DATAFORGE_REMOTE_MODEL_TOKEN Optional bearer token for private Spaces.
DATAFORGE_REMOTE_MODEL_TIMEOUT Per-call timeout in seconds (default 60).
DATAFORGE_REMOTE_MODEL_MAX_NEW_TOKENS Generation cap sent per call (default 384).
"""
from __future__ import annotations
import json
import os
from collections.abc import Callable, Sequence
from typing import Any
from dataforge.agent.providers import Message
__all__ = [
"RemoteBackendUnavailableError",
"RemoteCompletionError",
"build_remote_completion",
]
# Gradio REST prefixes: Gradio 5 serves under /gradio_api, Gradio 4 under /call.
_GRADIO_PREFIXES = ("/gradio_api/call/", "/call/")
_GENERATE_API = "generate"
class RemoteBackendUnavailableError(RuntimeError):
"""Raised at construction when the remote backend is not configured."""
class RemoteCompletionError(RuntimeError):
"""Raised at call time when a remote completion fails or is malformed."""
def _submit(client: Any, base_url: str, api_name: str, data: list[object]) -> str:
"""POST the call and return the SSE stream URL for its event id."""
body = {"data": data}
last_error: str = "no endpoint matched"
for prefix in _GRADIO_PREFIXES:
url = f"{base_url}{prefix}{api_name}"
response = client.post(url, json=body)
if response.status_code == 404:
last_error = f"404 at {url}"
continue
response.raise_for_status()
payload = response.json()
event_id = payload.get("event_id") or payload.get("hash")
if not event_id:
raise RemoteCompletionError(f"no event id in submit response: {payload!r}")
return f"{url}/{event_id}"
raise RemoteCompletionError(f"{api_name} endpoint not found ({last_error})")
def _parse_sse(text: str) -> str:
"""Extract the completion string from a Gradio SSE response body."""
event: str | None = None
last_data: str | None = None
for raw_line in text.splitlines():
line = raw_line.rstrip("\r")
if line.startswith("event:"):
event = line[len("event:") :].strip()
elif line.startswith("data:"):
data = line[len("data:") :].strip()
if event == "error":
raise RemoteCompletionError(f"remote model reported an error: {data}")
last_data = data
if last_data is None:
raise RemoteCompletionError("remote model returned no data")
try:
parsed = json.loads(last_data)
except json.JSONDecodeError as exc:
raise RemoteCompletionError(f"unparseable remote response: {last_data!r}") from exc
if isinstance(parsed, list) and parsed:
return str(parsed[0])
if isinstance(parsed, str):
return parsed
raise RemoteCompletionError(f"unexpected remote payload shape: {parsed!r}")
def build_remote_completion(
model: str | None = None,
) -> Callable[[Sequence[Message], str | None, float], str]:
"""Build a synchronous completion callable backed by a hosted model Space.
Args:
model: Accepted for signature parity but ignored -- the Space serves a
fixed checkpoint chosen by its own configuration.
Returns:
A callable ``(messages, model_name, temperature) -> str`` compatible
with :data:`dataforge.agent.policy.CompletionFn`.
Raises:
RemoteBackendUnavailableError: If ``DATAFORGE_REMOTE_MODEL_URL`` is unset.
"""
del model # The remote Space owns model selection.
base_url = os.environ.get("DATAFORGE_REMOTE_MODEL_URL", "").strip().rstrip("/")
if not base_url:
raise RemoteBackendUnavailableError("DATAFORGE_REMOTE_MODEL_URL is not set")
token = os.environ.get("DATAFORGE_REMOTE_MODEL_TOKEN", "").strip()
timeout = float(os.environ.get("DATAFORGE_REMOTE_MODEL_TIMEOUT", "60") or "60")
max_new_tokens = int(os.environ.get("DATAFORGE_REMOTE_MODEL_MAX_NEW_TOKENS", "384") or "384")
headers = {"Authorization": f"Bearer {token}"} if token else {}
def _complete(messages: Sequence[Message], _model_name: str | None, temperature: float) -> str:
import httpx
chat = [{"role": message["role"], "content": message["content"]} for message in messages]
data: list[object] = [json.dumps(chat), float(temperature), max_new_tokens]
try:
with httpx.Client(timeout=timeout, headers=headers) as client:
stream_url = _submit(client, base_url, _GENERATE_API, data)
response = client.get(stream_url)
response.raise_for_status()
return _parse_sse(response.text)
except httpx.HTTPError as exc:
raise RemoteCompletionError(f"remote model request failed: {exc}") from exc
return _complete