ENCODE / src /backend /llm.py
hiasgnpsadgd's picture
Upload folder using huggingface_hub
0372560 verified
Raw
History Blame Contribute Delete
24.3 kB
"""JSON completion clients for the query planner.
Two wire formats cover essentially every model a user is likely to bring:
* ``openai`` -- POST {base}/chat/completions, ``Authorization: Bearer``.
The de-facto standard: OpenAI, DeepSeek, Groq, Together, Fireworks,
OpenRouter, vLLM, Ollama, LM Studio, and most self-hosted gateways.
* ``anthropic`` -- POST {base}/v1/messages, ``x-api-key`` + ``anthropic-version``.
Deliberately stdlib-only. The Space image is CPU-basic and already carries
torch, faiss, and the encoder; one JSON POST per planned query does not
justify adding an SDK to the runtime requirements.
The built-in provider is whatever the operator configures, over the OpenAI
wire format. The deployed Space calls OpenAI. Users may instead supply their
own provider per request; those credentials live in the caller's browser, are
used for exactly one outbound call, and are never persisted or logged here.
Configuration for the built-in provider:
ENCODE_PLANNER_API_KEY required; without it the built-in provider is
absent. OPENAI_API_KEY and DEEPSEEK_API_KEY are
read as fallbacks, in that order, so an existing
deployment keeps working after this rename.
ENCODE_PLANNER_BASE_URL default https://api.openai.com/v1
ENCODE_PLANNER_MODEL default gpt-5.6-luna
ENCODE_PLANNER_TIMEOUT seconds, default 24
"""
from __future__ import annotations
import ipaddress
import json
import os
import re
import socket
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
# The models this deployment offers, in the order the picker shows them: the
# first available one is the default. Each is one user-visible choice that may
# be reachable through more than one endpoint, so a laptop with a direct
# DeepSeek key and a Space with an OpenRouter key offer the same named model
# without the user seeing the difference. Endpoint, key variable and model id
# travel together: sending one provider's model name to another is a 404 that
# reads like an outage, and sending the wrong key is a 401 that reads like a
# bad secret.
#
# Labels are what the picker shows. Keep them short and free of the provider
# namespace: "DeepSeek V4 Flash", not "deepseek/deepseek-v4-flash".
BUILTINS = (
{"id": "luna", "label": "GPT 5.6 Luna", "endpoints": (
{"key": "OPENAI_API_KEY", "base": "https://api.openai.com/v1",
"model": "gpt-5.6-luna"},
{"key": "OPEN_API_KEY", "base": "https://api.openai.com/v1",
"model": "gpt-5.6-luna"},
)},
{"id": "deepseek", "label": "DeepSeek V4 Flash", "endpoints": (
{"key": "OPENROUTER_API_KEY", "base": "https://openrouter.ai/api/v1",
"model": "deepseek/deepseek-v4-flash"},
{"key": "DEEPSEEK_API_KEY", "base": "https://api.deepseek.com/v1",
"model": "deepseek-v4-flash"},
)},
)
# The operator's own endpoint, if configured. It leads the list and becomes the
# default, which is how a model co-hosted with ENCODE is served.
CUSTOM_ID = "custom"
DEFAULT_BASE_URL = BUILTINS[0]["endpoints"][0]["base"]
DEFAULT_MODEL = BUILTINS[0]["endpoints"][0]["model"]
DEFAULT_TIMEOUT = 24.0
ANTHROPIC_VERSION = "2023-06-01"
KINDS = ("openai", "anthropic")
_ENV_FILE = Path(__file__).resolve().parents[2] / ".env"
def _load_dotenv() -> None:
"""Read the repo-root .env for local development.
Deployment does not use this: the Space injects the planner key as a
secret, and `.dockerignore` is allowlist-style so no .env ever reaches the
image. Real environment variables always win over the file.
"""
if not _ENV_FILE.exists():
return
try:
text = _ENV_FILE.read_text(encoding="utf-8")
except OSError:
return
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "): # SAGE-core writes this form
line = line[7:].lstrip()
key, sep, value = line.partition("=")
if not sep:
continue
key = key.strip()
value = value.strip().strip("'\"")
if key and key not in os.environ:
os.environ[key] = value
_load_dotenv()
class LlmError(RuntimeError):
"""Any failure that should surface to the caller as a planner outage."""
# -- provider resolution ----------------------------------------------------
def _env(name: str) -> str:
return os.environ.get(name, "").strip()
def _custom_builtin() -> dict | None:
"""The operator's own endpoint, from ENCODE_PLANNER_*, if configured."""
key = _env("ENCODE_PLANNER_API_KEY")
base = _env("ENCODE_PLANNER_BASE_URL")
model = _env("ENCODE_PLANNER_MODEL")
if not (key and (base or model)):
return None
return {"id": CUSTOM_ID, "label": model or "Built-in model",
"base_url": base or DEFAULT_BASE_URL,
"model": model or DEFAULT_MODEL, "api_key": key}
def _resolve(entry: dict) -> dict | None:
"""The first endpoint of a catalog entry whose key is present."""
for endpoint in entry["endpoints"]:
key = _env(endpoint["key"])
if key:
return {"id": entry["id"], "label": entry["label"],
"base_url": endpoint["base"], "model": endpoint["model"],
"api_key": key}
return None
def builtin_catalog() -> list[dict]:
"""Every built-in this deployment can actually serve, default first."""
custom = _custom_builtin()
served = [r for r in (_resolve(e) for e in BUILTINS) if r]
return ([custom] if custom else []) + served
def builtin_configured() -> bool:
return bool(builtin_catalog())
def builtin_model() -> str:
"""The default model's label, for a caller that wants one name."""
catalog = builtin_catalog()
return catalog[0]["label"] if catalog else DEFAULT_MODEL
def builtin_provider(choice: str | None = None) -> dict:
"""The named built-in, or the default when `choice` is absent or unknown.
An unknown id falls back rather than failing: a picker selection saved in
a browser outlives the deployment that offered it, and planning with the
default beats an error the user cannot act on.
"""
catalog = builtin_catalog()
if not catalog:
raise LlmError("No planner model is configured on this deployment. "
"Add your own model to use the Agent.")
resolved = next((c for c in catalog if c["id"] == choice), catalog[0])
return {"kind": "openai", "base_url": resolved["base_url"],
"api_key": resolved["api_key"], "model": resolved["model"],
"label": resolved["label"]}
def _timeout() -> float:
try:
return float(os.environ.get("ENCODE_PLANNER_TIMEOUT", "") or DEFAULT_TIMEOUT)
except ValueError:
return DEFAULT_TIMEOUT
def allowed_hosts() -> set[str]:
"""Endpoints the *operator* has explicitly permitted, as `host` or
`host:port`, comma separated in ENCODE_PLANNER_ALLOW_HOSTS.
This exists for a model co-hosted with ENCODE -- a vLLM or Ollama server on
the same Linux box or VPC, reachable only over http on a private address.
Whoever sets this env var already owns that machine, so permitting it is
not a privilege escalation; an end user typing the same address into the
UI form is exactly the request-forgery case the check below blocks.
"""
raw = os.environ.get("ENCODE_PLANNER_ALLOW_HOSTS", "")
return {h.strip().lower() for h in raw.split(",") if h.strip()}
# A user-supplied base_url turns this server into a request forwarder, so
# without this check a custom model is a server-side request forgery
# primitive: someone could point it at cloud instance metadata
# (169.254.169.254) or at services reachable only from inside the
# deployment's network.
#
# Note the built-in provider does NOT come through here -- it is configured
# from the environment by the operator, so it may already point anywhere,
# including an http endpoint on localhost.
def _assert_allowed_target(base_url: str) -> None:
parts = urllib.parse.urlsplit(base_url)
host = (parts.hostname or "").lower()
if not host:
raise LlmError("Model base URL has no host")
if parts.scheme not in ("http", "https"):
raise LlmError("Model base URL must start with http:// or https://")
# Operator-permitted endpoints skip the public-address requirement; that
# is the whole point of the allowlist.
hostport = f"{host}:{parts.port}" if parts.port else host
allow = allowed_hosts()
if host in allow or hostport in allow:
return
if parts.scheme != "https":
raise LlmError("Model base URL must start with https://")
try:
infos = socket.getaddrinfo(host, parts.port or 443, proto=socket.IPPROTO_TCP)
except socket.gaierror:
raise LlmError(f"Could not resolve model host '{host}'") from None
for info in infos:
ip = ipaddress.ip_address(info[4][0])
if (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
raise LlmError("Model base URL must point at a public host. An "
"endpoint inside this network has to be permitted "
"by the operator via ENCODE_PLANNER_ALLOW_HOSTS.")
def normalize_provider(spec: object) -> dict:
"""Validate a user-supplied provider. Raises LlmError with a message meant
to be read by whoever filled in the form."""
if not isinstance(spec, dict):
raise LlmError("Model settings are missing")
kind = str(spec.get("kind") or "").strip().lower()
if kind not in KINDS:
raise LlmError(f"API format must be one of: {', '.join(KINDS)}")
base_url = str(spec.get("base_url") or "").strip().rstrip("/")
model = str(spec.get("model") or "").strip()
api_key = str(spec.get("api_key") or "").strip()
if not base_url:
raise LlmError("Base URL is required")
if not model:
raise LlmError("Model name is required")
if not api_key:
raise LlmError("API key is required")
_assert_allowed_target(base_url)
label = str(spec.get("label") or "").strip() or model
return {"kind": kind, "base_url": base_url, "model": model,
"api_key": api_key, "label": label[:60]}
# -- request shaping --------------------------------------------------------
# Providers on the OpenAI wire format disagree about three fields, and the
# disagreement is only discoverable from a 400. OpenAI's newer models renamed
# the token cap to max_completion_tokens and accept only the default
# temperature; DeepSeek, Groq, vLLM and most gateways still want max_tokens.
# So: start with the spelling the host is known to want, and treat a 400 that
# names one of these fields as instructions rather than as a failure.
_OPENAI_HOSTS = ("api.openai.com",)
def _token_cap_field(base_url: str) -> str:
host = urllib.parse.urlsplit(base_url).hostname or ""
return ("max_completion_tokens" if host.lower() in _OPENAI_HOSTS
else "max_tokens")
def _relax_body(body: dict, message: str) -> bool:
"""Rename or drop the one field a 400 objected to.
Returns True when the body changed and the call is worth retrying. The
token cap flips to whichever spelling this body is not using, so the same
rule serves a model that wants max_completion_tokens and a gateway that
rejects it.
"""
m = message.lower()
if "max_tokens" in m or "max_completion_tokens" in m:
if "max_tokens" in body:
body["max_completion_tokens"] = body.pop("max_tokens")
return True
if "max_completion_tokens" in body:
body["max_tokens"] = body.pop("max_completion_tokens")
return True
if "temperature" in m and "temperature" in body:
body.pop("temperature") # reasoning models take the default only
return True
if "response_format" in m and "response_format" in body:
body.pop("response_format") # prompt still asks for JSON
return True
if "reasoning" in m and "reasoning" in body:
body.pop("reasoning") # costs the panel, not the plan
return True
return False
# What each endpoint turned out to accept, learned from its own 400s and kept
# for the life of the process. Without this every plan would pay one rejected
# request to rediscover the same fact.
_SHAPE_MEMO: dict[str, dict] = {}
def _shape_key(p: dict) -> str:
return f"{urllib.parse.urlsplit(p['base_url']).hostname}|{p['model']}"
def _remember_shape(p: dict, body: dict) -> None:
_SHAPE_MEMO[_shape_key(p)] = {
"cap": ("max_completion_tokens" if "max_completion_tokens" in body
else "max_tokens"),
"dropped": tuple(f for f in ("temperature", "response_format")
if f not in body)}
def _reasoning_opt_in(base_url: str) -> dict:
"""OpenRouter returns the model's reasoning only when asked for it.
DeepSeek sends `reasoning_content` unprompted; through OpenRouter the same
model needs `reasoning` in the body, and the text then arrives as
`delta.reasoning`, which the stream reader already handles. A provider
that does not know the field answers 400 and _relax_body drops it.
"""
host = (urllib.parse.urlsplit(base_url).hostname or "").lower()
return {"reasoning": {"enabled": True}} if host == "openrouter.ai" else {}
def _openai_body(p: dict, system: str, user: str, max_tokens: int, **extra) -> dict:
memo = _SHAPE_MEMO.get(_shape_key(p), {})
dropped = memo.get("dropped", ())
body = {"model": p["model"],
"messages": [{"role": "system", "content": system},
{"role": "user", "content": user}],
**extra}
if "response_format" not in dropped:
# json_object is the portable JSON mode. Gateways that don't know it
# generally ignore it; the prompt asks for JSON anyway and the
# response is parsed leniently below.
body["response_format"] = {"type": "json_object"}
if "temperature" not in dropped:
body["temperature"] = 0.0
body[memo.get("cap") or _token_cap_field(p["base_url"])] = max_tokens
return body
def _openai_request(p: dict, system: str, user: str, max_tokens: int):
return (f"{p['base_url']}/chat/completions",
_openai_body(p, system, user, max_tokens, stream=False),
{"Content-Type": "application/json",
"Authorization": f"Bearer {p['api_key']}"})
def _anthropic_request(p: dict, system: str, user: str, max_tokens: int):
body = {"model": p["model"], "max_tokens": max_tokens, "system": system,
"messages": [{"role": "user", "content": user}]}
return (f"{p['base_url']}/v1/messages", body,
{"Content-Type": "application/json", "x-api-key": p["api_key"],
"anthropic-version": ANTHROPIC_VERSION})
def _openai_text(payload: dict) -> tuple[str, str]:
try:
choice = payload["choices"][0]
return choice["message"]["content"] or "", choice.get("finish_reason") or ""
except (KeyError, IndexError, TypeError):
raise LlmError("Model response had no message content") from None
def _anthropic_text(payload: dict) -> tuple[str, str]:
try:
parts = [b.get("text", "") for b in payload["content"] if b.get("type") == "text"]
except (KeyError, TypeError):
raise LlmError("Model response had no message content") from None
if not parts:
raise LlmError("Model response had no message content")
stop = payload.get("stop_reason") or ""
return "".join(parts), "length" if stop == "max_tokens" else stop
def _http_detail(err: urllib.error.HTTPError) -> str:
"""The provider's own `error.message`, bounded.
Never echo the raw body: it can quote the request, and the request carries
the user's query. This one field is a fixed diagnostic about request shape,
which is what makes a 4xx debuggable and what _relax_body reads.
"""
try:
msg = json.loads(err.read().decode()).get("error", {}).get("message", "")
except Exception:
return ""
return msg if isinstance(msg, str) and 0 < len(msg) <= 200 else ""
# One retry per adjustable field, and there are three of them.
MAX_SHAPE_RETRIES = 3
_FENCE = re.compile(r"```(?:json)?\s*(.+?)\s*```", re.S)
def parse_json_object(text: str) -> dict:
"""Lenient parse: providers without a JSON mode often wrap the object in a
markdown fence or add a sentence around it. Strict parse first, then peel."""
for candidate in (text, ):
try:
value = json.loads(candidate)
if isinstance(value, dict):
return value
except json.JSONDecodeError:
pass
fenced = _FENCE.search(text)
if fenced:
try:
value = json.loads(fenced.group(1))
if isinstance(value, dict):
return value
except json.JSONDecodeError:
pass
start, end = text.find("{"), text.rfind("}")
if 0 <= start < end:
try:
value = json.loads(text[start:end + 1])
if isinstance(value, dict):
return value
except json.JSONDecodeError:
pass
raise LlmError("Model did not return valid JSON")
def stream_json(system: str, user: str, *, provider: dict | None = None,
max_tokens: int = 4000):
"""Yield ("thinking", delta) as the model reasons, then ("text", full).
Measured on deepseek-v4-flash: the first reasoning token arrives ~1.3s in
while the first answer token takes ~27s, and reasoning outweighs the answer
roughly 16:1 by volume. Streaming therefore turns almost the whole wait
into something the user can watch, and it keeps the socket busy so a read
timeout never fires on a slow call.
Only the OpenAI-compatible shape is streamed here; that is the built-in
provider's shape. A user's own model streams in their browser instead.
"""
p = provider or builtin_provider()
if p["kind"] != "openai":
raise LlmError("Streaming is only implemented for OpenAI-compatible models")
if "json" not in f"{system} {user}".lower():
raise LlmError("prompt must mention 'json' to use json_object mode")
body = _openai_body(p, system, user, max_tokens, stream=True,
**_reasoning_opt_in(p["base_url"]),
# DeepSeek honours this and returns
# completion_tokens_details.reasoning_tokens in the
# final frame, so the UI can report what the model
# actually spent rather than a character-count proxy.
stream_options={"include_usage": True})
parts: list[str] = []
finish = ""
usage: dict = {}
for attempt in range(MAX_SHAPE_RETRIES + 1):
req = urllib.request.Request(
f"{p['base_url']}/chat/completions", data=json.dumps(body).encode(),
method="POST", headers={"Content-Type": "application/json",
"Authorization": f"Bearer {p['api_key']}"})
try:
with urllib.request.urlopen(req, timeout=_timeout()) as resp:
for raw in resp:
line = raw.decode("utf-8", "replace").strip()
if not line.startswith("data: "):
continue
chunk = line[6:]
if chunk == "[DONE]":
break
try:
frame = json.loads(chunk)
except json.JSONDecodeError:
continue
if frame.get("usage"):
usage = frame["usage"] # final frame carries totals
try:
choice = frame["choices"][0]
except (KeyError, IndexError):
continue
finish = choice.get("finish_reason") or finish
delta = choice.get("delta") or {}
think = delta.get("reasoning_content") or delta.get("reasoning")
if think:
yield ("thinking", think)
if delta.get("content"):
parts.append(delta["content"])
_remember_shape(p, body)
break
except urllib.error.HTTPError as err:
detail = _http_detail(err)
# Same shape negotiation as the non-streaming path. Nothing has
# been yielded yet at this point, so the retry is invisible.
if (err.code == 400 and attempt < MAX_SHAPE_RETRIES
and not parts and _relax_body(body, detail)):
continue
raise LlmError(f"Model returned HTTP {err.code}"
f"{': ' + detail if detail else ''}") from None
except urllib.error.URLError as err:
raise LlmError(f"Could not reach the model: {err.reason}") from None
except TimeoutError:
raise LlmError("The model stopped responding") from None
if finish == "length":
raise LlmError("Model response was cut off by the token limit")
yield ("usage", {"reasoning_tokens":
(usage.get("completion_tokens_details") or {}).get("reasoning_tokens")})
yield ("text", "".join(parts))
def complete_json(system: str, user: str, *, provider: dict | None = None,
max_tokens: int = 4000) -> dict:
"""One non-streaming completion, parsed as a JSON object.
`provider` defaults to the deployment's built-in model. Raises LlmError
for missing config, transport failure, and any unusable response; callers
validate the object's contents.
"""
p = provider or builtin_provider()
# OpenAI-compatible json_object mode is rejected outright unless the word
# "json" appears in the prompt. Trimming a prompt can silently remove it,
# so fail here with the actual cause rather than shipping a 400.
if p["kind"] == "openai" and "json" not in f"{system} {user}".lower():
raise LlmError("prompt must mention 'json' to use json_object mode")
shape = _openai_request if p["kind"] == "openai" else _anthropic_request
url, body, headers = shape(p, system, user, max_tokens)
for attempt in range(MAX_SHAPE_RETRIES + 1):
req = urllib.request.Request(url, data=json.dumps(body).encode(),
method="POST", headers=headers)
try:
with urllib.request.urlopen(req, timeout=_timeout()) as resp:
payload = json.loads(resp.read().decode())
if p["kind"] == "openai":
_remember_shape(p, body)
break
except urllib.error.HTTPError as err:
detail = _http_detail(err)
# A 400 naming a field this provider spells differently is a fact
# about the endpoint, not a failed plan. Fix the field and resend;
# a rejected request costs no tokens.
if (err.code == 400 and attempt < MAX_SHAPE_RETRIES
and _relax_body(body, detail)):
continue
raise LlmError(f"Model returned HTTP {err.code}"
f"{': ' + detail if detail else ''}") from None
except urllib.error.URLError as err:
raise LlmError(f"Could not reach the model: {err.reason}") from None
except (TimeoutError, json.JSONDecodeError) as err:
raise LlmError(f"Model response was unusable: {type(err).__name__}") from None
text, finish = (_openai_text if p["kind"] == "openai" else _anthropic_text)(payload)
# Reasoning models bill their thinking against max_tokens alongside the
# visible content. A tight cap truncates the JSON mid-object, which would
# otherwise surface as a baffling "did not return valid JSON".
if finish == "length":
raise LlmError("Model response was cut off by the token limit")
return parse_json_object(text)