369 lines
12 KiB
Python
369 lines
12 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""HTTP request for VLLM Serving Engine."""
|
|
|
|
# Standard
|
|
from typing import Any, Optional
|
|
import json
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
_MAX_ERR = 65536
|
|
MetricValue = tuple[str, Any]
|
|
MetricMap = dict[str, MetricValue]
|
|
_METRIC_NAMES = {
|
|
"prompt_tokens": "Input tokens",
|
|
"output_tokens": "Output tokens",
|
|
"ttft_ms": "TTFT (ms)",
|
|
"tpot_ms_per_token": "TPOT (ms/token)",
|
|
"total_latency_ms": "Total latency (ms)",
|
|
"throughput_tokens_per_s": "Throughput (tokens/s)",
|
|
"model": "Model",
|
|
}
|
|
|
|
|
|
def _clip(text: str, limit: int = _MAX_ERR) -> str:
|
|
return (
|
|
text
|
|
if len(text) <= limit
|
|
else text[: max(0, limit - 24)] + "\n...(message truncated)..."
|
|
)
|
|
|
|
|
|
def _info(msg: str) -> None:
|
|
print(f"lmcache query: {msg}", file=sys.stderr)
|
|
|
|
|
|
def _openai_error(obj: dict[str, Any]) -> Optional[str]:
|
|
err = obj.get("error")
|
|
if err is None:
|
|
return None
|
|
if isinstance(err, str):
|
|
return err.strip() or None
|
|
if not isinstance(err, dict):
|
|
return _clip(str(err))
|
|
for key in ("message", "detail"):
|
|
val = err.get(key)
|
|
if not isinstance(val, str) or not val.strip():
|
|
continue
|
|
typ = err.get("type") or err.get("code")
|
|
if key == "message" and isinstance(typ, str) and typ.strip():
|
|
return f"{typ.strip()}: {val.strip()}"
|
|
return val.strip()
|
|
try:
|
|
return _clip(json.dumps(err, ensure_ascii=False))
|
|
except Exception:
|
|
return _clip(str(err))
|
|
|
|
|
|
def _raise_openai_error(obj: dict[str, Any]) -> None:
|
|
msg = _openai_error(obj)
|
|
if msg:
|
|
raise RuntimeError(_clip(msg))
|
|
|
|
|
|
def _raise_json_blob_error(blob: str) -> None:
|
|
s = blob.strip()
|
|
if not s.startswith("{"):
|
|
return
|
|
try:
|
|
obj = json.loads(s)
|
|
except json.JSONDecodeError:
|
|
return
|
|
if isinstance(obj, dict):
|
|
_raise_openai_error(obj)
|
|
|
|
|
|
def _api_url(base: str, path: str) -> str:
|
|
base = base.strip()
|
|
if "://" not in base:
|
|
base = f"http://{base}"
|
|
base = base.rstrip("/")
|
|
return f"{base if base.endswith('/v1') else base + '/v1'}/{path}"
|
|
|
|
|
|
def _read_json(url: str, timeout: float) -> dict[str, Any]:
|
|
try:
|
|
with urllib.request.urlopen(
|
|
urllib.request.Request(url, method="GET"), timeout=max(timeout + 2.0, 5.0)
|
|
) as resp:
|
|
raw = resp.read().decode("utf-8", errors="replace")
|
|
except urllib.error.HTTPError as e:
|
|
body = e.read().decode("utf-8", errors="replace")[:512]
|
|
raise RuntimeError(
|
|
f"GET {url} failed (HTTP {e.code}): {body or 'no body'}"
|
|
) from e
|
|
except urllib.error.URLError as e:
|
|
raise RuntimeError(f"GET {url} failed: {getattr(e, 'reason', e)}") from e
|
|
try:
|
|
obj = json.loads(raw)
|
|
except json.JSONDecodeError as e:
|
|
raise RuntimeError(f"Invalid JSON from GET {url}: {e}") from e
|
|
if not isinstance(obj, dict):
|
|
raise RuntimeError(f"GET {url}: expected a JSON object")
|
|
return obj
|
|
|
|
|
|
def _sse_piece(obj: dict[str, Any], chat: bool) -> str:
|
|
choices = obj.get("choices") or []
|
|
if not choices:
|
|
return ""
|
|
c0 = choices[0]
|
|
return (
|
|
str((c0.get("delta") or {}).get("content") or "")
|
|
if chat
|
|
else str(c0.get("text") or "")
|
|
)
|
|
|
|
|
|
def _trim_misc_buffer(misc: list[str], limit: int = _MAX_ERR) -> None:
|
|
while misc and sum(map(len, misc)) > limit:
|
|
misc.pop(0)
|
|
|
|
|
|
def _stream(
|
|
url: str,
|
|
body: dict[str, Any],
|
|
timeout: float,
|
|
*,
|
|
chat: bool,
|
|
max_tokens: int,
|
|
) -> dict[str, Any]:
|
|
"""POST with ``stream: true``; parse SSE; return the completion text plus
|
|
TTFT/TPOT and token metrics."""
|
|
payload = {
|
|
**body,
|
|
"stream": True,
|
|
"stream_options": {"include_usage": True},
|
|
}
|
|
req = urllib.request.Request(
|
|
url,
|
|
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
|
method="POST",
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
t0, first_token_t, pieces, usage, misc = time.time(), None, [], None, []
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
while True:
|
|
raw = resp.readline()
|
|
if not raw:
|
|
break
|
|
line = raw.decode("utf-8", errors="replace").strip()
|
|
if not line:
|
|
continue
|
|
if not line.startswith("data:"):
|
|
misc.append(line)
|
|
_trim_misc_buffer(misc)
|
|
continue
|
|
chunk = line[5:].strip()
|
|
if chunk == "[DONE]":
|
|
break
|
|
try:
|
|
obj = json.loads(chunk)
|
|
except json.JSONDecodeError:
|
|
misc.append(chunk)
|
|
_trim_misc_buffer(misc)
|
|
continue
|
|
if not isinstance(obj, dict):
|
|
continue
|
|
_raise_openai_error(obj)
|
|
piece = _sse_piece(obj, chat)
|
|
if piece:
|
|
first_token_t = first_token_t or time.time()
|
|
pieces.append(piece)
|
|
|
|
u_chunk = obj.get("usage")
|
|
if u_chunk is not None:
|
|
usage = u_chunk
|
|
t1 = time.time()
|
|
except urllib.error.HTTPError as e:
|
|
err_body = e.read().decode("utf-8", errors="replace")
|
|
_raise_json_blob_error(err_body)
|
|
raise RuntimeError(
|
|
_clip(f"POST {url} failed (HTTP {e.code}):\n{_clip(err_body)}")
|
|
) from e
|
|
except urllib.error.URLError as e:
|
|
raise RuntimeError(f"POST {url} failed: {getattr(e, 'reason', e)}") from e
|
|
|
|
misc_text = "\n".join(misc).strip()
|
|
_raise_json_blob_error(misc_text)
|
|
joined = "".join(pieces)
|
|
if not joined and usage is None:
|
|
raise RuntimeError(
|
|
_clip(f"No completion output from engine. Captured response:\n{misc_text}")
|
|
if misc_text
|
|
else "Empty response from engine (no SSE chunks parsed)."
|
|
)
|
|
|
|
u = usage or {}
|
|
prompt_tokens = int(u.get("prompt_tokens") or 0)
|
|
num_completion = int(u.get("completion_tokens") or 0)
|
|
# Match V2RequestSender: server count if present, else max_tokens cap.
|
|
num_generated = num_completion if num_completion > 0 else max_tokens
|
|
if first_token_t is None:
|
|
# Use total round-trip as a conservative TTFT approximation.
|
|
ttft_s = t1 - t0
|
|
decode_time = 0.0
|
|
else:
|
|
ttft_s = first_token_t - t0
|
|
decode_time = t1 - first_token_t
|
|
dt = t1 - t0
|
|
decoding_speed = (num_generated / decode_time) if decode_time > 0 else 0.0
|
|
tpot_s = (
|
|
(decode_time / num_generated) if num_generated > 0 and decode_time > 0 else 0.0
|
|
)
|
|
return {
|
|
"answer": joined,
|
|
"prompt_tokens": prompt_tokens,
|
|
"output_tokens": num_generated,
|
|
"ttft_ms": ttft_s * 1000.0,
|
|
"tpot_ms_per_token": tpot_s * 1000.0,
|
|
"total_latency_ms": dt * 1000.0,
|
|
"throughput_tokens_per_s": decoding_speed,
|
|
}
|
|
|
|
|
|
def _missing_chat_template(exc: BaseException) -> bool:
|
|
msg = str(exc).lower()
|
|
return any(
|
|
s in msg
|
|
for s in (
|
|
"chat template",
|
|
"chat_template",
|
|
"chattemplate",
|
|
"template resolution",
|
|
"must provide a chat template",
|
|
"default chat template is no longer allowed",
|
|
)
|
|
)
|
|
|
|
|
|
def _weak_completions_error(msg: str) -> bool:
|
|
msg = msg.lower()
|
|
return any(
|
|
s in msg
|
|
for s in (
|
|
"empty response from engine",
|
|
"no completion output from engine",
|
|
"no sse chunks parsed",
|
|
)
|
|
)
|
|
|
|
|
|
class Request:
|
|
"""Build and send one query request against an OpenAI-compatible endpoint."""
|
|
|
|
def __init__(
|
|
self,
|
|
base: str,
|
|
model: Optional[str],
|
|
max_tokens: int,
|
|
timeout: float,
|
|
*,
|
|
completions_only: bool = False,
|
|
chat_first: bool = False,
|
|
) -> None:
|
|
self._base = base
|
|
self._model = model
|
|
self._max_tokens = max_tokens
|
|
self._timeout = timeout
|
|
self._completions_only = completions_only
|
|
self._chat_first = chat_first
|
|
|
|
def build_request(self, prompt: str) -> dict[str, Any]:
|
|
"""Build request payload and metadata for the provided prompt."""
|
|
model = self._model or self._first_model_id()
|
|
return {
|
|
"base": self._base,
|
|
"model": model,
|
|
"prompt": prompt,
|
|
"max_tokens": self._max_tokens,
|
|
"timeout": self._timeout,
|
|
"completions_only": self._completions_only,
|
|
"chat_first": self._chat_first,
|
|
}
|
|
|
|
def send_request(self, prompt: str) -> tuple[str, MetricMap]:
|
|
"""Send request and return the completion text and its metrics.
|
|
|
|
Args:
|
|
prompt: The fully expanded prompt to send to the engine.
|
|
|
|
Returns:
|
|
A ``(answer, metrics)`` tuple where ``answer`` is the model's
|
|
completion text and ``metrics`` is a :data:`MetricMap` of token
|
|
and latency stats keyed by metric id.
|
|
"""
|
|
request_data = self.build_request(prompt)
|
|
result = self._query_with_fallback(request_data)
|
|
answer = str(result.pop("answer", ""))
|
|
stats = {"model": request_data["model"], **result}
|
|
metrics = {key: (_METRIC_NAMES.get(key, key), stats[key]) for key in stats}
|
|
return answer, metrics
|
|
|
|
def _first_model_id(self) -> str:
|
|
"""Return the first model ID from ``GET /v1/models``."""
|
|
obj = _read_json(_api_url(self._base, "models"), self._timeout)
|
|
data = obj.get("data")
|
|
if not isinstance(data, list) or not data:
|
|
raise RuntimeError(
|
|
"GET /v1/models returned no models; pass --model explicitly."
|
|
)
|
|
first = data[0]
|
|
if not isinstance(first, dict) or "id" not in first:
|
|
raise RuntimeError("GET /v1/models: first entry missing 'id'.")
|
|
return str(first["id"])
|
|
|
|
def _query_with_fallback(self, request_data: dict[str, Any]) -> dict[str, Any]:
|
|
"""Send one query and fallback between completions/chat endpoints."""
|
|
if request_data["completions_only"]:
|
|
return self._query(request_data, chat=False)
|
|
try:
|
|
return self._query(request_data, chat=request_data["chat_first"])
|
|
except RuntimeError as first_err:
|
|
if request_data["chat_first"]:
|
|
if not _missing_chat_template(first_err):
|
|
raise
|
|
_info(
|
|
"chat API failed (no chat template); retrying with /v1/completions"
|
|
)
|
|
return self._query(request_data, chat=False)
|
|
_info("/v1/completions failed; retrying with /v1/chat/completions")
|
|
try:
|
|
return self._query(request_data, chat=True)
|
|
except RuntimeError as second_err:
|
|
if _weak_completions_error(str(first_err)) and _missing_chat_template(
|
|
second_err
|
|
):
|
|
_info(
|
|
"base / completion-only models: try `--completions` or "
|
|
"an instruct model with a chat template."
|
|
)
|
|
raise second_err
|
|
raise RuntimeError(f"{first_err}; then {second_err}") from second_err
|
|
|
|
def _query(self, request_data: dict[str, Any], *, chat: bool) -> dict[str, Any]:
|
|
path = "chat/completions" if chat else "completions"
|
|
model = request_data["model"]
|
|
prompt = request_data["prompt"]
|
|
max_tokens = request_data["max_tokens"]
|
|
timeout = request_data["timeout"]
|
|
body = (
|
|
{
|
|
"model": model,
|
|
"messages": [{"role": "user", "content": prompt}],
|
|
"max_tokens": max_tokens,
|
|
}
|
|
if chat
|
|
else {"model": model, "prompt": prompt, "max_tokens": max_tokens}
|
|
)
|
|
return _stream(
|
|
_api_url(request_data["base"], path),
|
|
body,
|
|
timeout,
|
|
chat=chat,
|
|
max_tokens=max_tokens,
|
|
)
|