HFL — Complete Architecture

Run HuggingFace Models Locally Like Ollama
v0.15.0 Python >=3.10 License Apache-2.0 Author: Gabriel Galan Pelayo Build: Hatchling 3,537 tests | ~89% coverage

1. Overview and Purpose

HFL (HuggingFace Local) is a CLI tool and API server that allows downloading, managing, and running artificial intelligence models from HuggingFace Hub directly on the user's local machine. Its design aims to be a drop-in replacement for Ollama, but connected to the HuggingFace ecosystem with more than 500,000 models.

🎯

Problem It Solves

Users need to run LLMs locally without depending on cloud APIs. Ollama solves this but with a limited catalog. HFL connects directly to HuggingFace Hub, offering access to the world's largest catalog of open-weight models, with download, automatic GGUF conversion, and local execution.

🏗️

Design Philosophy

Extreme modularity with lazy imports, dual API compatibility (OpenAI + Ollama), rigorous legal compliance (licenses, privacy, EU AI Act), and multi-backend support (llama.cpp, Transformers, vLLM) with automatic selection based on model format and available hardware.

2. Technology Stack

Core

Python >=3.10Base language
typer >=0.12CLI Framework
rich >=13.0Styled output
pydantic >=2.10API data validation
pyyaml >=6.0Configuration parsing

API Server

fastapi >=0.115Async web framework
uvicorn >=0.32ASGI server
sse-starlette >=2.0Server-Sent Events
httpx >=0.28Async HTTP client

HuggingFace

huggingface-hub >=0.27HF Hub API

Optional Extras

llama-cpp-pythonGGUF Backend
transformers+torchNative GPU backend
vllmProduction backend
ggufFormat conversion

3. Project File Structure

hfl/
├── pyproject.toml           — Package definition, deps, scripts, tools
├── hfl.spec                 — PyInstaller spec for standalone executable
├── LICENSE                  — Apache-2.0
├── PRIVACY.md               — Privacy policy
├── NOTICE-EU-AI-ACT.md     — EU AI Act compliance
├── DISCLAIMER.md            — Liability disclaimer
├── README.md                — Main documentation
├── README.es.md             — Main documentation (Spanish)
├── LICENSE-FAQ.md            — License notice
├── CONTRIBUTING.md           — Contributing guide
├── CODE_OF_CONDUCT.md        — Code of Conduct (Contributor Covenant 2.1)
├── CHANGELOG.md              — Changelog (Keep a Changelog format)
├── SECURITY.md               — Security policy
│
├── src/hfl/                 — MAIN SOURCE CODE
│   ├── __init__.py          — Package version (0.15.0)
│   ├── config.py            — HFLConfig dataclass — global configuration
│   ├── exceptions.py        — Complete exception hierarchy
│   ├── events.py            — EventBus for internal pub/sub
│   ├── metrics.py           — Performance metrics
│   ├── plugins.py           — Plugin system with entry_points
│   ├── security.py          — Security sanitization and validation
│   ├── validators.py        — Common data validators
│   ├── logging_config.py    — Centralized logging configuration
│   │
│   ├── core/                — SYSTEM CORE
│   │   ├── __init__.py
│   │   ├── container.py     — DI container with thread-safe Singleton
│   │   ├── observability_setup.py — Observability event listeners setup
│   │   └── tracing.py       — Request tracing with IDs
│   │
│   ├── cli/                 — COMMAND LINE INTERFACE
│   │   ├── __init__.py
│   │   ├── main.py          — 12 commands: pull, run, serve, list, search, rm, inspect, alias, login, logout, version, compliance-report
│   │   └── commands/        — Modularized commands
│   │       └── _utils.py    — Shared CLI utilities
│   │
│   ├── api/                 — REST API SERVER
│   │   ├── __init__.py
│   │   ├── server.py        — FastAPI app, CORS, disclaimer, lifespan
│   │   ├── state.py         — ServerState with async locks for LLM/TTS
│   │   ├── streaming.py     — SSE streaming helpers
│   │   ├── model_loader.py  — Dynamic model loading
│   │   ├── helpers.py       — ensure_llm_loaded, ensure_tts_loaded
│   │   ├── errors.py        — Centralized HTTP error handling
│   │   ├── middleware.py    — Privacy-safe logging
│   │   ├── rate_limit.py    — Rate limiting by IP/token
│   │   ├── routes_openai.py — /v1/chat/completions, /v1/completions, /v1/models
│   │   ├── routes_native.py — /api/generate, /api/chat, /api/tags (Ollama-compatible)
│   │   ├── routes_tts.py    — /v1/audio/speech, /api/tts (TTS endpoints)
│   │   ├── routes_health.py — /health, /ready, /live (healthchecks)
│   │   ├── routes_metrics.py — /metrics (Prometheus + JSON)
│   │   ├── exception_handlers.py — Global HFLError exception handling
│   │   └── timeout.py       — @with_timeout decorator + configurable timeout
│   │
│   ├── engine/              — INFERENCE ENGINES
│   │   ├── __init__.py
│   │   ├── base.py          — InferenceEngine + AudioEngine ABCs + dataclasses
│   │   ├── selector.py      — Automatic backend selection for LLM/TTS
│   │   ├── llama_cpp.py     — LlamaCppEngine (GGUF, CPU/GPU)
│   │   ├── transformers_engine.py — TransformersEngine (safetensors, GPU)
│   │   ├── vllm_engine.py   — VLLMEngine (production GPU)
│   │   ├── bark_engine.py   — BarkEngine (TTS via transformers)
│   │   ├── coqui_engine.py  — CoquiEngine (TTS XTTS-v2)
│   │   ├── async_wrapper.py — Sync→async wrapper for engines
│   │   ├── model_pool.py    — Model pool with LRU eviction
│   │   ├── dependency_check.py — Optional dependency verification
│   │   ├── failover.py      — FailoverEngine (multi-engine retry with sticky routing)
│   │   ├── memory.py        — Real-time RAM/GPU memory tracking
│   │   ├── observability.py — Engine performance metrics
│   │   └── prompt_builder.py — Prompt formats + delimiter escaping
│   │
│   ├── hub/                 — HUGGINGFACE INTEGRATION
│   │   ├── __init__.py
│   │   ├── auth.py          — Authentication and tokens
│   │   ├── client.py        — HTTP client for HF Hub
│   │   ├── downloader.py    — Download with resume and rate limiting
│   │   ├── license_checker.py — License classification (5 levels)
│   │   └── resolver.py      — Intelligent model resolution
│   │
│   ├── models/              — DATA MODELS
│   │   ├── __init__.py
│   │   ├── manifest.py      — ModelManifest — complete metadata
│   │   ├── provenance.py    — ConversionRecord + ProvenanceLog
│   │   ├── registry.py      — ModelRegistry — local JSON inventory
│   │   └── backends/        — Storage backends (File + SQLite)
│   │
│   ├── converter/           — FORMAT CONVERSION
│   │   ├── __init__.py
│   │   ├── formats.py       — ModelFormat + ModelType enums
│   │   └── gguf_converter.py — GGUFConverter — conversion + quantization
│   │
│   ├── utils/               — CROSS-CUTTING UTILITIES
│   │   ├── __init__.py
│   │   ├── circuit_breaker.py — Circuit breaker for resilience
│   │   └── retry.py         — Retry with exponential backoff
│   │
│   └── i18n/                — INTERNATIONALIZATION
│       ├── __init__.py      — t(), get_language(), set_language()
│       └── locales/         — Translation files
│           ├── en.json      — English translations
│           └── es.json      — Spanish translations
│
├── docs/                    — DOCUMENTATION
│   ├── adr/                 — Architecture Decision Records
│   │   ├── 0001-singleton-pattern.md
│   │   ├── 0002-async-api-sync-engines.md
│   │   ├── 0003-gguf-default-format.md
│   │   ├── 0004-ollama-compatibility.md
│   │   ├── 0005-license-classification.md
│   │   └── 0006-rate-limiting-strategy.md
│   └── *.html               — Architecture documentation
│
└── tests/                   — TEST SUITE (~200 files, ~89% coverage)
    ├── conftest.py          — Shared fixtures
    ├── test_api*.py         — API tests (5 files)
    ├── test_cli*.py         — CLI tests (4 files)
    ├── test_engine*.py      — Engine tests (8 files)
    ├── test_hub*.py         — HF Hub tests (5 files)
    ├── test_i18n*.py        — i18n tests (4 files)
    └── test_*.py            — Unit and integration tests

├── .github/                 — GITHUB INFRASTRUCTURE
│   ├── workflows/
│   │   ├── ci.yml           — CI: lint + test + type-check (Python 3.10/3.11/3.12)
│   │   ├── pages.yml        — Deploy docs to GitHub Pages
│   │   ├── build-executables.yml — Cross-platform executable builds
│   │   ├── lint.yml         — Linting with ruff
│   │   ├── test.yml         — Tests with pytest
│   │   ├── security.yml     — Security audit
│   │   └── license-check.yml — License verification
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug_report.yml   — Bug report template
│   │   └── feature_request.yml — Feature request template
│   └── PULL_REQUEST_TEMPLATE.md — PR template with compliance checklist

4. General Architecture Diagram

High-Level Architecture — HFL v0.15.0
CLI (typer + rich) pull run serve list search rm inspect alias login logout version cli/main.py — Entry point: hfl.cli.main:app API Server (FastAPI + Uvicorn) OpenAI Compatible Ollama Compatible Middleware: CORS + Disclaimer + Privacy Logger Consumers Terminal (interactive CLI) OpenAI SDK / httpx / curl Ollama Apps (Open WebUI, etc.) Hub (HuggingFace Integration) resolver.py downloader.py license_checker.py auth.py Connection: huggingface_hub API Engine (Inference Engines) LlamaCpp GGUF CPU/GPU Transformers safetensors GPU vLLM Production GPU selector.py → Auto-selection by format + hardware ABC: InferenceEngine (base.py) Models (Data and Registry) manifest.py registry.py provenance.py Persistence: ~/.hfl/models.json ~/.hfl/provenance.json Converter (Format Conversion) formats.py gguf_converter.py safetensors → FP16 GGUF → Quantized GGUF Config + Exceptions (Core) config.py (HFLConfig) exceptions.py (15 types) Home: ~/.hfl | Port: 11434 (Ollama compat) HuggingFace Hub (External) huggingface.co API 500,000+ models Gated models + Licenses Local File System — ~/.hfl/ models/ Model files (GGUF, safetensors) models.json Local model registry provenance.json Conversion log cache/ + tools/llama.cpp HF cache + compiled tools

5. Module config — Central Configuration

File: src/hfl/config.py

Contains the HFLConfig class (dataclass) that defines all global application configuration. It is instantiated once as a singleton (config = HFLConfig()) when importing the module, and ensure_dirs() is called to create the directory structure.

PropertyTypeDefaultEnv VarDescription
home_dirPath~/.hflHFL_HOMERoot directory
models_dirPath (prop)~/.hfl/modelsStorage for downloaded models
cache_dirPath (prop)~/.hfl/cacheTemporary HuggingFace cache
registry_pathPath (prop)~/.hfl/models.jsonModel registry (local inventory)
llama_cpp_dirPath (prop)~/.hfl/tools/llama.cppCompiled conversion tools
hoststr127.0.0.1HFL_HOSTAPI server address
portint11434HFL_PORTPort (same as Ollama for compatibility)
default_ctx_sizeint0 (auto-detect)HFL_DEFAULT_CTX_SIZEDefault n_ctx; 0 = auto-detect from GGUF metadata
default_n_gpu_layersint-1GPU layers (-1 = all)
rate_limit_enabledbooltrueHFL_RATE_LIMIT_ENABLEDEnable/disable rate limiting
rate_limit_requestsint60HFL_RATE_LIMIT_REQUESTSMax requests per window
rate_limit_windowint60HFL_RATE_LIMIT_WINDOWRate limit window (seconds)
hf_tokenstr|NoneHF_TOKENAuthentication token (memory only, never persisted)
SLOConfig: Service Level Objectives configuration for monitoring availability targets and latency percentiles (P50, P95, P99). Used by the health and metrics endpoints to report SLI compliance.

New Environment Knobs (since v0.1.0)

Resolution rule for every row: HFL-specific name → HFL alias → Ollama-equivalent → default. So a host that already exports Ollama vars works as a drop-in replacement. An invalid value is logged once and ignored — boot never fails on operator misconfiguration. Full reference: docs/env-vars.md.

HFLOllama aliasDefaultWhat it does
OLLAMA_HOST / OLLAMA_PORT(native)Accepted for host/port: OLLAMA_HOST parses host, host:port or :port (_parse_ollama_host_env).
HFL_LLM_LIBRARYOLLAMA_LLM_LIBRARY(auto)Pin backend: llama-cpp, transformers, vllm, mlx. Per-call backend= still wins.
HFL_FLASH_ATTENTIONOLLAMA_FLASH_ATTENTION(auto)Toggle flash-attention fleet-wide; per-arch safety list still rejects unsafe arches.
HFL_DISABLE_MLX0Forces the llama-cpp Metal path on Apple Silicon (benchmarking).
HFL_KV_CACHE_TYPEOLLAMA_KV_CACHE_TYPEf16KV-cache dtype: f16 / q8_0 / q4_0 (halves / quarters VRAM).
HFL_NUM_PARALLEL / HFL_QUEUE_MAX_INFLIGHTOLLAMA_NUM_PARALLEL1Concurrent inference slots in the dispatcher.
HFL_MAX_QUEUE / HFL_QUEUE_MAX_SIZEOLLAMA_MAX_QUEUE16Wait-queue depth; over it requests get 429.
HFL_QUEUE_ACQUIRE_TIMEOUT60Seconds a caller waits for a slot before 503.
HFL_KEEP_ALIVEOLLAMA_KEEP_ALIVE5mDefault keep-alive when a request omits the field (Ollama duration grammar; -1=never).
HFL_ORIGINSOLLAMA_ORIGINS(same-origin)Comma-separated CORS allow-list; * flips wildcard mode (rejects credentials).
HFL_DEBUGOLLAMA_DEBUG(off)Truthy forces the hfl root logger to DEBUG.
HFL_MAX_REQUEST_BYTES10 MiBCap on request body (0 disables). Blob uploads use HFL_MAX_BLOB_BYTES (default unlimited).
HFL_STREAM_QUEUE_PUT/GET_TIMEOUT60/30Streaming backpressure timeouts (engine enqueue / consumer wait).
HFL_REGISTRY_SQLITE_TIMEOUT30SQLite registry busy-timeout in seconds.
OTEL_EXPORTER_OTLP_ENDPOINT(off)Standard OpenTelemetry env; HFL emits spans when set (extra [otel]).
CORS guard: HFLConfig.__post_init__ rejects the wildcard-origin + allow_credentials combination at construction time, so the misconfiguration surfaces on boot rather than after hours of broken CORS.

6. Module cli — Command Line Interface

File: src/hfl/cli/main.py (~2,350 lines)

Framework: Typer + Rich. Entry point registered in pyproject.toml as hfl = "hfl.cli.main:app"

CommandDescriptionKey Options
hfl pull <model>Download model from HF Hub--quantize Q4_K_M, --format auto|gguf|safetensors, --alias, --skip-license
hfl run <model>Interactive terminal chat--backend auto|llama-cpp|transformers|vllm, --ctx, --system, --verbose
hfl serveREST API server--host, --port, --model (pre-load), --api-key (authentication)
hfl listList local models with Rich tableShows name, alias, format, quantization, license (risk-colored), size
hfl search <query>Interactive paginated search on HF Hub--gguf, --max-params, --min-params, --sort, --page-size
hfl rm <model>Remove model with confirmationDeletes files + registry entry
hfl inspect <model>Full detail (Rich panel)Shows metadata, license, restrictions, timestamps
hfl alias <model> <alias>Assign short aliasAllows referring to models by simple names
hfl loginConfigure HF token--token or interactive. Verifies with whoami()
hfl logoutRemove saved tokenUses huggingface_hub.logout()
hfl versionShow version + license
hfl compliance-reportLegal compliance report (JSON/Markdown)Output format selection
Signal Handling: The run command handles Ctrl+C during token streaming gracefully, preserving the partial response.

CLI Helper Functions

_format_size() converts bytes to readable format. _get_key() reads a key without Enter (raw terminal). _extract_params_from_name() extracts parameters from name (regex: "70b", "7b", "1.5b"). _estimate_model_size() estimates disk size based on parameters and quantization. _display_model_row() renders a search result row. _get_params_value() extracts numeric value for filtering.

New Commands (since v0.1.0)

File grew to ~2,350 lines in src/hfl/cli/main.py. Two families were added: drop-in Ollama-parity commands and the Hub-native discovery/ops commands. Lazy imports keep startup cheap; most of these commands run an asyncio.run() coroutine internally.

CommandDescriptionKey Options
hfl cp <src> <dst>Zero-copy registry clone pointing at the same blob (Ollama-compatible)
hfl stop [model]Unload a model (or all) on a running server via POST /api/stop--host, --port
hfl show <model>Ollama-style summary: arch, params, capabilities, license--modelfile, --parameters, --template, --license
hfl psList models resident in memory via /api/ps (NAME/ID/SIZE/PROCESSOR/UNTIL)--host, --port
hfl create <model>Build a model from a Modelfile; streams NDJSON from POST /api/create--file, --host, --port
hfl mcp <action>Model Context Protocol: connect / disconnect / list / serve (stdio or sse)--transport, --host, --port, --capabilities
hfl doctorDiagnose accelerators (NVIDIA/Metal/ROCm), installed extras, VRAM + recommended num_ctx
hfl checkBackend availability (llama-cpp / transformers / vllm / mlx), GPU, TTS deps
hfl debugSystem / dependency versions / GPU / memory dump for bug reports
hfl discover [query]Filter the live HF Hub catalogue by capability + popularity (5-min disk cache)--family, --task, --quant, --multimodal, --min-likes, --license, --gated/--open, --refresh
hfl recommendHW-aware top-N picks (RAM/VRAM/MLX profile + capability score)--task, --family, --quant, --top
hfl pull-smart <model>Pull the optimal Hub variant for this hardware (MLX 4-bit / GGUF quant / CPU fallback)--max-vram-gb
hfl verify <model>5-probe sanity check: tokenizer round-trip, chat-template, smoke gen, tool-parser, embed dim
hfl bench <model>Benchmark TTFT + tok/s with p50/p95 summary--runs, --max-tokens, --lengths
hfl snapshot <action>KV-cache snapshot save / load / list / delete (skip prefill on restart)--name
hfl lora <action>Hot-swap LoRA adapters on a loaded model (apply / remove / list)--path, --id, --scale, --name
hfl draft-recommend <model>Pick a draft model for speculative decoding--max-ratio
hfl compliance-dashboardAt-a-glance license / EU AI Act risk overview of the local registry

The serve command also gained --tray/--gui (system tray, see the tray section), --log-level, --json-logs and --ctx.

Known defect (v0.15.0): compliance-report still hard-codes "hfl_version": "0.1.0" in its JSON/Markdown output instead of reading hfl.__version__ (now 0.15.0).

7. Module hub — HuggingFace Integration

resolver.py — Intelligent Resolution

Class ResolvedModel (dataclass) with: repo_id, revision, filename, format, quantization.

The resolve() function supports three input formats:

1. org/model → direct HF repo

2. org/model:Q4_K_M → repo with Ollama-style quantization

3. model-name → name search (top 5 by downloads)

After resolution, detects if repo has GGUF files (prefers _select_gguf() with priority Q4_K_M > Q5_K_M > Q4_K_S), safetensors, or pytorch.

downloader.py — Download with Progress

Main function pull_model(resolved). For GGUF downloads individual file with hf_hub_download(). For safetensors downloads complete snapshot with snapshot_download() filtering: *.safetensors, config.json, tokenizer*.json, tokenizer.model.

Implements rate limiting (0.5s between API calls) and identifying User-Agent (hfl/0.15.0) to comply with HuggingFace ToS.

auth.py — Authentication

get_hf_token() obtains token with priority: 1) HF_TOKEN env var, 2) token saved by huggingface_hub.

ensure_auth(repo_id) verifies repo access. If it fails and there's no token, requests interactively. Respects HF's gating system: does NOT bypass license acceptance for gated models.

license_checker.py — License Classification

Enum LicenseRisk: PERMISSIVE, CONDITIONAL, NON_COMMERCIAL, RESTRICTED, UNKNOWN.

Dictionary LICENSE_CLASSIFICATION with ~20 known licenses. Dictionary LICENSE_RESTRICTIONS with specific restrictions per family (Llama: 700M MAU, attribution, etc.).

check_model_license() queries HF API, classifies risk, and returns LicenseInfo. require_user_acceptance() presents a Rich panel with the license and requires explicit confirmation for non-permissive licenses.

7. Hub Discovery, Recommendation, Smart Pull & Push

Beyond plain pull, the Hub layer now exposes the full HuggingFace catalogue (1.5M+ models with structured metadata) as a queryable, hardware-aware surface. Four read/write endpoints sit on top of pure, network-free planning modules so the heuristics are unit-testable without the network.

🔎

GET /api/discover

Backed by hfl/hub/discovery.py. search_hub() queries HfApi.list_models (over-fetching page_size × 3, sorted by downloads) and reshapes each ModelInfo into a typed DiscoveryEntry: family (llama/qwen/gemma/mistral/mixtral/phi/yi/falcon/deepseek/command-r), quantization (gguf/awq/gptq/mlx/exl2/fp8/int4/int8), multimodal flag, and a best-effort parameter_estimate_b parsed from the repo id. HFL-specific post-filters (likes, downloads, family, quant, gated, license) run in Python because the Hub filter grammar can't express them.

🧠

GET /api/recommend

Backed by hfl/hub/recommend.py. recommend_models() blends four deterministic scores — hardware_fit 0.45, capability_fit 0.30, popularity 0.20, recency 0.05 — over a discovery query (page 60, min_likes 10). Candidates that overflow the memory budget score 0 and are dropped before sorting. The HTTP response always carries the hardware_profile so the client can explain the picks.

🎯

POST /api/pull/smart

Backed by hfl/hub/smart_pull.py. build_smart_plan() resolves a base repo to the best available variant for the host, probing community forks in order, intersecting the GGUF quant ladder with the files actually published, and returning the first (repo, quant) that fits the budget as a SmartPullPlan. The route streams a planningplanned NDJSON preface, then delegates byte transfer to the existing pull machinery via iter_pull_events().

⬆️

POST /api/push

Backed by hfl/hub/uploader.py + routes_push.py. build_upload_plan() (pure) resolves a local manifest to an UploadPlan; stream_push() drives HfApi.create_repo(exist_ok=True) + upload_folder and yields NDJSON mirroring /api/pull. Ollama clients may pass name as an alias for destination.

Hardware probing & VRAM budgets

hfl/hub/hw_profile.py builds a cheap, metadata-only HardwareProfile (no GPU work). Memory budgets used by both recommend and smart_pull:

HostDetectionBudget
CUDAtorch.cuda.is_available(); largest visible device via get_device_propertiesfull GPU VRAM
Apple Silicon (Metal)Darwin + arm64 + mlx_lm importable (has_mlx)70% of unified RAM
ROCmHIP_VISIBLE_DEVICES / ROCM_PATH envnot probed (None)
CPU-only / unknownfallthrough70% of system RAM (psutil)

hfl/hub/quant_table.py turns (params_b, quantization) into a conservative FitEstimate = (weights + 4k-context KV cache + 1.0 GB overhead) × 1.2 safety. Bits-per-weight are tabulated per quant (q4_k_m→4.85, q8_0→8.5, mlx-4bit→4.5, …); layer counts and KV hidden dims are bucketed by size, with Qwen 3, Gemma 2 and Mixtral-8x7B (effective) added to close the original Llama-only gap.

Smart-pull candidate order

_candidate_repos() generates, most-specific-first:

mlx-community/<name>-4bit-8bitbartowski/<name>-GGUFTheBloke/<name>-GGUFbase repo

The MLX legs only appear when has_mlx is true. The quant ladder is ["mlx-8bit","mlx-4bit","q5_k_m","q4_k_m"] on Apple Silicon, else ["q5_k_m","q4_k_m","q4_0","q3_k_m"]; rejected probes are recorded in fallback_chain and surfaced verbatim. When nothing fits, build_smart_plan() raises ValueError (mapped to a 400 with a "try --max-vram-gb or a smaller repo" hint).

# POST /api/pull/smart — Apple Silicon, 16 GB unified
{"status": "planned", "target_repo_id": "mlx-community/Llama-3.1-8B-Instruct-4bit",
 "quantization": "mlx-4bit", "estimated_vram_gb": 6.3,
 "reason": "picked mlx-community/... @ mlx-4bit (6.3 GB / 11.2 GB budget)"}

Discovery cache & security

DiscoveryCache (HFL_HOME/cache/discovery.json) keys on the JSON-serialised query, TTL 300s, bounded to the 32 most-recent keys, with atomic temp-file writes and corrupt-file reset — it exists to dodge the Hub's unauthenticated 429s on chained CLI calls. On the push side, redact_secrets() strips anything matching hf_[A-Za-z0-9]{20,} from both logs and client-facing error events, and the upload is scoped to allow_patterns = exactly the planned files — critical because a blob-backed model (FROM sha256:<digest>) has its local_dir pointing at the shared blobs/ store, so an unscoped upload_folder would exfiltrate every other model's bytes.

8. Module models — Data and Registry

ModelManifest (manifest.py)

Dataclass that stores complete metadata for each downloaded model. It is the fundamental unit of information in the system.

FieldTypePurpose
name, repo_idstrIdentification (short name + HF repo)
aliasstr|NoneUser-defined custom name
local_path, formatstrLocation and type (gguf/safetensors/pytorch)
size_bytes, quantizationint, strSize on disk + Q level
architecture, parameters, context_lengthstr, str, intModel characteristics
license, license_name, license_urlstrLegal information (R1)
license_restrictions, gated, license_accepted_atlist, bool, strRestrictions and acceptance
gpai_classification, training_flopsstrEU AI Act (R4)
created_at, last_usedstrTimestamps

ModelRegistry (registry.py)

Manages local inventory. Persists to ~/.hfl/models.json as JSON array. Operations: add() (avoids duplicates), get() (searches by name, alias, or repo_id), set_alias(), list_all() (sorted by date), remove().

ProvenanceLog (provenance.py)

Immutable conversion log in ~/.hfl/provenance.json. Each ConversionRecord documents: source (repo, format, revision), destination (format, path, quantization), tool used (llama.cpp + version), original license, and timestamps. Serves for legal traceability and compliance audit (R3).

8. Registry, Manifest, Provenance & Capabilities (current state)

The model layer hardened and widened considerably. The on-disk registry gained concurrency safety; the manifest absorbed Modelfile-derived and legal fields; and a dedicated module now computes Ollama-style capabilities.

🗄️

ModelRegistry

hfl/models/registry.py is now thread-safe (RLock) and multi-process-safe via cross-platform file locking (fcntl / msvcrt). It keeps three O(1) dict indexes (by_name, by_alias, by_repo_id), does atomic temp-file saves with a .json.bak backup, recovers from a corrupt file by restoring the backup (emitting an ERROR event), and adds copy() (Ollama /api/copy parity — duplicates the manifest, shares the on-disk blob), repair(), validate_integrity() and set_alias(). The singleton is obtained through hfl.core.container, not a module global.

📋

ModelManifest

hfl/models/manifest.py carries far more than name/path/format: license block (SPDX id, name, url, restrictions, gated, license_accepted_at), EU-AI-Act fields (gpai_classification, training_flops), integrity (file_hash + verify_integrity() / update_hash()), and Modelfile-derived fields: system, default_parameters, adapter_paths, messages, parent_name/parent_digest (set by POST /api/create), plus env_vars and declared_capabilities.

Capability detection (Ollama-compatible)

New module hfl/models/capabilities.py. detect_capabilities() returns the list POST /api/show emits, which Ollama SDK clients (ollama-python, Open WebUI, LangChain) use to gate tool-calling, vision, embeddings and reasoning. Detection is permissive substring-matching over name + repo_id + architecture:

CapabilityTrigger
completionany model whose model_type isn't tts/stt/audio
toolsqwen, llama/llama3, mistral, mixtral, gemma4 (kept in sync with tool_parsers.dispatch)
insert (FIM)codellama, codegemma, starcoder/2, qwen-coder, deepseek-coder
visionllava, llama-3.2-vision, llama4, gemma-3, qwen2-vl/2.5-vl, internvl, pixtral, molmo, idefics, minicpm-v
embeddingbert/nomic/jina/bge/gte/e5/mxbai/stella/arctic-embed — drops completion
thinkinggemma-4, deepseek-r1, qwen3-thinking, gpt-oss, o1/o3

Order is deterministic: completion/embedding first, the rest alphabetical.

Provenance log

hfl/models/provenance.py records the source→conversion→result chain (~/.hfl/provenance.json) for the legal-audit safeguard: source repo/format/revision, target path, quantization, tool + version, original license and acceptance timestamp. log_conversion() is the convenience entrypoint, called from the GGUF converter after a successful quantize.

9. Module engine — Inference Engines

Class Hierarchy — Engine
«ABC» InferenceEngine + load(path, **kwargs) + unload() / generate() / generate_stream() + chat() / chat_stream() / model_name / is_loaded LlamaCppEngine GGUF | CPU+Metal+CUDA+Vulkan Flash Attention, auto chat format TransformersEngine safetensors | GPU CUDA BitsAndBytes 4bit/8bit, TextIteratorStreamer VLLMEngine MLXEngine safetensors | Apple Silicon Metal (mlx-lm) Production GPU | NVIDIA CUDA PagedAttention, continuous batching selector.py → GGUF→LlamaCpp | safetensors on Darwin-arm64→MLX | CUDA+safetensors→Transformers | fallback→LlamaCpp Dataclasses ChatMessage(role, content) GenerationConfig(temp, top_p...) GenerationResult(text, tokens...)

LlamaCppEngine

Main backend. Uses llama-cpp-python. Parameters: n_ctx (with a per-architecture cap, e.g. Gemma 3/4 → 8192), n_gpu_layers (-1=all), n_threads (0=auto), flash_attn (architecture-aware, off for known-unsafe arches like gemma4 unless forced), chat_format (auto-detect), kv_cache_type (q4_0/q8_0/f16), lora_paths and draft_model_path (speculative decoding). Includes stderr suppression to silence Metal/CUDA logs when verbose=False. Generates results with metrics: tokens/s, prompt/eval token counts and nanosecond durations, stop reason.

TransformersEngine

Uses models in native format with GPU. Dynamic quantization support: 4bit (NF4 double quant via BitsAndBytes) and 8bit. Streaming via TextIteratorStreamer in separate thread. Uses tokenizer's apply_chat_template() or Llama-style fallback.

EXPERIMENTAL VLLMEngine

EXPERIMENTAL Production GPU backend. Wraps vllm.LLM with SamplingParams. Real streaming with AsyncLLMEngine, with synchronous fallback for compatibility. Requires NVIDIA GPU with CUDA.

FailoverEngine

Multi-backend engine with sticky routing — automatically retries with the next available engine if one fails. Provides high availability across multiple inference backends.

Model Pool & Memory

Model pool with non-recursive waiting (bounded polling), preventing stack overflow with concurrent loads. Real-time RAM and GPU memory tracking via psutil and GPUtil.

Automatic Selector (selector.py)

Decision logic in select_engine(model_path, backend):

GGUF model? → Yes → LlamaCppEngine
CUDA GPU available? → Yes → TransformersEngine → ImportError → Fallback: LlamaCppEngine
No GPU? LlamaCppEngine (will need prior conversion)

All imports are lazy (_get_llama_cpp_engine(), etc.) to avoid requiring all dependencies to be installed.

MLX Backend — Apple Silicon (Phase 13)

mlx_engine.MLXEngine is a fourth InferenceEngine implementation that wraps Apple's mlx-lm and hits Metal directly. The module docstring claims a 3-10% edge on prompt processing and 15-25% on decode at fp16 versus llama-cpp's Metal path for Llama-family architectures, plus native q4/q5/q8 mixed precision with no GGUF conversion. The dependency lives behind the [mlx] extra.

🍏

Availability gate

is_available() returns True only when platform.system() == "Darwin", platform.machine() is arm64/aarch64, and mlx_lm imports. On every other platform the import is a no-op, so a stray hfl[mlx] in a Linux container fails fast rather than crashing.

🎛️

mlx-lm 0.30+ sampler shim

_build_sampling() translates GenerationConfig into the post-0.30 API: a make_sampler() callable (temp / top_p / top_k) plus make_logits_processors() for the repetition penalty, splatted into generate() / stream_generate().

mlx-lm exposes no per-call seed or stop-string support, so the engine fills both gaps itself: _maybe_seed() seeds mlx's global RNG (mx.random.seed) when cfg.seed >= 0 (ENG-10/11 parity with vLLM/diffusers), and _stop_strings() + _earliest_stop() truncate the completion at the first caller stop string. In generate_stream() the stop enforcement is a buffered char-level filter that holds back a tail (max_stop) that could begin a stop marker so output never runs past it. Token counts for the response envelope are recomputed via the tokenizer because mlx-lm returns only the completion text.

Backend Selection — MLX path & force-override

select_engine() in selector.py gained an Apple-Silicon branch and an operator-level override that the old flow doesn't show.

GGUF?→ Yes →LlamaCppEngine
safetensors/pytorch + _mlx_preferred()?→ Yes →MLXEngine→ MissingDependencyError →fall through
_has_cuda()?→ Yes →TransformersEngine→ else →LlamaCppEngine
ControlEffect
HFL_DISABLE_MLX=1_mlx_preferred() returns False — forces the legacy llama-cpp path on Apple Silicon (benchmarking parity).
HFL_LLM_LIBRARY / OLLAMA_LLM_LIBRARYRead by _resolve_forced_backend(). Accepts llama-cpp, transformers, vllm, mlx; only honoured when the caller passed backend="auto" (an explicit per-model request always wins). Unrecognised values log a warning and fall back to auto.

GGUF stays on llama-cpp regardless — MLX cannot ingest GGUF. _create_engine("mlx") wires the explicit --backend mlx request.

KV-Cache Snapshots — warm-start (snapshot.py)

Most servers re-process the system prompt / few-shot prefix on every new conversation. snapshot.py exposes llama-cpp-python's Llama.save_state() / load_state() as a server feature so an operator can capture the entire KV cache (processed tokens + cache tensors) and restore it for zero TTFT on the cached prefix, even across server restarts. It backs POST /api/snapshot/save, /load, GET /api/snapshot and DELETE /api/snapshot/{name}.

def save_snapshot(engine, *, name, model_name) -> SnapshotMeta: ...
def load_snapshot(engine, *, name, model_name) -> SnapshotMeta: ...

State is a pickle of save_state() plus a <name>.meta.json sidecar (model, tokens, created_at, bytes, version, mac) under HFL_HOME/snapshots/. Safety properties built into the module:

🔐

HMAC integrity

pickle.loads is RCE (CWE-502), so every blob is authenticated with HMAC-SHA256 keyed by a per-install secret (HFL_HOME/snapshot.key, 32 random bytes, 0600) and verified with hmac.compare_digest before unpickling. A tampered/foreign blob raises SnapshotIntegrityError. Snapshots are a same-machine cache, not portable.

🏷️

Model + version guards

Load checks the sidecar's model matches model_name (wrong-model tensors would corrupt memory) and rejects a foreign SNAPSHOT_FORMAT_VERSION with SnapshotVersionMismatch.

💾

Atomic write + name guard

The .state is written to a .tmp sibling and os.replaced so a crash never leaves a truncated blob. _validate_name() rejects path separators / .. / odd unicode.

save_snapshot/load_snapshot resolve save_state/load_state either on the engine directly or on its inner _model; engines without that API raise (KV snapshots are llama-cpp-python-only).

Speculative Decoding (llama_cpp.py)

The llama-cpp backend can run a draft model to propose tokens the target verifies in a single pass. Selected at load time through the draft_model_path kwarg (surfaced from a Modelfile); GenerationConfig also carries a draft_model field. Two modes share the one kwarg:

🔁

prompt-lookup

draft_model_path="prompt-lookup" uses llama-cpp-python's LlamaPromptLookupDecoding (num_pred_tokens=10, max_ngram_size=2). Zero VRAM, a documented 1.3-2× speedup on repetitive prompts (RAG, code, structured output). If unavailable it logs and continues without speculation.

📐

draft model

draft_model_path="<draft.gguf>" loads a second small Llama and routes it through _LlamaModelDraftAdapter. Only safe with a same-tokenizer-family draft (e.g. Qwen3-14B ↔ Qwen3-0.6B). A failed draft load logs and falls back to no speculation.

_LlamaModelDraftAdapter bridges a plain Llama into the LlamaDraftModel protocol (a bare Llama returns a completion dict, not a token-ids array). It is deliberately incremental: _align_and_eval_suffix() finds the longest common prefix with the previously-processed ids and evaluates only the suffix, preserving the draft's KV cache across steps. A naive reset-per-call would pay the full prefill N times and make speculation 2-3× slower; on divergence (fresh request / rejected predictions) it resets and replays. The draft Llama is tracked as self._draft_model and freed in unload() so a subsequent load doesn't double up VRAM.

LoRA Hot-Swap & Multi-LoRA (lora.py)

lora.py applies / removes LoRA adapters on a running model without reloading base weights, backing POST /api/lora/apply, /remove, GET /api/lora and /api/lora/{model}. The public surface:

def apply_lora(engine, *, lora_path, scale=1.0, name=None) -> AdapterInfo
def remove_lora(engine, adapter_id) -> bool
def list_loras(engine=None) -> list[AdapterInfo]
🧩

LoraRegistry (multi-LoRA)

A process-wide, lock-guarded singleton (get_registry()) maps adapter_idAdapterInfo (path, name, scale, engine_id). Keyed by _engine_id() = engine-{id(engine)} so several loaded models each carry their own adapter set. Multiple adapters stack at fractional scale (e.g. 0.7 + 0.3); scale is validated to [0.0, 5.0].

🔌

Engine bridge

_set_lora() tries engine.apply_lora first, then the inner _model.set_lora_adapter (llama-cpp-python ≥ 0.3) and the legacy apply_lora_from_file. _unset_lora() mirrors that with remove_lora_adapter / unload_lora; no API → RuntimeError (surfaced as 503).

remove_lora detaches from the live engine first and only drops the registry entry on success — if the engine refuses the unset, the entry stays tracked so the client's retry can succeed instead of leaving a ghost adapter mixed into every generation but unremovable. At load time, LlamaCppEngine.load() also accepts a lora_paths list but wires only the first into llama-cpp's single lora_path kwarg, logging that any extras are ignored (post-load stacking is the multi-LoRA route).

Flash Attention — architecture-aware gating

The doc lists flash_attn as a load parameter; the backend now resolves it through a three-level precedence in LlamaCppEngine.load(), because llama-cpp-python's flash-attn path is historically crash-prone on new architectures:

#SourceBehaviour
1per-load flash_attn= kwargExplicit value (e.g. from a Modelfile) wins outright.
2HFL_FLASH_ATTENTION / OLLAMA_FLASH_ATTENTIONFalsy (0/false/...) forces off globally. Truthy still defers to the per-arch safety list — operators don't get crashes on known-bad arches "for free".
3architecture defaultFalse for arches in _ARCHITECTURE_NO_FLASH_ATTN (currently {"gemma4"}), True otherwise.

The resolved value is passed into the Llama constructor alongside the architecture-aware context cap (_ARCHITECTURE_CTX_CAP, e.g. Gemma 3/4 capped at 8192 to avoid pinning unified memory on Apple Silicon) and optional KV-cache quantisation (kv_cache_typetype_k/type_v q4_0/q8_0/f16).

Engine-Side Model Lifetime — keep-alive & eviction

HFL has no literal pin()/unpin() on the engine; a model's residency is governed by the ModelPool (model_pool.py) and keep-alive deadlines, so "pinning" is an emergent property rather than an API call. The pool keeps multiple loaded engines and evicts on three signals: LRU (capacity), idle timeout (default idle_timeout_seconds=3600), and memory pressure (estimates). A background loop (background_eviction_interval=60s) sweeps idle models via start_background_eviction().

📌

Pin = keep-alive deadline

The route layer's apply_keep_alive() translates Ollama's keep_alive into a deadline on ServerState. keep_alive=-1 ("never expire") clears the deadline so the model is effectively pinned in residence; a positive duration sets an expiry; 0 unloads after the response (unload_after_response()). keep_alive_default in config seeds the baseline.

🧹

Race-safe eviction

_evict_over_capacity_locked(keep=...) runs after a load and never evicts the model just inserted (pinned to the LRU back via move_to_end), closing a window where a concurrent load of a different model could evict the fresh one against an inconsistent _models/_loading view (part of the Phase 0 model-lifecycle UAF fixes).

Other Engines in the Package

Beyond the three text backends, engine/ ships audio, diffusion and embedding engines the original section never mentioned. They are loaded lazily behind their own extras.

EngineFile / classRole
Bark TTSbark_engine.py · BarkEngine(AudioEngine)Text-to-speech via transformers; synthesize() / synthesize_stream().
Coqui TTScoqui_engine.py · CoquiEngine(AudioEngine)XTTS/VITS/Tacotron-family TTS.
Whisper STTwhisper_engine.py · WhisperEngineSpeech-to-text; transcribe() returning WhisperResult/WhisperSegment.
Diffusiondiffusers_engine.py · DiffusersEngineText-to-image (generate()ImageResult). Deliberately does not implement InferenceEngine.
Embeddingsembedding_engine.py · LlamaCppEmbeddingEngine, TransformersEmbeddingEngineText/image embeddings (EmbeddingEngine ABC + pooling helpers).

The base module defines the parallel AudioEngine ABC (TTSConfig / AudioResult), and select_tts_engine() in selector.py auto-routes a TTS model to Bark or Coqui (name/config.json architecture sniffing, Bark as the transformers-pipeline default).

10. Module core — Concurrency, Dispatcher & Sessions

The hfl.core package is the cross-cutting infrastructure layer added after the v0.1.0 freeze. It holds the dependency-injection Container, request tracing, optional process sandboxing, the events→metrics bridge, and chat-session persistence. Its single most important companion is the InferenceDispatcher — the bounded-concurrency primitive that serialises every inference call against the shared, non-reentrant model.

Where it lives. The dispatcher class itself is defined in hfl/engine/dispatcher.py, but it is wired as a singleton inside the core container and always reached through hfl.core.get_dispatcher(). Treat it as part of the core concurrency story.

10.1 InferenceDispatcher — a bounded Bulkhead

🚦

Why it exists

The llama.cpp and transformers-GPU backends share one non-reentrant model instance. Two overlapping requests corrupt each other's KV cache and produce empty or truncated replies. The dispatcher is therefore a Bulkhead: it caps how many inferences run at once and how many may wait, shedding the rest with backpressure rather than letting load melt the model.

InferenceDispatcher (hfl/engine/dispatcher.py) is async-aware and backed by an asyncio.Semaphore whose capacity tracks max_inflight exactly. All counters mutate under a single _counter_lock so fast-path and slow-path decisions observe one consistent view.

ParameterDefaultConfig / envMeaning
max_inflight1queue_max_inflightHFL_QUEUE_MAX_INFLIGHT / HFL_NUM_PARALLEL / OLLAMA_NUM_PARALLELHow many requests may execute simultaneously. Default 1 preserves single-flight behaviour.
max_queued16queue_max_sizeHFL_QUEUE_MAX_SIZE / HFL_MAX_QUEUE / OLLAMA_MAX_QUEUEHow many more may wait in line. When full, new callers are rejected immediately.
acquire_timeout60 squeue_acquire_timeout_secondsHFL_QUEUE_ACQUIRE_TIMEOUTCap on how long a queued caller waits for a slot before giving up.

The factory build_default_dispatcher() reads these from hfl.config; the container's _create_dispatcher() wraps it in the lazy singleton.

The acquire lifecycle — fast path, slow path, rejection

The async context manager slot() drives a four-phase protocol. Under the counter lock it decides between the fast path (capacity now) and the slow path (must wait); a full wait queue is rejected before queueing, so the client gets an immediate 429 rather than blocking.

async with self._counter_lock:
    if self._in_flight < self._max_inflight:
        self._in_flight += 1          # fast path: reserve a slot
        fast_path = True
    else:
        if self._depth >= self._max_queued:
            self._rejected_full_total += 1
            raise QueueFullError(...)  # → HTTP 429 + Retry-After
        self._depth += 1            # slow path: enqueue and wait
        fast_path = False

Phase 2 acquires the semaphore outside the counter lock (so releasing callers never deadlock). Slow-path callers wrap the acquire in asyncio.wait_for(..., timeout=acquire_timeout); a timeout decrements depth, bumps rejected_timeout_total, and raises QueueTimeoutError. Cancellation safety is explicit: a cancel while waiting unwinds depth; a cancel after reserving the fast-path slot rolls back in_flight; a slot held during execution is always released in the finally of phase 4 (decrement in_flight, then sem.release() — chosen so a snapshot taken mid-release over-counts load rather than under-counting it).

requestslot() acquirein_flight < max?run func() on threadrelease

accepted_total

Counter incremented once a caller actually holds a slot (fast or slow path).

🛑

rejected_full_total

QueueFullError429 with Retry-After. The wait queue was already at max_queued.

⏱️

rejected_timeout_total

QueueTimeoutError503. The caller waited past acquire_timeout — the server is simply saturated.

snapshot() — the metrics surface

snapshot() returns an immutable DispatcherSnapshot (six ints: max_inflight, max_queued, in_flight, depth, accepted_total, rejected_full_total, rejected_timeout_total). It is read-only and cheap, so it is exported on every surface:

SurfaceSourceExposes
/healthz / /healthz/readyroutes_health.pyqueue_depth, queue_in_flight
/metrics (Prometheus)metrics.pyhfl_inference_concurrency_max, …_inflight, hfl_inference_queue_depth + saturation counters
Every HTTP responsemiddleware.pyheaders X-Queue-Depth, X-Queue-In-Flight, X-Queue-Max-Inflight, X-Queue-Max-Size

How routes consume it: run_dispatched() in api/helpers.py acquires a slot, runs the sync engine call on a thread (asyncio.to_thread), and applies the generation timeout. Critically it holds the slot until the worker thread actually exits — a sync engine call cannot be cancelled, so on a 504/client-cancel the dispatcher slot is kept (_release_when_worker_exits) to stop a queued request starting a second inference that would corrupt the model mid-generation. Streaming routes pre-acquire via acquire_stream_slot(), which returns either an entered context manager or a ready 429/503 JSONResponse.

10.2 exclusive() — the drain barrier for admin work

🧯

Zero inference in flight

exclusive() acquires all max_inflight semaphore permits, so the guarded block runs with no inference executing. Unlike slot() it never raises QueueFull/QueueTimeout (admin work must not be dropped — it simply waits) and it does not touch the queue counters (an unload is not an inference request). New inference callers block on the semaphore until the block exits — exactly the serialisation a model swap needs.

It guards three operations against the shared model:

🔄

Model hot-swap

ServerState.set_llm_engine() drains via exclusive() before retiring the displaced engine, so no slot-holding HTTP request is mid-read of the old model when it is freed.

💾

Snapshot save

routes_snapshot.py runs the multi-GB save_state() + pickle + write under exclusive() + to_thread — off-loop and with inference/swaps drained.

📥

Snapshot load

load_state() writes KV tensors into the live model; same drain barrier or it corrupts the model mid-generation.

10.3 Engine refcount pin/unpin — the hot-swap use-after-free guard

exclusive() only covers paths that hold a dispatcher slot. The WebSocket chat turn (routes_ws.py) reads the engine directly and does not hold a slot, so a concurrent swap could unload() the model mid-stream — a use-after-free of the non-reentrant model. ServerState closes that gap with a reference count.

MemberRole
_engine_inuse: dict[int,int]Per-engine (keyed by id()) refcount of non-slot readers currently using it.
_engine_retired: dict[int,InferenceEngine]Engines a swap displaced but could not unload because they were still pinned.
_engine_ref_lock: asyncio.LockGuards both maps.
pin_engine(engine)Increment the refcount; while pinned a hot-swap defers the unload.
unpin_engine(engine)Decrement; if this was the last reader of a retired engine, unload it now (off-loop via to_thread).

The swap logic in set_llm_engine() performs an atomic retire-then-assign: it retires the old engine before assigning the new one, so an unload failure leaves the old engine in place (callers observe "replacement failed", not a half-updated state). Inside the exclusive() drain it checks _engine_inuse: if the displaced engine is still pinned it goes into _engine_retired and the unload is deferred to the last unpin_engine; otherwise it unloads immediately off-loop. The WS producer pins for the lifetime of the producer thread and unpins in its finally (which can outlive a client cancel, since the engine call cannot be preempted).

WS turn pins engineswap drains dispatcherold engine pinned? defer unloadproducer ends, unpinslast reader unloads it

10.4 Container — dependency injection & singletons

core/container.py centralises global state. Singleton[T] is a thread-safe lazy holder (double-checked locking around a factory, with reset() for tests). The Container dataclass holds one Singleton per shared service and exposes convenience accessors:

from hfl.core import get_config, get_registry, get_state, get_dispatcher
AccessorSingleton
get_config()HFLConfig (the global config instance)
get_registry()ModelRegistry
get_event_bus()EventBus
get_state()ServerState
get_metrics()Metrics
get_rate_limiter()RateLimiter (in-memory by default)
get_dispatcher()InferenceDispatcher

reset_container() rebuilds everything from scratch — the standard test fixture for clean state.

10.5 Tracing, Sessions, Sandbox, Observability

🧵

tracing.py

Request-ID propagation via contextvars (survives asyncio tasks and thread-pool hops). set_request_id() / get_request_id() generate short 8-char hex IDs; RequestContext and with_request_id scope them; format_log_prefix() renders the [abc123de] log prefix.

💬

sessions.py

Human-readable chat persistence under ~/.hfl/sessions/<name>.json (no pickle). ChatSession holds model, options, messages, system. save_session writes atomically (tmp + replace) and touches updated_at; names are validated against a strict regex (InvalidSessionNameError) to prevent directory escape; list_sessions skips malformed files, newest first.

🛡️

sandbox.py

Opt-in process hardening for hfl serve via --sandbox / HFL_SANDBOX. seccomp (Linux): PR_SET_NO_NEW_PRIVS + a minimal seccomp-bpf filter blocking ptrace/kexec_*/reboot/unshare/setuid; macos: advisory App-Sandbox hint (real enforcement from the signed DMG entitlement). Fully defensive — any failure logs a WARNING and continues; apply_sandbox returns an inspectable SandboxResult.

📈

observability_setup.py

setup_event_listeners() bridges the event bus to metrics: on MODEL_LOADED/MODEL_UNLOADED/GENERATION_COMPLETED/GENERATION_FAILED it records load duration, generation tokens/latency, and error type. Idempotent (registers once).

11. Module converter — Format Conversion

formats.py — Format Detection

Enum ModelFormat: GGUF, SAFETENSORS, PYTORCH, UNKNOWN. The detect_format(path) function inspects extensions (.gguf, .safetensors, .pt/.pth/.bin) both in individual files and directories (rglob). find_model_file() locates the main model file.

gguf_converter.py — GGUF Conversion

Class GGUFConverter with two-step pipeline:

safetensors/pytorch → Step 1 → convert_hf_to_gguf.py (FP16) → Step 2 → llama-quantize (Q4_K_M) Final GGUF

ensure_tools() auto-installs llama.cpp if not present: git clone → cmake build → pip install requirements. check_model_convertibility() validates that the model is convertible (rejects LoRA adapters, image models, models without config.json).

QuantizationBits/weightQualityUse Case
Q2_K~2.5~80%Extreme compression
Q3_K_M~3.5~87%Low RAM
Q4_K_M~4.5~92%DEFAULT — best balance
Q5_K_M~5.0~96%High quality
Q6_K~6.5~97%Premium
Q8_0~8.0~98%+Maximum quantized quality
F1616.0100%No quantization

11. Architecture coverage, Modelfile parser & Go-template renderer (current state)

The converter cluster spans format/type detection (formats.py), the GGUF pipeline (gguf_converter.py), a full bidirectional Modelfile parser/renderer (modelfile_parser.py) and a minimal Go-template engine (go_template.py).

30+ LLM architectures + MLX detection

hfl/converter/formats.py recognises 33 causal-LM architectures in LLM_ARCHITECTURES (Llama, Mistral, Mixtral, Qwen2/Qwen2Moe, Gemma/Gemma2, Phi/Phi3, Falcon, Cohere/CommandR, DeepseekV2, InternLM/InternLM2, Yi, StarCoder2, CodeLlama, Olmo/Olmo2, Mamba, Jamba, Arctic, StableLm, OPT, Bloom, GPT2/NeoX/J, …), plus 16 other architecture families (TTS, STT, embeddings, vision-QA, seq2seq, depth, …) used by detect_model_type() to classify any HF config.json. is_mlx_quantized_repo() flags MLX-packed repos (mlx in the repo id, or a quantization object with group_size+bits in config.json) so they're routed to the MLX backend instead of llama.cpp's converter, which rejects MLX-packed graphs.

GGUF conversion pipeline

hfl/converter/gguf_converter.py runs safetensors → convert_hf_to_gguf.py (F16) → llama-quantize → final GGUF. check_model_convertibility() rejects LoRA adapters, diffusion/image, and audio/TTS configs up front. convert_with_cache() normalises the quant token before building the lock key (so q4_k_m and Q4_K_M can't race onto the same output file) and serialises duplicate conversions with a per-key lock; _gguf_variant_path() deliberately avoids with_suffix so version dots in repo names (Qwen2.5-7B) aren't truncated into colliding filenames. The F16 intermediate is resumable, and _check_conversion_environment() probes the host Python for a compatible transformers/huggingface_hub combo before launching the real conversion.

Modelfile parser — escapes, INCLUDE guards, round-trip

hfl/converter/modelfile_parser.py closes the loop for POST /api/create: parse_modelfile() handles FROM, PARAMETER, TEMPLATE, SYSTEM, ADAPTER, LICENSE, MESSAGE, REQUIRES, ENV, CAPABILITIES, DRAFT and INCLUDE; render_modelfile_document() serialises back with a tested round-trip invariant. Hardening:

Go-template renderer with recursion guard

hfl/converter/go_template.py implements the Go text/template subset real Modelfiles use — {{ .Field }}, nested paths, {{ range }}, {{ if }}/{{ else }}/{{ end }}, string literals, and {{- -}} whitespace trimming. render_go_template() is a convenience layer, never a gate: on GoTemplateError it falls back to the literal source with a warning, and it catches RecursionError explicitly so a crafted, deeply-nested {{ if }}/{{ range }} can't exhaust the Python stack and surface as an unhandled 500.

12. Module api — REST Server

Endpoints and API Compatibility
FastAPI Server (server.py) RequestLogger → APIKey → RateLimit → Disclaimer → CORS OpenAI Compatible (routes_openai.py) POST /v1/chat/completions Chat with SSE streaming (text/event-stream) POST /v1/completions Text completion with streaming GET /v1/models — List available models Schemas: ChatCompletionRequest, CompletionRequest Helpers: _ensure_model_loaded() → auto-load Streaming: _stream_chat(), _stream_completion() Format: SSE data: {...}\n\n + data: [DONE] Ollama Compatible (routes_native.py) POST /api/generate Text generation with NDJSON streaming POST /api/chat Multi-turn chat with NDJSON streaming GET /api/tags + /api/version + HEAD / Schemas: GenerateRequest, ChatRequest Streaming: application/x-ndjson Compatible with: Open WebUI, Chatbox, etc. Helper: _options_to_config() for parameters

ServerState

Class with engine (active InferenceEngine), current_model (loaded ModelManifest) and api_key (str|None for authentication). Instantiated as global singleton. Server lifespan handles cleanup on close.

Middleware Stack

Execution order: RequestLogger → APIKey → RateLimit (conditional) → Disclaimer → CORS. APIKey runs BEFORE RateLimit, so unauthenticated requests are rejected without consuming rate limit tokens.

RequestLogger — Privacy-safe logging. NEVER logs: bodies (prompts/outputs), auth headers, User-Agent. Only: method, path, status, duration.

APIKeyMiddleware — Optional authentication via --api-key flag. Supports Authorization: Bearer <key> and X-API-Key: <key> headers. Public endpoints (/health, /) exempt.

RateLimitMiddleware — IP-based rate limiting, configurable via env vars. Supports per-model rate limiting.

DisclaimerMiddleware — Adds X-AI-Disclaimer header to AI generation endpoint responses (R9).

CORSMiddleware — Origin allow-list configurable via cors_origins / cors_allow_all (not unconditionally all origins). WebSocket upgrades bypass this middleware, so /ws/chat re-checks the same API-key and Origin policy inline in _check_ws_auth_and_origin().

Exception Handlers

Centralized exception handling via register_exception_handlers(app). Maps the entire HFLError hierarchy to HTTP responses, and additionally registers a RequestValidationError handler (per-dialect 400 on /v1/*) and a catch-all Exception handler. All three reshape the body per API dialect via render_envelope() (see "Per-Dialect Error Envelopes" below).

OpenAPI Documentation

All endpoints include tags, summary, and responses in their decorators for auto-generated OpenAPI documentation. Tags include: OpenAI, Anthropic, Ollama, TTS, Health, Metrics, and "HFL Beyond" (the WebSocket /ws/chat surface).

Structured Logging

All logging uses %-style format strings (logger.info('Model loaded: %s', name)) instead of f-strings, avoiding unnecessary evaluation when the log level is disabled.

Health & Metrics Endpoints

EndpointDescription
/health/deep?probe=trueRuns a minimal inference test to verify the model works. Reports "degraded" status on failure.
/health/sliService Level Indicators with availability and latency metrics.
/metricsPrometheus-format metrics.
/metrics/jsonJSON-format metrics.

Shared Chat Core (chat_core.py)

Every chat surface — OpenAI /v1/chat/completions, Ollama /api/chat and Anthropic /v1/messages — has to make the same decision once the engine has produced text: did the model emit a tool call, and if so what is it? Historically the OpenAI route drifted behind the Ollama route and silently dropped tool calling. That decision now lives in a single place so routes can only differ in their wire-format translation, never in this logic.

🧩

resolve_chat_output()ChatOutput

The frozen dataclass ChatOutput(content, tool_calls) is the canonical resolved turn, independent of dialect. resolve_chat_output() takes the raw engine text, the model name, the declared tools, and any structured engine_tool_calls, and applies three rules:

ConditionResult
tools_disabled (client sent tool_choice: "none")Text returned verbatim; markers are never scanned (required, since the per-family parser fires on a marker regardless of the tools list)
Engine returned a real non-empty tool_calls listTrusted as-is; content=""
OtherwiseFall back to parsing markers out of the text via tool_parsers.dispatch()

The canonical tool-call shape is {"function": {"name": str, "arguments": dict}}; each route maps it to its own wire format. arguments is always a parsed object, never a string (rule C3). When a turn is a tool call, content is "" (rule C4) and tool_calls is always a list — empty when none (rule C7).

def resolve_chat_output(raw_text, model_name, tools, engine_tool_calls=None, *, tools_disabled=False) -> ChatOutput:
    if tools_disabled:
        return ChatOutput(content=raw_text)
    if isinstance(engine_tool_calls, list) and engine_tool_calls:
        return ChatOutput(content="", tool_calls=engine_tool_calls)
    cleaned, parsed = parse_tool_calls(raw_text, model_name, tools)
    return ChatOutput(content="", tool_calls=parsed) if parsed else ChatOutput(content=cleaned)

Tool Calling (3 surfaces)

Function/tool calling is implemented uniformly across all three chat dialects. Each route maps into the engine's OpenAI-function payload on the way in, and maps the canonical ChatOutput.tool_calls out to its own wire shape on the way back.

🟦

OpenAI /v1/chat/completions

routes_openai.py. _tools_payload() honours tool_choice: "none" drops tools entirely (hard guarantee, no tool_calls), {"type":"function","function":{"name":"X"}} narrows to tool X, and "auto"/"required"/unset forward all ("required" behaves like "auto" — it cannot be hard-enforced without constrained decoding). On output, _to_openai_tool_calls() emits {"id","type":"function","function":{"name","arguments":str}} with arguments re-serialised to a JSON string, content: null, and finish_reason: "tool_calls".

🟩

Ollama /api/chat

routes_native.py. _build_chat_message() wraps resolve_chat_output() and keeps tool_calls in the canonical dict shape Ollama expects. MCP tools from connected servers are folded in via _merge_mcp_tools(). A prior assistant tool_calls turn and the matching role=tool results are forwarded back to the model for multi-turn convergence.

🟪

Anthropic /v1/messages

routes_anthropic.py. _anthropic_tools_to_payload() maps {name, description, input_schema} to the OpenAI-function shape and honours tool_choice: {"type":"none"|"tool"}. On output, _to_anthropic_tool_use() emits tool_use content blocks (argumentsinput dict, toolu_… ids) with stop_reason: "tool_use". Inbound tool_use/tool_result blocks are reconstructed into the engine message list (Claude Code path).

Per-family marker parsers (tool_parsers.py)

When a backend does not surface structured tool_calls itself, HFL parses the model's native tool-call markers out of the generated text. dispatch(text, model_name, tools) selects a parser by model-name substring, runs it first, and only falls through to the generic JSON-envelope fallback when tools are declared (so ordinary chat that happens to contain JSON is never misread as a tool call).

FamilyMarker
Qwen 2.5 / Qwen 3<tool_call>{json}</tool_call>
Llama 3.x<|python_tag|>{json}<|eom_id|> or <function=name>{json}</function>
Mistral / Mixtral[TOOL_CALLS][{json array}]
Gemma 4split-pipe DSL <|tool_call>call:NAME{…}<tool_call|> (bare keys, strings wrapped in Gemma 4's <|"|> delimiter)
Fallbackfenced json block, {"tool_call":{…}} wrapper, or bare {"name","arguments"}

Streaming with declared tools is buffered: every dialect accumulates the full generation and emits the structured tool_calls in one terminal delta, so a raw <tool_call> marker never leaks as a content token (API-7).

Native agent loop (agent_loop.py)

On /api/chat, opting in with agent_loop=true drives the tool-use cycle server-side: call the model, dispatch any emitted tool_calls (MCP tools only — the model must not hallucinate external names), append role=tool results, and re-call until a tool-call-free reply or max_iterations (default 6). Tool calls within one turn fire concurrently via asyncio.gather. run_agent_loop() returns the final message plus a tool_trace for replay/debugging.

Responses API (POST /v1/responses)

routes_openai_responses.py. OpenAI's higher-level wrapper (the path client.responses.create(...) hits) is implemented on top of the existing chat-completion machinery — no new engine path. It is not stateful: every request is self-contained and the server does not persist a response_id chain.

Responses requestChat-completion equivalent
input (str | list of message dicts)messages via _input_to_messages() (role defaults to user; list-of-parts text is concatenated, image parts dropped — image support not in scope)
instructionsleading system message
toolsforwarded to the engine; parsed back via tool_parsers.dispatch()
reasoning.effort (low/medium/high)GenerationConfig.thinking_level via _resolve_thinking()
response_formatnormalised through normalize_openai_response_format()
stream: falserenders the output[] envelope
stream: trueSSE with response.created / response.output_text.delta / response.completed events

The non-stream response is built by _render_response() as a heterogeneous ordered output list: reasoning summaries first, then the assistant message (output_text blocks), then any function_call items — plus a usage block with input_tokens/output_tokens/total_tokens. Streaming buffers tool-aware turns and only emits structured function_call items in the final response.completed event; both modes run through the inference dispatcher (a slot is held for the whole stream).

WebSocket /ws/chat (cancellable)

routes_ws.py. The streaming HTTP endpoints are one-shots: cancellation relies on TCP close and carries no actionable signal back. The WebSocket endpoint adds a persistent connection across multiple chat turns, a cancel frame that interrupts the in-flight generation, and token-level server frames so a client renders UI without parsing NDJSON or SSE.

Frame grammar (JSON, one per WS message)

DirectionFrames
client → server{type:"chat", model, messages, options?}, {type:"cancel"}, {type:"ping"}
server → client{type:"ready", model}, {type:"token", delta}, {type:"done", tokens}, {type:"cancelled", tokens}, {type:"error", message}, {type:"pong"}
chatreadytoken…done / cancelled

The receive loop reads frames continuously while one turn runs in a background asyncio.Task (_drive_chat), so a cancel can arrive mid-flight. The driver races a per-turn asyncio.Event against each produced token via asyncio.wait(FIRST_COMPLETED) and exits cleanly, leaving the connection open for the next prompt.

⚠️

Cancellation honesty

The sync engine producer thread cannot be preempted (llama-cpp-python exposes no cancel API). On cancel, the consumer stops but the producer keeps running until the engine returns by itself, so the dispatcher slot stays busy. The engine is pinned via state.pin_engine() for the producer's lifetime to defer a concurrent hot-swap unload (model-lifecycle UAF guard), and the orphaned slot is logged + counted in hfl_ws_cancel_orphans_total.

Pre-accept gate

WebSocket upgrades bypass the HTTP APIKeyMiddleware and CORSMiddleware, so _check_ws_auth_and_origin() mirrors both inline: API key via ?api_key=, Authorization: Bearer or X-API-Key (constant-time compare), and an Origin allow-list check (strict same-origin by default). Rejections accept-then-close with code 1008 so the browser surfaces the reason.

Per-Dialect Error Envelopes

HFL's native (flat) {"error": str, "code": …, "category", "retryable", "details", "request_id"} shape matches no real provider. errors.py:render_envelope(path, status_code, flat) reshapes it by request-path prefix so real OpenAI/Anthropic SDKs (which branch on error.type) parse it correctly, keeping HFL's rich diagnostic fields as SDK-ignored extensions inside the error object.

Path prefixEnvelope
/v1/messages* (Anthropic){"type":"error", "error":{type, message, …}, "request_id"}type from _ANTHROPIC_TYPE (e.g. 401→authentication_error, 503→overloaded_error)
/v1/* (OpenAI){"error":{message, type, param, code, …}}type from _OPENAI_TYPE (e.g. 429→rate_limit_error)
/api/* (Ollama) + everything elseflat HFL body, unchanged

Every error code carries a (category, retryable) policy from _ERROR_POLICY (e.g. RATE_LIMIT_EXCEEDED→retryable, VALIDATION_ERROR→not), so a client's retry / circuit-breaker logic decides without parsing prose. Factory helpers (service_unavailable, model_loading, queue_full, queue_timeout, …) all take an optional path for per-dialect rendering.

Validation handler (400)

exception_handlers.py registers three handlers via register_exception_handlers(app). The RequestValidationError handler emits a per-dialect 400 on /v1/* surfaces (real OpenAI/Anthropic return 400, not FastAPI's default 422 {"detail":[…]}), flattening the first error to "loc: msg"; /api/* and everything else keep the native 422. The HFLError handler and a catch-all Exception handler both run their bodies through render_envelope too, so even an unhandled 500 reaches an OpenAI/Anthropic SDK as a parseable error object.

Dispatcher Integration (spec §5.3)

Every chat surface routes its engine call through the shared inference dispatcher via helpers.py, serialising concurrent requests against the non-reentrant llama.cpp / transformers model (concurrent use corrupts the KV cache).

🚦

run_dispatched()

One-stop entry for non-stream handlers: acquires a slot, runs the sync engine call in a thread, applies the generation timeout. A timed-out / cancelled sync call cannot be preempted, so the slot is held until the worker thread actually exits — the next queued request can't start a second inference that corrupts the model mid-generation.

🚦

prepare_stream_response() / acquire_stream_slot()

Streaming endpoints pre-acquire a slot held for the whole stream. On QueueFullError / QueueTimeoutError the helper returns a pre-built 429 / 503 envelope (with Retry-After / X-Queue-Depth), rendered for the request's dialect.

Metrics & Health (updated)

routes_metrics.py exposes GET /metrics (Prometheus text via get_metrics().export_prometheus()) and GET /metrics/json (structured JSON). The RequestLogger middleware (middleware.py) feeds every request into Metrics.record_request(), accumulating per-endpoint / per-status / per-method counters plus all-time latency histograms (_bucket/_sum/_count for Prometheus).

New counters since v0.1.0

MetricMeaning
hfl_ws_cancels_totalevery /ws/chat cancel frame received
hfl_ws_cancel_orphans_totalcancels that left the dispatcher slot busy (engine still running)
hfl_stream_cancel_orphans_totalSSE/HTTP streams torn down while the engine producer thread was still running

The orphan counters exist because a sync engine call run via asyncio.to_thread cannot be cancelled — they let capacity planning see how often a cancel/disconnect leaves an inference running to completion in the background.

GET /healthz (orchestrator, spec §5.5)

Beyond the existing /health* probes, routes_health.py adds /healthz: returns 200 status=ok when an LLM engine is loaded and ready, else 503 status=degraded. The body carries models_loaded, live queue_depth / queue_in_flight from the dispatcher snapshot, and uptime_seconds — one scrape for orchestration.

13. Module i18n — Internationalization

File: src/hfl/i18n/__init__.py

Complete internationalization system that allows the CLI to display all messages in multiple languages. Uses JSON translation files with nested keys and dot-notation access.

Main Functions

FunctionDescription
t(key, **kwargs)Translates a key (e.g., t("commands.pull.downloading")). Supports interpolation with .format(**kwargs). Cached with lru_cache for performance.
get_language()Returns the current language. Reads from HFL_LANG env var, defaults to "en"
set_language(lang)Changes language at runtime and clears the translation cache
_load_translations(lang)Loads the language JSON file from locales/
_get_nested_value(data, key)Navigates nested dictionaries with dot notation

Translation Files

Each language has a JSON file (~441 lines, ~294 keys) in src/hfl/i18n/locales/:

en.json (English) and es.json (Spanish). Keys follow the structure module.action.message, for example: commands.pull.downloading, commands.search.no_results, errors.model_not_found.

Usage: Set language with export HFL_LANG=es. All CLI commands use t() for their messages, allowing language changes without modifying code.

Current State (v0.15.0)

Still exactly two locales — en.json and es.json — but each has grown to 441 lines (~294 leaf keys) as every new command (discover, recommend, bench, snapshot, lora, …) registers its commands.<name>.description / option help strings through t() at import time. Language resolution is HFL_LANG → system locale (LC_ALL/LC_MESSAGES/LANG) → en. A missing key falls back to English and, failing that, returns the key verbatim, so an untranslated string degrades gracefully rather than crashing the CLI.

14. Module tray — System Tray Icon

Files: src/hfl/tray/icon.py, src/hfl/tray/controller.py

Optional cross-platform system-tray front-end for the server, reached with hfl serve --tray (alias --gui). Built on pystray + Pillow (extra [tray]); the icon image is generated programmatically — no asset files ship with the package. If the extra is missing, serve prints an install hint and exits.

🔴

TrayServerController

Runs uvicorn in a daemon background thread with its own event loop, exposing start() / stop() / status from the non-async tray callbacks. State is a ServerStatus enum: STOPPED, STARTING, RUNNING, STOPPING, ERROR. stop() sets server.should_exit and joins with a 35 s budget (30 s graceful + 5 s margin). Can pre-load a model via select_engine before serving.

🧩

HFLTrayIcon

_generate_icon_image() draws a 64×64 RGBA circle (colour keyed to status) with a centred “H”. The menu offers Start/Stop (enabled by current status), a status line, the server URL, the HFL v{__version__} string and Exit. On macOS run() must be called from the main thread; a background poller transitions the icon STARTING→RUNNING.

Convenience entry: run_tray(host, port, api_key, model, log_level, json_logs, auto_start) wires a controller to an icon and (optionally) auto-starts the server on launch.

15. Module observability — Metrics, Signing & Audit

Two distinct concerns live here, at two distinct maturity levels. Metrics (src/hfl/metrics.py) is the production telemetry path: it backs /metrics and the SLI view, and now emits aggregatable Prometheus histograms plus dispatcher saturation counters. The src/hfl/observability/ package — ed25519 manifest signing, a structured audit log, and OpenTelemetry tracing — is fully implemented and test-covered but dormant: no module under src/hfl/ imports hfl.observability yet, so these helpers ship as opt-in plumbing with zero production call sites. We document what the code does and flag the wiring gap explicitly.

Prometheus histograms

Metrics (a thread-safe @dataclass guarded by a single threading.Lock) keeps two latency families as all-time, monotonic histograms in addition to the bounded deque(maxlen=1000) ring buffers used for the local JSON percentile view. The deques silently shrink under load (their window is the last 1000 samples), so the exported series is the cumulative bucket array instead — that is what makes histogram_quantile() and rate(..._count) correct across any scrape window and across replicas.

Bucket boundaries (_LATENCY_BUCKETS_MS) are 13 fixed millisecond edges from 5 to 60000, plus a +Inf overflow bucket. _bucket_index() finds the first boundary >= value; record_request() and record_generation() increment the matching bucket and the running _sum_ms. _append_histogram() renders the cumulative _bucket{le=...} / _sum / _count triple.

def _bucket_index(value_ms: float) -> int:
    for i, boundary in enumerate(_LATENCY_BUCKETS_MS):
        if value_ms <= boundary:
            return i
    return len(_LATENCY_BUCKETS_MS)  # +Inf overflow

Two histogram series are exported: hfl_request_latency_ms and hfl_generation_latency_ms, each with the canonical _bucket/_sum/_count lines. The JSON view (export_json()) and get_sli() still compute p50/p95/p99 locally over the deque via linear-interpolation _percentile().

Dispatcher saturation metrics

On every scrape, export_prometheus() pulls a DispatcherSnapshot (from src/hfl/engine/dispatcher.py, a frozen six-int dataclass) via get_dispatcher().snapshot() and exports the load-shedding signals that were previously un-alertable. Reading the snapshot is cheap, so it happens inline under a best-effort try/except (config may not be loaded in some tests).

MetricTypeSource fieldMeaning
hfl_inference_concurrency_maxgaugemax_inflightConfigured max in-flight inference requests
hfl_inference_concurrency_inflightgaugein_flightRequests currently executing
hfl_inference_queue_depthgaugedepthRequests waiting for a slot
hfl_inference_accepted_totalcounteraccepted_totalRequests admitted to the dispatcher
hfl_inference_rejected_full_totalcounterrejected_full_totalRejected because the wait queue was full (HTTP 429)
hfl_inference_rejected_timeout_totalcounterrejected_timeout_totalRejected after waiting too long for a slot (HTTP 503)

Alongside these, the exporter publishes streaming-cancel diagnostics: hfl_ws_cancels_total / hfl_ws_cancel_orphans_total (WebSocket cancel frames, and the subset that left an engine call running in the background with the dispatcher slot still busy) and the SSE/HTTP parity counter hfl_stream_cancel_orphans_total.

ed25519 manifest signing dormant

src/hfl/observability/signing.py implements opt-in provenance over model manifests. manifest_digest() carves a stable subset of the envelope (name, repo_id, file_hash, hash_algorithm, size_bytes, quantization, architecture, adapter_paths, parent_digest) — deliberately dropping mutable metadata like last_used so a hfl show never invalidates a signature — and SHA-256s its canonical JSON. sign_manifest_envelope() attaches an {alg:"ed25519", key_id, digest, sig} block; verify_manifest_envelope() checks it against a TrustRoot keyring the operator curates at ~/.hfl/trusted-publishers.json (key_id → base64url public key).

carve envelopeSHA-256 digested25519 signverify vs TrustRoot

The crypto backend is selected at runtime: it tries pynacl first, then cryptography, raising SignatureUnavailableError if neither is importable; a bad/unauthorized block raises SignatureInvalidError (intended to become a 400 in strict mode). Unsigned manifests stay valid — verify_manifest_envelope() simply returns False.

⚠️

Wiring gap

No code under src/hfl/ imports signing; it is exercised only by tests/test_signing.py and tests/test_signing_edge_cases.py. Note in particular that the digest emitted by /api/ps is computed by a separate, local helper _manifest_digest() in src/hfl/api/routes_ps.py (it stamps the manifest's stored file_hash), not by this module.

Audit log dormant

src/hfl/observability/audit.py writes one JSON object per line to HFL_AUDIT_LOG_PATH via a RotatingFileHandler (default 100 MB × 5 backups, tunable through HFL_AUDIT_LOG_MAX_BYTES / HFL_AUDIT_LOG_BACKUPS). It is opt-in: with no path configured, audit_event() is a zero-cost no-op. The event envelope carries ts (UTC, microseconds, Z-suffixed), event, actor (an SHA-256 prefix, never the raw API key), resource, metadata, and outcome. The catalogue is a frozen set AUDIT_EVENTSmodel.create/delete/copy/pull/stop/unload/load, blob.upload, mcp.connect/disconnect, api_key.mint/revoke. The emitter never raises; an unknown event name logs a warning but still emits.

⚠️

Wiring gap

audit_event() has no production call site under src/hfl/; despite the docstring's claim that "every privileged route / CLI command records exactly one event", the instrumentation is not yet wired in. Covered only by tests/test_audit.py / tests/test_audit_log.py.

OpenTelemetry tracing dormant

src/hfl/observability/tracing.py wraps the OTEL SDK behind a trace_span(name, attributes=...) context manager that becomes a nullcontext() no-op when tracing is off — so call sites never have to check is_enabled(). configure_tracing() is gated on HFL_OTEL_ENABLED; when on it builds a TracerProvider with a BatchSpanProcessor + OTLP/HTTP exporter (endpoint HFL_OTEL_EXPORTER_ENDPOINT, default http://localhost:4318/v1/traces; service name HFL_OTEL_SERVICE_NAME, default hfl). The SDK ships behind the [otel] extra; if it's missing, configuration logs a warning and stays disabled.

⚠️

Wiring gap

No trace_span() / configure_tracing() call sites exist under src/hfl/ outside the module itself. The helper is ready but no operation is yet instrumented; exercised by tests/test_tracing.py / tests/test_otel_tracing.py.

16. Modules tools & mcp

The agentic surface. src/hfl/tools/ provides the two built-in tools — web search and URL fetch — exposed Ollama-compatibly at /api/web_search and /api/web_fetch (src/hfl/api/routes_web.py). src/hfl/mcp/ implements both halves of the Model Context Protocol: HFL as a host that connects to external MCP servers and folds their tools into chat, and HFL as a server that publishes its own tools to other hosts. Both are wired into production; MCP rides behind the optional [mcp] extra.

Built-in tools (src/hfl/tools/)

🔎

web_search

web_search.py exposes one async search(query, max_results) that dispatches to a backend chosen by HFL_WEB_SEARCH_BACKEND. Every backend returns the same Ollama envelope {"results":[{title,url,content}]}. max_results is clamped to [1,10].

🛡️

web_fetch

web_fetch.py exposes async fetch(url) returning {title, content, links, url} extracted by a single std-lib HTMLParser (no BeautifulSoup). The HTTP-level errors never raise — only protocol/security/timeout failures raise WebFetchError (turned into a 400).

The search backends form a small registry behind an abstract WebSearchBackend:

BackendEndpointKey
duckduckgo (default, alias ddg)html.duckduckgo.com/html/ (regex-scraped)none
tavilyapi.tavily.com/searchTAVILY_API_KEY
braveapi.search.brave.com/res/v1/web/searchBRAVE_API_KEY
serpapiserpapi.com/search (engine=google)SERPAPI_API_KEY

get_backend() resolves the name (falling back to DuckDuckGo with a warning for unknown values, so the server never 500s on startup). DDG's /l/?uddg= redirect wrappers are unwrapped to the real target URL.

SSRF / DNS-rebinding hardening

web_fetch is hardened against server-side request forgery in depth. The flow:

scheme checkresolve + reject private IPpin validated IPdial IP, real Host/SNIre-validate each redirect
def _is_private_ip(ip_str: str) -> bool:
    try:
        ip = ipaddress.ip_address(ip_str)
    except ValueError:
        return True  # unparseable == unsafe
    return (ip.is_private or ip.is_loopback or ip.is_link_local
            or ip.is_multicast or ip.is_reserved or ip.is_unspecified)

The route layer (routes_web.py) validates request bodies with Pydantic (query 1–2048 chars; max_results 1–10; url 1–2048 chars), maps WebSearchError/WebFetchError to HTTP 400, and a crash to 500 with a non-leaking message.

Model Context Protocol (src/hfl/mcp/)

The official mcp Python SDK is an optional dependency. Both modules import unconditionally and degrade gracefully: a missing SDK raises MCPClientUnavailableError / MCPServerUnavailableError only at the point of use, never at import, so installations without the [mcp] extra keep working.

HFL as host — mcp/client.py

MCPClient (a singleton via get_client()) holds live sessions to every configured server. connect(server_id, target) accepts two transports — stdio://<cmd> <args> (launches a subprocess speaking JSON-RPC) and sse://<url> (HTTP + Server-Sent Events) — calls session.initialize() + list_tools(), and wraps each result as an MCPTool. Tool names are namespaced as server_id__tool (qualified_name) to prevent collisions across servers, and to_ollama_tool() converts them to the {type:"function", function:{...}} schema the model sees. autoload_servers() connects every entry in the JSON file pointed to by HFL_MCP_AUTOLOAD; a broken entry is logged but never aborts hfl serve.

Chat integration (src/hfl/api/routes_native.py): _merge_mcp_tools() folds every connected MCP tool into the tools array of an /api/chat request that didn't already declare them (best-effort — a failed read is logged and ignored). When agent_loop=true, the route passes mcp_client.call_tool as the mcp_caller into run_agent_loop(), which dispatches tool calls and re-submits results until a tool-free reply or max_iterations (default 6).

HFL as server — mcp/server.py

HFLMCPServer publishes a minimal, safe-by-default registry HFL_TOOLS: web_search, web_fetch, model_list and model_show. The first two delegate to hfl.tools; the latter two read hfl.models.registry. --capabilities narrows the published set (_filter_tools()). build_server() wires the SDK's @server.list_tools() / @server.call_tool() handlers; a handler ValueError becomes an MCP IsError TextContent response, any other exception is logged and surfaced as a generic internal error. Two transports are served: serve_stdio() (for subprocess hosts) and serve_sse(host, port) (a Starlette app under uvicorn, /sse + /messages/).

CLI surface — hfl mcp

The mcp command in src/hfl/cli/main.py drives both halves:

CommandEffect
hfl mcp listPrint every tool across connected servers (qualified name + description)
hfl mcp connect <id> stdio://… | sse://…Open a session and enumerate its tools
hfl mcp disconnect <id>Close the session (idempotent)
hfl mcp serve --transport stdioRun HFL as an MCP server over stdio
hfl mcp serve --transport sse --host … --port … --capabilities web_search,web_fetchRun as an SSE server with a narrowed tool set

17. Exception Hierarchy

Custom Exception Tree
HFLError ModelNotFoundError ModelAlreadyExistsError DownloadError NetworkError ConversionError ToolNotFoundError LicenseError LicenseNotAcceptedError GatedModelError EngineError ModelNotLoadedError MissingDependencyError OutOfMemoryError AuthenticationError InvalidTokenError TokenRequiredError ConfigurationError InvalidConfigError

All inherit from HFLError(message, details) with __str__ combining both. Each exception includes specific contextual data (model_name, repo_id, required_gb, etc.) for detailed diagnostics.

18. Complete Flow: hfl pull

Model Download and Registration Pipeline
1. Resolveresolver.py 2. Licenselicense_checker.py 3. Downloaddownloader.py 4. Detect fmtformats.py 5. Convert?gguf_converter.py(only if not GGUF)safetensors→FP16→Qx 6. Create Manifestmanifest.py 7. Registerregistry.add() HfApi.model_info() check + accept hf_hub_download/ snapshot_download models.json

19. Complete Flow: hfl run

Interactive Chat Pipeline
1. Registry.get()search by name/alias 2. select_engine()auto → format + HW 3. engine.load()load into RAM/VRAM 4. Chat Loopinput → messages.append()→ chat_stream()→ print tokens 5. /exitengine.unload() loop until /exit

20. Complete Flow: hfl serve

API Server Pipeline
ClientHTTP/SSE MiddlewareCORSAPIKeyDisclaimer (R9) Routerroutes_openai.pyroutes_native.py/health, / _ensure_model_loadedRegistry → select_engine→ engine.load() (if needed) Enginechat() / generate()chat_stream() ... ResponseJSON / SSE Auto-load: if different model requested, unloads current and loads new — then the engine call runs through the inference dispatcher (slot acquire, spec §5.3)

22. Design Patterns

Strategy Pattern (Engine)

The InferenceEngine interface (ABC) defines the contract. Three concrete implementations (LlamaCpp, Transformers, vLLM) are interchangeable. selector.py acts as factory choosing the correct strategy based on context.

Lazy Loading / Lazy Imports

All heavy dependencies (torch, transformers, vllm, llama-cpp-python) are imported only when needed. Imports inside functions prevent minimal installations from failing due to absent optional dependencies.

Dependency Injection Container

core/container.py implements a DI container with thread-safe singletons (double-checked locking). Provides get_config(), get_registry(), get_state(), get_event_bus(), get_metrics(). Facilitates testing with reset_container().

Dataclass as Value Objects

ChatMessage, GenerationConfig, GenerationResult, ModelManifest, ResolvedModel, ConversionRecord, LicenseInfo — all are immutable or semi-immutable dataclasses encapsulating data.

Pipeline / Chain (CLI pull)

The pull command implements a sequential 7-step pipeline: resolve → verify license → download → detect format → convert (conditional) → create manifest → register. Each step is a decoupled component.

Adapter Pattern (Dual API)

The same inference engines are exposed through two different API interfaces (OpenAI and Ollama) via separate routers that adapt request/response formats to each ecosystem's expected format.

Circuit Breaker + Retry

utils/circuit_breaker.py implements circuit breaker for external calls. utils/retry.py provides retry with configurable exponential backoff. Both improve resilience against network failures or external service issues.

Event Bus (Pub/Sub)

events.py implements an internal EventBus for decoupled communication between components. Allows subscribing to events like model_loaded, inference_complete, download_progress without creating direct dependencies.

Architecture Decision Records (ADRs)

Important architectural decisions are documented in docs/adr/ following the standard ADR format:

ADRTitleStatus
0001Singleton Pattern for Config and RegistryAccepted
0002Async API with Sync Engines (sync_to_thread)Accepted
0003GGUF as Default FormatAccepted
0004Ollama API CompatibilityAccepted
0005License Classification (5 levels)Accepted
0006Rate Limiting StrategyAccepted

New Patterns (added since v0.1.0)

🚧

Bulkhead / Bounded Dispatcher

engine/dispatcher.pyInferenceDispatcher serialises access to the non-reentrant model behind a bounded-concurrency, bounded-wait-queue primitive: max_inflight (default 1), max_queued (16), acquire_timeout (60 s). A full queue raises QueueFullError → HTTP 429 + Retry-After; a slot timeout raises QueueTimeoutError → 503. Live state is exposed via snapshot() for /healthz and the X-Queue-Depth header.

🚰

Drain Barrier (hot-swap safety)

api/state.py · set_llm_engine wraps the retire-and-assign of the previous engine inside dispatcher.exclusive(), which waits for every inference slot to clear before the old model is freed. The teardown runs off-loop via asyncio.to_thread(prev.unload) so a slow GPU/Metal context cleanup never freezes /healthz or /metrics. The swap is atomic: an unload failure leaves the old engine in place.

📌

Refcount Pin / Unpin

Inference paths that do not hold a dispatcher slot — notably the WebSocket chat turn — call pin_engine() / unpin_engine() around their use of the engine. While an engine is pinned, a concurrent hot-swap defers its unload (stashing it in _engine_retired) until the last reader unpins. This closes the use-after-free window on the shared non-reentrant model that a drain alone would miss.

🔀

Shared Chat-Core

api/chat_core.pyresolve_chat_output() is the single dialect-agnostic decision point: given raw engine text it produces a canonical ChatOutput(content, tool_calls), honouring tool_choice:"none" and preferring the engine's structured tool-calls over marker parsing. OpenAI, Ollama and Anthropic routes now differ only in wire-format translation — the historical bug where the OpenAI route silently dropped tool-calling can no longer recur.

23. Dependencies and Extras

Dependency Map by Group
Core (always installed) typer rich huggingface-hub fastapi uvicorn pydantic httpx sse-starlette pyyaml [llama] llama-cpp-python >=0.3 [transformers] transformers >=4.47 torch >=2.5 accelerate >=1.2 sentencepiece [vllm] vllm >=0.6 [convert] gguf >=0.10 [dev] pytest >=8.0 pytest-asyncio ruff >=0.8 pytest-cov [build] pyinstaller >=6.0 | [all] = llama + transformers + vllm + convert Build system: hatchling | Target: Python >=3.10 | Linting: ruff (E, F, W, I) | Line length: 100

New Optional Extras (since v0.1.0)

The extras matrix expanded well beyond the original four. Heavy native deps now carry upper bounds (cap at the next major) because they are the largest vuln/breakage surface. [all] = llama,transformers,vllm,convert,tts,coqui,audio,tray,mcp,mlx,stt,imagegen.

ExtraPulls inPurpose
[vulkan]references [llama]Vulkan GPU backend (Intel Arc, AMD w/o ROCm); built with CMAKE_ARGS="-DGGML_VULKAN=ON".
[rocm]references [llama]AMD HIP path; built with -DGGML_HIPBLAS=ON. Same wheel, different CMake flag.
[mlx]mlx-lm>=0.31.2,<1.0 (darwin/arm64)Apple Silicon native backend.
[coqui]coqui-tts, torch, torchaudio, soundfileCoqui TTS engine (alongside the Bark-via-transformers [tts]).
[audio]sounddevice, soundfileLocal audio playback.
[tray]pystray, PillowSystem-tray GUI for serve --tray.
[mcp]mcp>=1.0.0Model Context Protocol client/server.
[stt]faster-whisper>=1.0.0,<2.0Speech-to-text (Whisper).
[imagegen]diffusers, torch, safetensors, Pillow, accelerateSDXL / FLUX image generation (~2 GB of wheels).
[otel]opentelemetry api/sdk + OTLP-HTTP exporterDistributed tracing.

Pin bumps: core now targets transformers>=5.0,<6.0 + torch>=2.5,<3.0 (the 5.x / hub-1.x line), huggingface-hub>=1.5,<2.0, fastapi~=0.135, starlette>=1.3.1 and python-multipart>=0.0.31 (both floors set by CVE fixes). llama-cpp-python>=0.3.20 (gemma4 support). [dev] added mypy>=1.10 and numpy>=1.24.

24. Testing

Coverage: ~89% with ~3,537 tests — Comprehensive suite with pytest + pytest-asyncio (asyncio_mode = "auto"). ~201 test files (incl. a tests/stress/ dir) with shared fixtures in conftest.py.

Test Categories

Core & Config

test_config.pyHFLConfig, paths, env vars
test_container.pyDI container, Singleton pattern
test_exceptions.pyException hierarchy
test_events.pyEventBus, pub/sub
test_metrics.pyPerformance metrics
test_validators.pyData validation
test_security.pySecurity & sanitization

API Server

test_api*.py (5)Endpoints, auth, contracts
test_routes_*.py (4)OpenAI, Native, TTS
test_state.pyServerState, async locks
test_streaming.pySSE streaming
test_model_loader.pyDynamic model loading
test_helpers.pyensure_llm/tts_loaded
test_middleware.pyPrivacy logger, disclaimer

Engine & Inference

test_engine*.py (2)Base, LlamaCpp
test_selector*.py (3)Backend auto-selection
test_transformers*.py (2)TransformersEngine
test_vllm_engine.pyvLLM with mocks
test_tts_*.py (2)Bark, Coqui TTS
test_async_wrapper.pySync→Async wrapper
test_model_pool.pyModel pool

Hub & Downloads

test_hub*.py (2)HF Hub integration
test_downloader*.py (2)Download with resume
test_resolver*.py (2)Model resolution
test_license_checker.pyLicense classification
test_auth.pyHF authentication

CLI & Utils

test_cli*.py (4)Typer commands
test_converter*.py (3)GGUF conversion
test_i18n*.py (4)Internationalization
test_circuit_breaker.pyCircuit breaker
test_retry.pyRetry with backoff

Integration & Edge Case Tests

test_integration.pyFull end-to-end flows (pull→run→serve)
test_concurrency.pyConcurrency, thread safety, race conditions
test_network_errors.pyTimeouts, disconnects, retry logic
test_edge_cases.pyMalformed inputs, boundary conditions
test_server_lifecycle.pyStartup, shutdown, cleanup
test_cli_signal.pyCLI signal handling (Ctrl+C graceful shutdown)
test_middleware_order.pyMiddleware execution order verification
test_exception_handlers.pyHFLError → HTTP status code mapping
test_health_probes.pyHealth probe endpoints (/health/deep, /health/sli)
test_config_env.pyConfig env var override tests
test_model_pool_wait.pyModel pool non-recursive waiting
test_stress.pyStress tests (concurrent streaming, model pool stress)
test_timeout.pyTimeout decorator tests
test_failover.pyFailover engine multi-backend retry
test_rate_limit_per_model.pyPer-model rate limiting
test_conversion_caching.pyConversion caching tests

Main fixtures (conftest.py): tmp_hfl_home (temp directory), mock_hf_api (HfApi mock), sample_manifest (sample model), populated_registry (pre-populated registry), mock_engine (mocked inference engine), test_client (FastAPI TestClient).

# Run tests with coverage
pytest --cov=hfl --cov-report=html --cov-report=term-missing  # fail_under=75 (pyproject)

# Tests by category
pytest tests/test_api*.py -v          # API only
pytest tests/test_engine*.py -v       # Engines only
pytest -k "not slow" -v               # Exclude slow tests

25. Build and Distribution

Build System: Hatchling

Configured in pyproject.toml. Source packages in src/hfl. Entry point: hfl = "hfl.cli.main:app". Installation with pip install . or pip install .[all] for all optional dependencies.

PyInstaller (hfl.spec)

Spec to generate standalone executable. Allows distributing HFL as a single binary without requiring Python installed. Generated with pyinstaller hfl.spec and result is in dist/hfl.

Local Storage

~/.hfl/
├── models/               # Downloaded model files
│   └── org--model/       # Directory per model (org--model format)
├── cache/                # HuggingFace Hub cache
├── tools/
│   └── llama.cpp/        # Compiled conversion tools
│       └── build/bin/    # Binaries: llama-quantize, etc.
├── models.json           # Model registry (array of ModelManifest)
└── provenance.json       # Immutable conversion log

26. CI/CD & GitHub

GitHub Actions Workflows

WorkflowTriggerDescription
ci.ymlManual (workflow_dispatch)Full pipeline: tests (pytest matrix ubuntu+macos × Python 3.10/3.11/3.12 + coverage) + lint (ruff check & format) + type-check (mypy)
pages.ymlManual (workflow_dispatch)Deploy HTML docs to GitHub Pages (manual trigger only)
build-executables.ymlRelease/manualCross-platform executable builds with PyInstaller
lint.ymlManual (workflow_dispatch)Linting with ruff (E, F, W, I)
test.ymlManual (workflow_dispatch)Tests with pytest + coverage
security.ymlManual (workflow_dispatch)Dependency security audit
license-check.ymlManual (workflow_dispatch)Dependency license verification

GitHub Templates

Issue Templates: bug_report.yml (structured form with system info, reproduction steps, logs) and feature_request.yml (problem statement, proposed solution, affected component).

PR Template: Checklist including verification of all 5 Compliance Modules (license, provenance, disclaimer, privacy, gating) as project responsible-use norms.

Community files: CONTRIBUTING.md (dev setup, code style, PR process), CODE_OF_CONDUCT.md (Contributor Covenant v2.1), SECURITY.md (vulnerability reporting policy).

Manual-Only Workflows (v0.15.0)

Every one of the 14 workflows under .github/workflows/ is now declared on: workflow_dispatch — the automatic push / pull_request / schedule / tag triggers were intentionally removed. The owner runs each pipeline by hand from the Actions tab. There is no automatic publish: publish-pypi.yml has its tag trigger removed and runs only on manual dispatch (with a testpypi input), using PyPI Trusted Publisher (OIDC, no stored token) plus a Sigstore attestation. build-executables.yml additionally keeps a release: trigger for asset uploads.

WorkflowTriggerWhat it runs
ci.ymldispatchTest matrix (ubuntu+macos × Py 3.10/3.11/3.12) with coverage, ruff lint+format, mypy on api/+cli/.
test.yml / lint.ymldispatchStandalone pytest+coverage / ruff (legacy split jobs).
security.ymldispatchDependency security audit.
license-check.ymldispatchDependency license verification.
publish-pypi.ymldispatchBuild + twine check + OIDC publish to PyPI/TestPyPI + Sigstore.
docker.yml / homebrew.ymldispatchContainer images / Homebrew formula.
macos-dmg.yml / windows-msi.yml / build-executables.ymldispatch (+ release)Native installers and PyInstaller bundles.
pages.yml / release.ymldispatchDocs to GitHub Pages / release packaging.

HFL v0.15.0 — Comprehensive Architecture Documentation

Updated on June 19, 2026 — All diagrams are interactive SVGs

License: Apache-2.0