Files
IPSExtractor/docs/plan_pruebas/scripts/llm_mock_server.py
T

167 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""Mock OpenAI-compatible para ST-EXT-01 (cola ARQ sin coste LLM real).
Endpoints:
GET /v1/models
POST /v1/chat/completions
Responde JSON fijo con delay configurable (default 50 ms).
Ejemplo:
python docs/plan_pruebas/scripts/llm_mock_server.py --port 8099 --delay-ms 50
Apuntar extraction/workers (Docker):
EXTRACTOR_BASE_URL=http://host.docker.internal:8099/v1
NORMALIZER_BASE_URL=http://host.docker.internal:8099/v1
EXTRACTOR_API_KEY=mock-key
ASYNC_QUEUE=true
"""
from __future__ import annotations
import argparse
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
MOCK_SCHEMA = {
"numero_factura": "str",
"fecha": "str",
"nit_prestador": "str",
"razon_social": "str",
"valor_total": "number",
}
MOCK_EXTRACTION = {
"numero_factura": "MOCK-0001",
"fecha": "2026-01-15",
"nit_prestador": "900000000",
"razon_social": "IPS MOCK ST-EXT-01",
"valor_total": 1000,
}
class Stats:
def __init__(self) -> None:
self.lock = threading.Lock()
self.completions = 0
self.models = 0
def inc(self, kind: str) -> None:
with self.lock:
if kind == "completions":
self.completions += 1
else:
self.models += 1
STATS = Stats()
def make_handler(delay_ms: float):
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args) -> None: # quieter
pass
def _json(self, code: int, payload: dict) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None: # noqa: N802
path = urlparse(self.path).path.rstrip("/")
if path in ("/v1/models", "/models"):
STATS.inc("models")
self._json(
200,
{
"object": "list",
"data": [
{"id": "mock-llm", "object": "model", "owned_by": "st-ext-01"},
{"id": "gpt-5.4-mini", "object": "model", "owned_by": "st-ext-01"},
],
},
)
return
if path in ("/health", "/v1/health"):
self._json(200, {"status": "ok", "completions": STATS.completions})
return
self._json(404, {"error": {"message": f"not found: {path}", "type": "not_found"}})
def do_POST(self) -> None: # noqa: N802
path = urlparse(self.path).path.rstrip("/")
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
try:
req = json.loads(raw.decode("utf-8") or "{}")
except Exception:
req = {}
if path not in ("/v1/chat/completions", "/chat/completions"):
self._json(404, {"error": {"message": f"not found: {path}", "type": "not_found"}})
return
if delay_ms > 0:
time.sleep(delay_ms / 1000.0)
# Heurística: schema discovery suele pedir "campos" / schema; extracción pide factura.
messages = req.get("messages") or []
blob = json.dumps(messages, ensure_ascii=False).lower()
if "schema" in blob or "campos" in blob or "field" in blob or "descub" in blob:
content = json.dumps(MOCK_SCHEMA, ensure_ascii=False)
else:
content = json.dumps(MOCK_EXTRACTION, ensure_ascii=False)
model = req.get("model") or "mock-llm"
STATS.inc("completions")
self._json(
200,
{
"id": f"chatcmpl-mock-{STATS.completions}",
"object": "chat.completion",
"created": int(time.time()),
"model": model,
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": content},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 100,
"completion_tokens": 50,
"total_tokens": 150,
},
},
)
return Handler
def main() -> int:
p = argparse.ArgumentParser(description="Mock OpenAI API for ST-EXT-01")
p.add_argument("--host", default="0.0.0.0")
p.add_argument("--port", type=int, default=int(__import__("os").environ.get("LLM_MOCK_PORT", "8099")))
p.add_argument("--delay-ms", type=float, default=float(__import__("os").environ.get("LLM_MOCK_DELAY_MS", "50")))
args = p.parse_args()
httpd = ThreadingHTTPServer((args.host, args.port), make_handler(args.delay_ms))
print(f"[llm-mock] http://{args.host}:{args.port}/v1 delay={args.delay_ms}ms")
print(f"[llm-mock] health: http://127.0.0.1:{args.port}/health")
print("[llm-mock] Ctrl+C para detener")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\n[llm-mock] stopped")
return 0
if __name__ == "__main__":
raise SystemExit(main())