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.
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.
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.
Python >=3.10 | Base language |
typer >=0.12 | CLI Framework |
rich >=13.0 | Styled output |
pydantic >=2.10 | API data validation |
pyyaml >=6.0 | Configuration parsing |
fastapi >=0.115 | Async web framework |
uvicorn >=0.32 | ASGI server |
sse-starlette >=2.0 | Server-Sent Events |
httpx >=0.28 | Async HTTP client |
huggingface-hub >=0.27 | HF Hub API |
llama-cpp-python | GGUF Backend |
transformers+torch | Native GPU backend |
vllm | Production backend |
gguf | Format conversion |
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
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.
| Property | Type | Default | Env Var | Description |
|---|---|---|---|---|
home_dir | Path | ~/.hfl | HFL_HOME | Root directory |
models_dir | Path (prop) | ~/.hfl/models | — | Storage for downloaded models |
cache_dir | Path (prop) | ~/.hfl/cache | — | Temporary HuggingFace cache |
registry_path | Path (prop) | ~/.hfl/models.json | — | Model registry (local inventory) |
llama_cpp_dir | Path (prop) | ~/.hfl/tools/llama.cpp | — | Compiled conversion tools |
host | str | 127.0.0.1 | HFL_HOST | API server address |
port | int | 11434 | HFL_PORT | Port (same as Ollama for compatibility) |
default_ctx_size | int | 0 (auto-detect) | HFL_DEFAULT_CTX_SIZE | Default n_ctx; 0 = auto-detect from GGUF metadata |
default_n_gpu_layers | int | -1 | — | GPU layers (-1 = all) |
rate_limit_enabled | bool | true | HFL_RATE_LIMIT_ENABLED | Enable/disable rate limiting |
rate_limit_requests | int | 60 | HFL_RATE_LIMIT_REQUESTS | Max requests per window |
rate_limit_window | int | 60 | HFL_RATE_LIMIT_WINDOW | Rate limit window (seconds) |
hf_token | str|None | — | HF_TOKEN | Authentication token (memory only, never persisted) |
hf_token is read ONLY from the environment variable. It is never persisted to disk, never saved to models.json or any configuration file. It exists only in memory during process execution.
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.
| HFL | Ollama alias | Default | What 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_LIBRARY | OLLAMA_LLM_LIBRARY | (auto) | Pin backend: llama-cpp, transformers, vllm, mlx. Per-call backend= still wins. |
HFL_FLASH_ATTENTION | OLLAMA_FLASH_ATTENTION | (auto) | Toggle flash-attention fleet-wide; per-arch safety list still rejects unsafe arches. |
HFL_DISABLE_MLX | — | 0 | Forces the llama-cpp Metal path on Apple Silicon (benchmarking). |
HFL_KV_CACHE_TYPE | OLLAMA_KV_CACHE_TYPE | f16 | KV-cache dtype: f16 / q8_0 / q4_0 (halves / quarters VRAM). |
HFL_NUM_PARALLEL / HFL_QUEUE_MAX_INFLIGHT | OLLAMA_NUM_PARALLEL | 1 | Concurrent inference slots in the dispatcher. |
HFL_MAX_QUEUE / HFL_QUEUE_MAX_SIZE | OLLAMA_MAX_QUEUE | 16 | Wait-queue depth; over it requests get 429. |
HFL_QUEUE_ACQUIRE_TIMEOUT | — | 60 | Seconds a caller waits for a slot before 503. |
HFL_KEEP_ALIVE | OLLAMA_KEEP_ALIVE | 5m | Default keep-alive when a request omits the field (Ollama duration grammar; -1=never). |
HFL_ORIGINS | OLLAMA_ORIGINS | (same-origin) | Comma-separated CORS allow-list; * flips wildcard mode (rejects credentials). |
HFL_DEBUG | OLLAMA_DEBUG | (off) | Truthy forces the hfl root logger to DEBUG. |
HFL_MAX_REQUEST_BYTES | — | 10 MiB | Cap on request body (0 disables). Blob uploads use HFL_MAX_BLOB_BYTES (default unlimited). |
HFL_STREAM_QUEUE_PUT/GET_TIMEOUT | — | 60/30 | Streaming backpressure timeouts (engine enqueue / consumer wait). |
HFL_REGISTRY_SQLITE_TIMEOUT | — | 30 | SQLite registry busy-timeout in seconds. |
OTEL_EXPORTER_OTLP_ENDPOINT | — | (off) | Standard OpenTelemetry env; HFL emits spans when set (extra [otel]). |
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.
File: src/hfl/cli/main.py (~2,350 lines)
Framework: Typer + Rich. Entry point registered in pyproject.toml as hfl = "hfl.cli.main:app"
| Command | Description | Key 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 serve | REST API server | --host, --port, --model (pre-load), --api-key (authentication) |
hfl list | List local models with Rich table | Shows 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 confirmation | Deletes files + registry entry |
hfl inspect <model> | Full detail (Rich panel) | Shows metadata, license, restrictions, timestamps |
hfl alias <model> <alias> | Assign short alias | Allows referring to models by simple names |
hfl login | Configure HF token | --token or interactive. Verifies with whoami() |
hfl logout | Remove saved token | Uses huggingface_hub.logout() |
hfl version | Show version + license | — |
hfl compliance-report | Legal compliance report (JSON/Markdown) | Output format selection |
run command handles Ctrl+C during token streaming gracefully, preserving the partial response.
_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.
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.
| Command | Description | Key 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 ps | List 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 doctor | Diagnose accelerators (NVIDIA/Metal/ROCm), installed extras, VRAM + recommended num_ctx | — |
hfl check | Backend availability (llama-cpp / transformers / vllm / mlx), GPU, TTS deps | — |
hfl debug | System / 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 recommend | HW-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-dashboard | At-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.
compliance-report still hard-codes "hfl_version": "0.1.0" in its JSON/Markdown output instead of reading hfl.__version__ (now 0.15.0).
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.
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.
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.
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.
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.
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.
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.
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 planning → planned NDJSON preface, then delegates byte transfer to the existing pull machinery via iter_pull_events().
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.
hfl/hub/hw_profile.py builds a cheap, metadata-only HardwareProfile (no GPU work). Memory budgets used by both recommend and smart_pull:
| Host | Detection | Budget |
|---|---|---|
| CUDA | torch.cuda.is_available(); largest visible device via get_device_properties | full GPU VRAM |
| Apple Silicon (Metal) | Darwin + arm64 + mlx_lm importable (has_mlx) | 70% of unified RAM |
| ROCm | HIP_VISIBLE_DEVICES / ROCM_PATH env | not probed (None) |
| CPU-only / unknown | fallthrough | 70% 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.
_candidate_repos() generates, most-specific-first:
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)"}
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.
Dataclass that stores complete metadata for each downloaded model. It is the fundamental unit of information in the system.
| Field | Type | Purpose |
|---|---|---|
name, repo_id | str | Identification (short name + HF repo) |
alias | str|None | User-defined custom name |
local_path, format | str | Location and type (gguf/safetensors/pytorch) |
size_bytes, quantization | int, str | Size on disk + Q level |
architecture, parameters, context_length | str, str, int | Model characteristics |
license, license_name, license_url | str | Legal information (R1) |
license_restrictions, gated, license_accepted_at | list, bool, str | Restrictions and acceptance |
gpai_classification, training_flops | str | EU AI Act (R4) |
created_at, last_used | str | Timestamps |
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().
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).
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.
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.
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.
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:
| Capability | Trigger |
|---|---|
completion | any model whose model_type isn't tts/stt/audio |
tools | qwen, llama/llama3, mistral, mixtral, gemma4 (kept in sync with tool_parsers.dispatch) |
insert (FIM) | codellama, codegemma, starcoder/2, qwen-coder, deepseek-coder |
vision | llava, llama-3.2-vision, llama4, gemma-3, qwen2-vl/2.5-vl, internvl, pixtral, molmo, idefics, minicpm-v |
embedding | bert/nomic/jina/bge/gte/e5/mxbai/stella/arctic-embed — drops completion |
thinking | gemma-4, deepseek-r1, qwen3-thinking, gpt-oss, o1/o3 |
Order is deterministic: completion/embedding first, the rest alphabetical.
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.
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.
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 Production GPU backend. Wraps vllm.LLM with SamplingParams. Real streaming with AsyncLLMEngine, with synchronous fallback for compatibility. Requires NVIDIA GPU with CUDA.
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 with non-recursive waiting (bounded polling), preventing stack overflow with concurrent loads. Real-time RAM and GPU memory tracking via psutil and GPUtil.
Decision logic in select_engine(model_path, backend):
All imports are lazy (_get_llama_cpp_engine(), etc.) to avoid requiring all dependencies to be installed.
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.
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.
_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.
select_engine() in selector.py gained an Apple-Silicon branch and an operator-level override that the old flow doesn't show.
| Control | Effect |
|---|---|
HFL_DISABLE_MLX=1 | _mlx_preferred() returns False — forces the legacy llama-cpp path on Apple Silicon (benchmarking parity). |
HFL_LLM_LIBRARY / OLLAMA_LLM_LIBRARY | Read 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.
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:
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.
Load checks the sidecar's model matches model_name (wrong-model tensors would corrupt memory) and rejects a foreign SNAPSHOT_FORMAT_VERSION with SnapshotVersionMismatch.
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).
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:
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_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.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]
A process-wide, lock-guarded singleton (get_registry()) maps adapter_id → AdapterInfo (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].
_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).
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:
| # | Source | Behaviour |
|---|---|---|
| 1 | per-load flash_attn= kwarg | Explicit value (e.g. from a Modelfile) wins outright. |
| 2 | HFL_FLASH_ATTENTION / OLLAMA_FLASH_ATTENTION | Falsy (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". |
| 3 | architecture default | False 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_type → type_k/type_v q4_0/q8_0/f16).
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().
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.
_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).
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.
| Engine | File / class | Role |
|---|---|---|
| Bark TTS | bark_engine.py · BarkEngine(AudioEngine) | Text-to-speech via transformers; synthesize() / synthesize_stream(). |
| Coqui TTS | coqui_engine.py · CoquiEngine(AudioEngine) | XTTS/VITS/Tacotron-family TTS. |
| Whisper STT | whisper_engine.py · WhisperEngine | Speech-to-text; transcribe() returning WhisperResult/WhisperSegment. |
| Diffusion | diffusers_engine.py · DiffusersEngine | Text-to-image (generate() → ImageResult). Deliberately does not implement InferenceEngine. |
| Embeddings | embedding_engine.py · LlamaCppEmbeddingEngine, TransformersEmbeddingEngine | Text/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).
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.
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.
| Parameter | Default | Config / env | Meaning |
|---|---|---|---|
max_inflight | 1 | queue_max_inflight ← HFL_QUEUE_MAX_INFLIGHT / HFL_NUM_PARALLEL / OLLAMA_NUM_PARALLEL | How many requests may execute simultaneously. Default 1 preserves single-flight behaviour. |
max_queued | 16 | queue_max_size ← HFL_QUEUE_MAX_SIZE / HFL_MAX_QUEUE / OLLAMA_MAX_QUEUE | How many more may wait in line. When full, new callers are rejected immediately. |
acquire_timeout | 60 s | queue_acquire_timeout_seconds ← HFL_QUEUE_ACQUIRE_TIMEOUT | Cap 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 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).
Counter incremented once a caller actually holds a slot (fast or slow path).
QueueFullError → 429 with Retry-After. The wait queue was already at max_queued.
QueueTimeoutError → 503. The caller waited past acquire_timeout — the server is simply saturated.
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:
| Surface | Source | Exposes |
|---|---|---|
/healthz / /healthz/ready | routes_health.py | queue_depth, queue_in_flight |
/metrics (Prometheus) | metrics.py | hfl_inference_concurrency_max, …_inflight, hfl_inference_queue_depth + saturation counters |
| Every HTTP response | middleware.py | headers 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.
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:
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.
routes_snapshot.py runs the multi-GB save_state() + pickle + write under exclusive() + to_thread — off-loop and with inference/swaps drained.
load_state() writes KV tensors into the live model; same drain barrier or it corrupts the model mid-generation.
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.
| Member | Role |
|---|---|
_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.Lock | Guards 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).
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
| Accessor | Singleton |
|---|---|
| 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.
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.
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.
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.
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).
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.
Class GGUFConverter with two-step pipeline:
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).
| Quantization | Bits/weight | Quality | Use 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 |
| F16 | 16.0 | 100% | No quantization |
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).
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.
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.
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:
\n \t \r \" \\ inside single- and triple-quoted values; the encoder escapes backslashes (and embedded """) symmetrically so a Jinja template carrying a literal \n survives parse→render→parse unchanged..., absolute paths, backslashes and NUL; cycles and a depth-16 cap are enforced.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.
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.
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().
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).
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).
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.
| Endpoint | Description |
|---|---|
/health/deep?probe=true | Runs a minimal inference test to verify the model works. Reports "degraded" status on failure. |
/health/sli | Service Level Indicators with availability and latency metrics. |
/metrics | Prometheus-format metrics. |
/metrics/json | JSON-format metrics. |
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() → ChatOutputThe 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:
| Condition | Result |
|---|---|
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 list | Trusted as-is; content="" |
| Otherwise | Fall 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)
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.
/v1/chat/completionsroutes_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".
/api/chatroutes_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.
/v1/messagesroutes_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 (arguments→input dict, toolu_… ids) with stop_reason: "tool_use". Inbound tool_use/tool_result blocks are reconstructed into the engine message list (Claude Code path).
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).
| Family | Marker |
|---|---|
| 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 4 | split-pipe DSL <|tool_call>call:NAME{…}<tool_call|> (bare keys, strings wrapped in Gemma 4's <|"|> delimiter) |
| Fallback | fenced 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).
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.
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 request | Chat-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) |
instructions | leading system message |
tools | forwarded to the engine; parsed back via tool_parsers.dispatch() |
reasoning.effort (low/medium/high) | GenerationConfig.thinking_level via _resolve_thinking() |
response_format | normalised through normalize_openai_response_format() |
stream: false | renders the output[] envelope |
stream: true | SSE 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).
/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.
| Direction | Frames |
|---|---|
| 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"} |
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.
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.
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.
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 prefix | Envelope |
|---|---|
/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 else | flat 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.
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.
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.
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).
| Metric | Meaning |
|---|---|
hfl_ws_cancels_total | every /ws/chat cancel frame received |
hfl_ws_cancel_orphans_total | cancels that left the dispatcher slot busy (engine still running) |
hfl_stream_cancel_orphans_total | SSE/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.
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.
| Function | Description |
|---|---|
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 |
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.
export HFL_LANG=es. All CLI commands use t() for their messages, allowing language changes without modifying code.
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.
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.
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.
_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.
observability — Metrics, Signing & AuditTwo 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.
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().
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).
| Metric | Type | Source field | Meaning |
|---|---|---|---|
hfl_inference_concurrency_max | gauge | max_inflight | Configured max in-flight inference requests |
hfl_inference_concurrency_inflight | gauge | in_flight | Requests currently executing |
hfl_inference_queue_depth | gauge | depth | Requests waiting for a slot |
hfl_inference_accepted_total | counter | accepted_total | Requests admitted to the dispatcher |
hfl_inference_rejected_full_total | counter | rejected_full_total | Rejected because the wait queue was full (HTTP 429) |
hfl_inference_rejected_timeout_total | counter | rejected_timeout_total | Rejected 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.
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).
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.
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.
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_EVENTS — model.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.
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.
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.
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.
tools & mcpThe 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.
src/hfl/tools/)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.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:
| Backend | Endpoint | Key |
|---|---|---|
duckduckgo (default, alias ddg) | html.duckduckgo.com/html/ (regex-scraped) | none |
tavily | api.tavily.com/search | TAVILY_API_KEY |
brave | api.search.brave.com/res/v1/web/search | BRAVE_API_KEY |
serpapi | serpapi.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.
web_fetch is hardened against server-side request forgery in depth. The flow:
http/https (_ALLOWED_SCHEMES)._resolve_and_validate() calls socket.getaddrinfo() and rejects the URL if any resolved address is private, loopback, link-local, multicast, reserved or unspecified (_is_private_ip(); an unparseable address is treated as unsafe). This blocks the cloud-metadata endpoint 169.254.169.254 and internal services._pinned_request() rewrites the URL host to the vetted IP literal while preserving the original authority in the Host header and, for HTTPS, the TLS sni_hostname extension — so httpx cannot re-resolve at connect time and an attacker with TTL-0 authoritative DNS cannot answer public-here / private-to-httpx. Userinfo (user:pass@) is preserved so Basic-auth fetches keep working.follow_redirects=False; up to _MAX_REDIRECTS (3) hops are followed by hand, each re-run through the full scheme + private-IP guard and re-pinned, so a public URL cannot 302 onto an internal/metadata address.resp.aiter_bytes() with a hard max_bytes cap (default 5 MiB). A non-streaming get would buffer the whole (model-controlled) URL into RAM and could OOM the process; streaming caps actual memory, not just parser input.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.
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.
mcp/client.pyMCPClient (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).
mcp/server.pyHFLMCPServer 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/).
hfl mcpThe mcp command in src/hfl/cli/main.py drives both halves:
| Command | Effect |
|---|---|
hfl mcp list | Print 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 stdio | Run HFL as an MCP server over stdio |
hfl mcp serve --transport sse --host … --port … --capabilities web_search,web_fetch | Run as an SSE server with a narrowed tool set |
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.
HFL implements a comprehensive legal compliance system documented with audit references (R1-R9):
Mandatory verification before download. Classification into 5 risk levels. Presentation to user with visual panel. Explicit acceptance required for non-permissive licenses. License metadata persisted in ModelManifest.
Immutable ProvenanceLog records each conversion: source, destination, tool, version, license, timestamp. Legal warning displayed during conversion: "the original license remains in effect".
Fields in ModelManifest for GPAI classification: gpai_classification ("gpai", "gpai-systemic", "exempt") and training_flops. File NOTICE-EU-AI-ACT.md.
HF token only in memory. Privacy-safe logging: NEVER logs prompts, AI outputs, tokens, User-Agent. Only metadata (method, path, status, duration). File PRIVACY.md.
Rate limiting (0.5s between API calls). Identifying User-Agent (hfl/0.15.0). Respect for gating system: does NOT bypass license acceptance. User must accept on huggingface.co first.
DisclaimerMiddleware adds X-AI-Disclaimer header to all AI endpoint responses. Disclaimer in CLI chat: "AI models may generate incorrect information..."
LICENSE | Apache-2.0 |
LICENSE-FAQ.md | Apache-2.0 license text |
PRIVACY.md | Privacy policy (no data collection) |
NOTICE-EU-AI-ACT.md | EU AI Act compliance |
DISCLAIMER.md | General liability disclaimer |
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.
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.
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().
ChatMessage, GenerationConfig, GenerationResult, ModelManifest, ResolvedModel, ConversionRecord, LicenseInfo — all are immutable or semi-immutable dataclasses encapsulating data.
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.
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.
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.
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.
Important architectural decisions are documented in docs/adr/ following the standard ADR format:
| ADR | Title | Status |
|---|---|---|
0001 | Singleton Pattern for Config and Registry | Accepted |
0002 | Async API with Sync Engines (sync_to_thread) | Accepted |
0003 | GGUF as Default Format | Accepted |
0004 | Ollama API Compatibility | Accepted |
0005 | License Classification (5 levels) | Accepted |
0006 | Rate Limiting Strategy | Accepted |
engine/dispatcher.py — InferenceDispatcher 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.
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.
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.
api/chat_core.py — resolve_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.
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.
| Extra | Pulls in | Purpose |
|---|---|---|
[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, soundfile | Coqui TTS engine (alongside the Bark-via-transformers [tts]). |
[audio] | sounddevice, soundfile | Local audio playback. |
[tray] | pystray, Pillow | System-tray GUI for serve --tray. |
[mcp] | mcp>=1.0.0 | Model Context Protocol client/server. |
[stt] | faster-whisper>=1.0.0,<2.0 | Speech-to-text (Whisper). |
[imagegen] | diffusers, torch, safetensors, Pillow, accelerate | SDXL / FLUX image generation (~2 GB of wheels). |
[otel] | opentelemetry api/sdk + OTLP-HTTP exporter | Distributed 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.
pytest + pytest-asyncio (asyncio_mode = "auto"). ~201 test files (incl. a tests/stress/ dir) with shared fixtures in conftest.py.
test_config.py | HFLConfig, paths, env vars |
test_container.py | DI container, Singleton pattern |
test_exceptions.py | Exception hierarchy |
test_events.py | EventBus, pub/sub |
test_metrics.py | Performance metrics |
test_validators.py | Data validation |
test_security.py | Security & sanitization |
test_api*.py (5) | Endpoints, auth, contracts |
test_routes_*.py (4) | OpenAI, Native, TTS |
test_state.py | ServerState, async locks |
test_streaming.py | SSE streaming |
test_model_loader.py | Dynamic model loading |
test_helpers.py | ensure_llm/tts_loaded |
test_middleware.py | Privacy logger, disclaimer |
test_engine*.py (2) | Base, LlamaCpp |
test_selector*.py (3) | Backend auto-selection |
test_transformers*.py (2) | TransformersEngine |
test_vllm_engine.py | vLLM with mocks |
test_tts_*.py (2) | Bark, Coqui TTS |
test_async_wrapper.py | Sync→Async wrapper |
test_model_pool.py | Model pool |
test_hub*.py (2) | HF Hub integration |
test_downloader*.py (2) | Download with resume |
test_resolver*.py (2) | Model resolution |
test_license_checker.py | License classification |
test_auth.py | HF authentication |
test_cli*.py (4) | Typer commands |
test_converter*.py (3) | GGUF conversion |
test_i18n*.py (4) | Internationalization |
test_circuit_breaker.py | Circuit breaker |
test_retry.py | Retry with backoff |
test_integration.py | Full end-to-end flows (pull→run→serve) |
test_concurrency.py | Concurrency, thread safety, race conditions |
test_network_errors.py | Timeouts, disconnects, retry logic |
test_edge_cases.py | Malformed inputs, boundary conditions |
test_server_lifecycle.py | Startup, shutdown, cleanup |
test_cli_signal.py | CLI signal handling (Ctrl+C graceful shutdown) |
test_middleware_order.py | Middleware execution order verification |
test_exception_handlers.py | HFLError → HTTP status code mapping |
test_health_probes.py | Health probe endpoints (/health/deep, /health/sli) |
test_config_env.py | Config env var override tests |
test_model_pool_wait.py | Model pool non-recursive waiting |
test_stress.py | Stress tests (concurrent streaming, model pool stress) |
test_timeout.py | Timeout decorator tests |
test_failover.py | Failover engine multi-backend retry |
test_rate_limit_per_model.py | Per-model rate limiting |
test_conversion_caching.py | Conversion 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
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.
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.
~/.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
| Workflow | Trigger | Description |
|---|---|---|
ci.yml | Manual (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.yml | Manual (workflow_dispatch) | Deploy HTML docs to GitHub Pages (manual trigger only) |
build-executables.yml | Release/manual | Cross-platform executable builds with PyInstaller |
lint.yml | Manual (workflow_dispatch) | Linting with ruff (E, F, W, I) |
test.yml | Manual (workflow_dispatch) | Tests with pytest + coverage |
security.yml | Manual (workflow_dispatch) | Dependency security audit |
license-check.yml | Manual (workflow_dispatch) | Dependency license verification |
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).
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.
| Workflow | Trigger | What it runs |
|---|---|---|
ci.yml | dispatch | Test matrix (ubuntu+macos × Py 3.10/3.11/3.12) with coverage, ruff lint+format, mypy on api/+cli/. |
test.yml / lint.yml | dispatch | Standalone pytest+coverage / ruff (legacy split jobs). |
security.yml | dispatch | Dependency security audit. |
license-check.yml | dispatch | Dependency license verification. |
publish-pypi.yml | dispatch | Build + twine check + OIDC publish to PyPI/TestPyPI + Sigstore. |
docker.yml / homebrew.yml | dispatch | Container images / Homebrew formula. |
macos-dmg.yml / windows-msi.yml / build-executables.yml | dispatch (+ release) | Native installers and PyInstaller bundles. |
pages.yml / release.yml | dispatch | Docs 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