167 lines
5.5 KiB
Python
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())
|