""" Minimal HTTP server for llm_connect — serve mode (FR-1). Exposes: POST /execute — run a prompt through the configured adapter POST /images — render one square image when an image client is configured GET /health — liveness probe Usage (programmatic):: from llm_connect import MockLLMAdapter from llm_connect.server import LLMServer server = LLMServer(adapter=MockLLMAdapter(), port=8080) server.start() # background thread # ... server.stop() Usage (CLI):: python -m llm_connect.server --port 8080 --provider openrouter --model google/gemini-2.5-flash """ import argparse import base64 import datetime as _dt import json import os import re import threading import time import uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any from urllib.parse import parse_qs, urlsplit from llm_connect._diagnostics import capture_diagnostics from llm_connect.adapter import LLMAdapter from llm_connect.exceptions import ( LLMAPIError, LLMBudgetExceededError, LLMConfigurationError, LLMError, LLMRateLimitError, LLMTimeoutError, ) from llm_connect.images import ( ImageClient, ImageRenderRequest, ImageSpendGate, render_image, usage_event_from_receipt, ) from llm_connect.models import LLMResponse, RunConfig from llm_connect.profiles import ProfiledLLMAdapter, default_runtime_profiles from llm_connect.usage import UsageLedger, maybe_record_usage, suppress_auto_usage_record class _AdapterHTTPServer(ThreadingHTTPServer): adapter: LLMAdapter image_client: ImageClient | None image_gate: ImageSpendGate | None class _Handler(BaseHTTPRequestHandler): """Request handler — adapter injected via server.adapter.""" server: _AdapterHTTPServer def log_message(self, format: str, *args: Any) -> None: # suppress default access log pass # ── GET ──────────────────────────────────────────────────────── def do_GET(self) -> None: parsed = urlsplit(self.path) if parsed.path == "/health": self._respond(200, {"status": "ok"}) else: self._respond(404, {"error": "not found"}) # ── POST ─────────────────────────────────────────────────────── def do_POST(self) -> None: parsed = urlsplit(self.path) if parsed.path == "/images": self._post_image() return if parsed.path != "/execute": self._respond(404, {"error": "not found"}) return debug_enabled = _debug_requested(parsed.query) audit_dir = os.environ.get("LLM_CONNECT_AUDIT_DIR") length = int(self.headers.get("Content-Length", 0)) raw = self.rfile.read(length) try: data = json.loads(raw) except (json.JSONDecodeError, ValueError): self._respond(400, {"error": "invalid JSON body"}) return prompt = data.get("prompt") if not prompt: self._respond(400, {"error": "missing required field: 'prompt'"}) return cfg = data.get("config", {}) if not isinstance(cfg, dict): self._respond(400, {"error": "field 'config' must be an object"}) return config = RunConfig.from_dict(cfg) start = time.time() diagnostics_enabled = debug_enabled or bool(audit_dir) try: with capture_diagnostics(diagnostics_enabled) as diagnostics: adapter = self.server.adapter if not adapter.validate_config(config): raise LLMConfigurationError( "Adapter rejected RunConfig", context={"model_name": config.model_name}, ) with suppress_auto_usage_record(): response = adapter.execute_prompt(prompt, config) latency = time.time() - start provider = ( (response.metadata or {}).get("provider") or getattr(adapter, "provider_id", None) or type(adapter).__name__ ) maybe_record_usage( response, provider=str(provider), source="server", ) body = response.to_dict() debug = diagnostics.to_dict() if diagnostics is not None else None if debug_enabled and debug is not None: body["debug"] = debug if audit_dir: _write_audit_record(audit_dir, prompt, config, response, debug, latency) self._respond(200, body) except Exception as exc: status, body = _error_response(exc) self._respond(status, body) def _post_image(self) -> None: client = self.server.image_client gate = self.server.image_gate if client is None or gate is None: self._respond(404, {"error": "not found"}) return length = int(self.headers.get("Content-Length", 0)) raw = self.rfile.read(length) try: data = json.loads(raw) except (json.JSONDecodeError, ValueError): self._respond(400, {"error": "invalid JSON body"}) return if not isinstance(data, dict): self._respond(400, {"error": "JSON body must be an object"}) return prompt = data.get("prompt") if not isinstance(prompt, str) or not prompt.strip(): self._respond(400, {"error": "missing required field: 'prompt'"}) return size = data.get("size", "1024x1024") output_format = data.get("output_format", "png") if not isinstance(size, str) or not isinstance(output_format, str): self._respond(400, {"error": "size and output_format must be strings"}) return try: result = render_image( client, ImageRenderRequest(prompt=prompt, size=size, output_format=output_format), gate=gate, ) except Exception as exc: status, body = _error_response(exc) self._respond(status, body) return ledger_path = os.environ.get("LLM_CONNECT_USAGE_LEDGER") if ledger_path: UsageLedger(Path(ledger_path)).append( usage_event_from_receipt(result.receipt, source="server") ) body = { "receipt": result.receipt.to_dict(), "image_base64": base64.b64encode(result.image).decode("ascii"), } self._respond(200, body) # ── helpers ──────────────────────────────────────────────────── def _respond(self, status: int, body: dict) -> None: payload = json.dumps(body).encode() self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) class LLMServer: """HTTP server wrapping an :class:`~llm_connect.adapter.LLMAdapter`. Args: adapter: The adapter that handles ``POST /execute`` requests. host: Bind address (default ``"127.0.0.1"``). port: TCP port (default ``8080``; ``0`` picks a free port). """ def __init__( self, adapter: LLMAdapter, host: str = "127.0.0.1", port: int = 8080, image_client: ImageClient | None = None, image_gate: ImageSpendGate | None = None, ) -> None: self._httpd = _AdapterHTTPServer((host, port), _Handler) self._httpd.adapter = adapter self._httpd.image_client = image_client self._httpd.image_gate = image_gate self._thread: threading.Thread | None = None @property def port(self) -> int: """Actual bound port (useful when ``port=0`` was requested).""" return self._httpd.server_address[1] @property def host(self) -> str: return str(self._httpd.server_address[0]) def start(self) -> None: """Start serving in a daemon background thread.""" self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True) self._thread.start() def stop(self) -> None: """Shut down the server and join the background thread.""" self._httpd.shutdown() if self._thread is not None: self._thread.join() def serve_forever(self) -> None: """Block the calling thread until interrupted.""" self._httpd.serve_forever() # ── CLI entry point ──────────────────────────────────────────────────────────── def _build_adapter( provider: str, model: str | None, *, enable_profiles: bool = True, strict_profiles: bool = False, ) -> LLMAdapter: from llm_connect.factory import create_adapter adapter = create_adapter(provider, model=model) if not enable_profiles: return adapter return ProfiledLLMAdapter( adapter, default_runtime_profiles(provider=provider, model=model), strict_profiles=strict_profiles, ) def _debug_requested(query: str) -> bool: env = os.environ.get("LLM_CONNECT_DEBUG", "") if _truthy(env): return True values = parse_qs(query).get("debug", []) return any(_truthy(value) for value in values) def _truthy(value: str) -> bool: return value.strip().lower() in {"1", "true", "yes", "on"} def _error_response(exc: Exception) -> tuple[int, dict]: """Map exceptions to operator-useful, secret-safe server responses.""" if isinstance(exc, LLMRateLimitError): body = _error_body("provider_rate_limited", exc) body["provider_status"] = exc.status_code return 429, body if isinstance(exc, LLMTimeoutError): return 504, _error_body("provider_timeout", exc) if isinstance(exc, LLMAPIError): body = _error_body("provider_api_error", exc) if exc.status_code: body["provider_status"] = exc.status_code return 502, body if isinstance(exc, LLMBudgetExceededError): return 400, _error_body("budget_exceeded", exc) if isinstance(exc, LLMConfigurationError): if _message(exc).startswith("Unknown LLM runtime profile"): return 400, _error_body("unknown_profile", exc) return 500, _error_body("configuration_error", exc) if isinstance(exc, LLMError): return 500, _error_body("llm_error", exc) return 500, _error_body("internal_error", exc) def _error_body(code: str, exc: Exception) -> dict: body: dict[str, Any] = { "error": code, "message": _sanitize_text(_message(exc)), "type": exc.__class__.__name__, } context = getattr(exc, "context", None) if isinstance(context, dict): safe_context = _safe_context(context) if safe_context: body["context"] = safe_context return body def _message(exc: Exception) -> str: if exc.args: return str(exc.args[0]) return str(exc) def _safe_context(context: dict) -> dict: safe: dict[str, Any] = {} for key, value in context.items(): lowered = str(key).lower() if any(secret_word in lowered for secret_word in ("key", "secret", "token", "password")): safe[key] = "" elif isinstance(value, (str, int, float, bool)) or value is None: safe[key] = _sanitize_text(str(value)) if isinstance(value, str) else value else: safe[key] = _sanitize_text(str(value)) return safe def _sanitize_text(value: str) -> str: value = re.sub(r"Bearer\s+[A-Za-z0-9._~+/=-]+", "Bearer ", value) value = re.sub(r"([?&]key=)[^&\s]+", r"\1", value) value = re.sub(r"\bsk-[A-Za-z0-9_-]{8,}", "sk-", value) value = re.sub( r"(?i)(api[_-]?key|token|secret|password)=([^,\s\]]+)", r"\1=", value, ) return value def _write_audit_record( audit_dir: str, prompt: str, config: RunConfig, response: LLMResponse, debug: dict | None, latency_seconds: float, ) -> None: target_dir = Path(audit_dir) target_dir.mkdir(parents=True, exist_ok=True) now = _dt.datetime.now(_dt.timezone.utc) response_id = str(response.metadata.get("response_id") or uuid.uuid4().hex) filename = f"{now.strftime('%Y%m%dT%H%M%S%fZ')}-{_safe_filename(response_id)}.json" diagnostics = debug or {} record = { "timestamp": now.isoformat().replace("+00:00", "Z"), "prompt": prompt, "config": config.to_dict(), "provider": response.metadata.get("provider"), "provider_request": diagnostics.get("provider_request"), "provider_response": diagnostics.get("provider_response"), "adapter_transformations": diagnostics.get("adapter_transformations", []), "parsed_content": response.content, "latency_seconds": round(latency_seconds, 3), "response": response.to_dict(), } (target_dir / filename).write_text( json.dumps(record, indent=2, sort_keys=True), encoding="utf-8", ) def _safe_filename(value: str) -> str: return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-") or "response" def main(argv: list[str] | None = None) -> None: parser = argparse.ArgumentParser( prog="python -m llm_connect.server", description="Start llm_connect HTTP serve mode.", ) parser.add_argument( "--port", type=int, default=int(os.environ.get("LLM_CONNECT_PORT", "8080")), help="TCP port (default: env LLM_CONNECT_PORT or 8080)", ) parser.add_argument( "--host", default=os.environ.get("LLM_CONNECT_HOST", "127.0.0.1"), help="Bind address (default: env LLM_CONNECT_HOST or 127.0.0.1)", ) parser.add_argument( "--provider", default=os.environ.get("LLM_CONNECT_PROVIDER", "mock"), help="Provider name passed to create_adapter (default: env LLM_CONNECT_PROVIDER or mock)", ) parser.add_argument( "--model", default=os.environ.get("LLM_CONNECT_MODEL") or None, help="Model name (default: env LLM_CONNECT_MODEL, optional)", ) parser.add_argument( "--disable-profiles", action="store_true", help="Disable server runtime profile dispatch.", ) parser.add_argument( "--strict-profiles", action="store_true", default=_truthy(os.environ.get("LLM_CONNECT_STRICT_PROFILES", "")), help="Reject non-profile model_name values instead of passing them through.", ) parser.add_argument( "--image-model", default=os.environ.get("LLM_CONNECT_IMAGE_MODEL") or None, help="Enable POST /images for this model. Requires per-call cap and quote.", ) parser.add_argument("--image-provider", default="openrouter", help="Image provider (default openrouter)") parser.add_argument("--image-max-cost-usd", type=float, default=None, help="Per-call USD cap for /images") parser.add_argument( "--image-max-batch-cost-usd", type=float, default=None, help="Process-lifetime USD cap for /images", ) parser.add_argument( "--image-quote-usd", type=float, default=None, help="USD reserved before each /images call", ) args = parser.parse_args(argv) adapter = _build_adapter( args.provider, args.model, enable_profiles=not args.disable_profiles, strict_profiles=args.strict_profiles, ) image_client = None image_gate = None if args.image_model: if args.image_max_cost_usd is None or args.image_quote_usd is None: parser.error("--image-model requires --image-max-cost-usd and --image-quote-usd") from llm_connect.images import ImageSpendPolicy, create_image_client image_client = create_image_client(args.image_provider, model=args.image_model) image_gate = ImageSpendGate( ImageSpendPolicy( max_cost_usd=args.image_max_cost_usd, max_batch_cost_usd=args.image_max_batch_cost_usd, quote_usd=args.image_quote_usd, ) ) server = LLMServer( adapter=adapter, host=args.host, port=args.port, image_client=image_client, image_gate=image_gate, ) print(f"llm_connect server listening on http://{args.host}:{args.port}") try: server.serve_forever() except KeyboardInterrupt: print("\nShutting down.") if __name__ == "__main__": main()