237 lines
10 KiB
Python
Executable File
237 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""OpenVINO GenAI embedding HTTP service for Will's local swarm stack.
|
|
|
|
Default port: 18817
|
|
Default model: OpenVINO/bge-base-en-v1.5-int8-ov, cached under ~/.cache/openvino-models/
|
|
Default device: NPU
|
|
|
|
Exposes a deliberately small compatibility surface:
|
|
GET /healthz
|
|
GET /api/tags # Ollama-ish model listing for health scripts
|
|
POST /api/embed # Ollama-ish batched embeddings
|
|
POST /api/embeddings # Ollama-ish single embedding
|
|
POST /v1/embeddings # OpenAI-compatible embeddings response
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import threading
|
|
import time
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import openvino as ov
|
|
import openvino_genai as ovg
|
|
|
|
DEFAULT_MODEL_NAME = "bge-base-en-v1.5-int8-ov"
|
|
DEFAULT_MODEL_DIR = Path.home() / ".cache/openvino-models" / DEFAULT_MODEL_NAME
|
|
DEFAULT_PORT = 18817
|
|
NPU_BUSY_FILE = Path("/sys/class/accel/accel0/device/npu_busy_time_us")
|
|
|
|
|
|
def npu_busy_time_us() -> int | None:
|
|
try:
|
|
return int(NPU_BUSY_FILE.read_text().strip())
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
class EmbeddingService:
|
|
def __init__(self, model_dir: Path, model_name: str, device: str, max_length: int) -> None:
|
|
self.model_dir = model_dir
|
|
self.model_name = model_name
|
|
self.device = device
|
|
self.max_length = max_length
|
|
self.loaded_at = time.time()
|
|
self.lock = threading.Lock()
|
|
self.embedding_dim: int | None = None
|
|
|
|
if not self.model_dir.exists():
|
|
raise FileNotFoundError(f"model directory not found: {self.model_dir}")
|
|
|
|
core = ov.Core()
|
|
self.available_devices = list(core.available_devices)
|
|
if self.device not in self.available_devices:
|
|
raise RuntimeError(f"OpenVINO device {self.device!r} unavailable; available={self.available_devices}")
|
|
|
|
# Intel NPU currently needs static shape for this embedding pipeline.
|
|
# batch_size=1 is intentional: multi-input requests are served by looping
|
|
# one text at a time, keeping the model shape acceptable to NPUW.
|
|
cfg = ovg.TextEmbeddingPipeline.Config()
|
|
cfg.max_length = int(max_length)
|
|
cfg.pad_to_max_length = True
|
|
cfg.batch_size = 1
|
|
self.pipeline = ovg.TextEmbeddingPipeline(self.model_dir, self.device, cfg)
|
|
|
|
def embed_one(self, text: str, *, purpose: str = "query") -> dict[str, Any]:
|
|
text = str(text or "")
|
|
if not text.strip():
|
|
raise ValueError("embedding input text is empty")
|
|
if purpose not in {"query", "document"}:
|
|
raise ValueError("embedding purpose must be 'query' or 'document'")
|
|
before = npu_busy_time_us()
|
|
started = time.perf_counter()
|
|
# TextEmbeddingPipeline is a native object; serialize calls until proven
|
|
# safe under concurrent NPU use. Tiny silicon clown-car avoidance clause.
|
|
with self.lock:
|
|
if purpose == "document":
|
|
# batch_size=1 means embed_documents must receive exactly one doc.
|
|
vec = self.pipeline.embed_documents([text])[0]
|
|
else:
|
|
vec = self.pipeline.embed_query(text)
|
|
after = npu_busy_time_us()
|
|
vector = [float(x) for x in vec]
|
|
self.embedding_dim = len(vector)
|
|
return {
|
|
"embedding": vector,
|
|
"dim": len(vector),
|
|
"purpose": purpose,
|
|
"duration_ms": round((time.perf_counter() - started) * 1000, 3),
|
|
"npu_busy_delta_us": None if before is None or after is None else after - before,
|
|
}
|
|
|
|
def health(self) -> dict[str, Any]:
|
|
return {
|
|
"status": "ok",
|
|
"service": "openvino-embeddings",
|
|
"model": self.model_name,
|
|
"model_dir": str(self.model_dir),
|
|
"device": self.device,
|
|
"available_devices": self.available_devices,
|
|
"embedding_dim": self.embedding_dim,
|
|
"max_length": self.max_length,
|
|
"uptime_s": round(time.time() - self.loaded_at, 3),
|
|
"npu_busy_time_us": npu_busy_time_us(),
|
|
}
|
|
|
|
|
|
def normalize_input(value: Any) -> list[str]:
|
|
if isinstance(value, str):
|
|
return [value]
|
|
if isinstance(value, list):
|
|
texts = [str(item) for item in value]
|
|
if texts:
|
|
return texts
|
|
raise ValueError("input must be a non-empty string or list of strings")
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
server_version = "OpenVINOEmbeddings/0.1"
|
|
|
|
@property
|
|
def svc(self) -> EmbeddingService:
|
|
return self.server.embedding_service # type: ignore[attr-defined]
|
|
|
|
def do_GET(self) -> None:
|
|
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
|
if path in {"/", "/healthz", "/readyz"}:
|
|
self.write_json(self.svc.health())
|
|
elif path == "/api/tags":
|
|
self.write_json({"models": [{"name": self.svc.model_name, "model": self.svc.model_name}]})
|
|
elif path == "/v1/models":
|
|
self.write_json({"object": "list", "data": [{"id": self.svc.model_name, "object": "model", "owned_by": "local"}]})
|
|
else:
|
|
self.write_json({"error": "not found"}, status=404)
|
|
|
|
def do_POST(self) -> None:
|
|
path = self.path.split("?", 1)[0].rstrip("/") or "/"
|
|
try:
|
|
payload = self.read_json()
|
|
if path == "/api/embed":
|
|
texts = normalize_input(payload.get("input"))
|
|
purpose = str(payload.get("purpose") or payload.get("task") or "document")
|
|
results = [self.svc.embed_one(text, purpose=purpose) for text in texts]
|
|
self.write_json({
|
|
"model": payload.get("model") or self.svc.model_name,
|
|
"embeddings": [item["embedding"] for item in results],
|
|
"embedding_dim": results[0]["dim"] if results else None,
|
|
"purpose": purpose,
|
|
"npu_busy_delta_us": sum((item.get("npu_busy_delta_us") or 0) for item in results),
|
|
"durations_ms": [item["duration_ms"] for item in results],
|
|
})
|
|
elif path == "/api/embeddings":
|
|
text = payload.get("prompt") or payload.get("input")
|
|
result = self.svc.embed_one(str(text or ""), purpose="query")
|
|
self.write_json({
|
|
"model": payload.get("model") or self.svc.model_name,
|
|
"embedding": result["embedding"],
|
|
"embedding_dim": result["dim"],
|
|
"npu_busy_delta_us": result["npu_busy_delta_us"],
|
|
"duration_ms": result["duration_ms"],
|
|
})
|
|
elif path == "/v1/embeddings":
|
|
texts = normalize_input(payload.get("input"))
|
|
purpose = str(payload.get("purpose") or payload.get("task") or "query")
|
|
results = [self.svc.embed_one(text, purpose=purpose) for text in texts]
|
|
self.write_json({
|
|
"object": "list",
|
|
"model": payload.get("model") or self.svc.model_name,
|
|
"data": [
|
|
{"object": "embedding", "index": idx, "embedding": item["embedding"]}
|
|
for idx, item in enumerate(results)
|
|
],
|
|
"usage": {"prompt_tokens": 0, "total_tokens": 0},
|
|
"embedding_dim": results[0]["dim"] if results else None,
|
|
"purpose": purpose,
|
|
"npu_busy_delta_us": sum((item.get("npu_busy_delta_us") or 0) for item in results),
|
|
"durations_ms": [item["duration_ms"] for item in results],
|
|
})
|
|
else:
|
|
self.write_json({"error": "not found"}, status=404)
|
|
except ValueError as exc:
|
|
self.write_json({"error": str(exc)}, status=400)
|
|
except Exception as exc:
|
|
self.write_json({"error": f"{type(exc).__name__}: {exc}"}, status=500)
|
|
|
|
def read_json(self) -> dict[str, Any]:
|
|
length = int(self.headers.get("Content-Length") or 0)
|
|
body = self.rfile.read(length).decode("utf-8", "replace") if length else "{}"
|
|
payload = json.loads(body or "{}")
|
|
if not isinstance(payload, dict):
|
|
raise ValueError("JSON body must be an object")
|
|
return payload
|
|
|
|
def write_json(self, payload: dict[str, Any], status: int = 200) -> None:
|
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - stdlib override name
|
|
print(f"{self.address_string()} - {format % args}", file=sys.stderr, flush=True)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--host", default=os.environ.get("OPENVINO_EMBED_HOST", "0.0.0.0"))
|
|
parser.add_argument("--port", type=int, default=int(os.environ.get("OPENVINO_EMBED_PORT", DEFAULT_PORT)))
|
|
parser.add_argument("--model-dir", default=os.environ.get("OPENVINO_EMBED_MODEL_DIR", str(DEFAULT_MODEL_DIR)))
|
|
parser.add_argument("--model-name", default=os.environ.get("OPENVINO_EMBED_MODEL", DEFAULT_MODEL_NAME))
|
|
parser.add_argument("--device", default=os.environ.get("OPENVINO_EMBED_DEVICE", "NPU"))
|
|
parser.add_argument("--max-length", type=int, default=int(os.environ.get("OPENVINO_EMBED_MAX_LENGTH", "512")))
|
|
args = parser.parse_args()
|
|
|
|
service = EmbeddingService(Path(args.model_dir).expanduser(), args.model_name, args.device, args.max_length)
|
|
httpd = ThreadingHTTPServer((args.host, args.port), Handler)
|
|
httpd.embedding_service = service # type: ignore[attr-defined]
|
|
print(
|
|
f"openvino-embeddings listening on {args.host}:{args.port} "
|
|
f"model={args.model_name} device={args.device}",
|
|
flush=True,
|
|
)
|
|
try:
|
|
httpd.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|