HFL (HuggingFace Local) es una herramienta CLI y servidor API que permite descargar, gestionar y ejecutar modelos de inteligencia artificial de HuggingFace Hub directamente en la máquina local del usuario. Su diseño aspira a ser un drop-in replacement de Ollama, pero conectado al ecosistema de HuggingFace con más de 500.000 modelos.
Los usuarios necesitan ejecutar LLMs localmente sin depender de APIs en la nube. Ollama resuelve esto pero con un catálogo limitado. HFL conecta directamente con HuggingFace Hub, ofreciendo acceso al catálogo más grande de modelos open-weight del mundo, con descarga, conversión automática a GGUF y ejecución local.
Modularidad extrema con lazy imports, compatibilidad API dual (OpenAI + Ollama), cumplimiento legal riguroso (licencias, privacidad, EU AI Act), y soporte multi-backend (llama.cpp, Transformers, vLLM) con selección automática según el formato del modelo y el hardware disponible.
Python ≥3.10 | Lenguaje base |
typer ≥0.12 | Framework CLI |
rich ≥13.0 | Output con estilo |
pydantic ≥2.10 | Validación de datos API |
pyyaml ≥6.0 | Parsing de configuración |
fastapi ≥0.115 | Framework web async |
uvicorn ≥0.32 | Servidor ASGI |
sse-starlette ≥2.0 | Server-Sent Events |
httpx ≥0.28 | HTTP client async |
huggingface-hub ≥0.27 | API de HF Hub |
llama-cpp-python | Backend GGUF |
transformers+torch | Backend GPU nativo |
vllm | Backend producción |
gguf | Conversión de formato |
hfl/ ├── pyproject.toml — Definición del paquete, deps, scripts, herramientas ├── hfl.spec — PyInstaller spec para ejecutable standalone ├── LICENSE — Apache-2.0 ├── LICENSE-DEPENDENCIES.md — Licencias de todas las dependencias ├── PRIVACY.md — Política de privacidad ├── NOTICE-EU-AI-ACT.md — Cumplimiento EU AI Act ├── DISCLAIMER.md — Exención de responsabilidad ├── README.md — Documentación principal ├── README.es.md — Documentación principal (español) ├── LICENSE-FAQ.md — Texto de la licencia Apache-2.0 ├── CONTRIBUTING.md — Guía de contribución ├── CODE_OF_CONDUCT.md — Código de conducta (Contributor Covenant 2.1) ├── CHANGELOG.md — Historial de cambios (Keep a Changelog) ├── SECURITY.md — Política de seguridad │ ├── src/hfl/ — CÓDIGO FUENTE PRINCIPAL │ ├── __init__.py — Versión del paquete (0.14.0) │ ├── config.py — HFLConfig dataclass — configuración global │ ├── exceptions.py — Jerarquía completa de excepciones │ ├── events.py — EventBus para pub/sub interno │ ├── metrics.py — Métricas de rendimiento │ ├── plugins.py — Sistema de plugins con entry_points │ ├── security.py — Sanitización y validación de seguridad │ ├── validators.py — Validadores de datos comunes │ ├── logging_config.py — Configuración centralizada de logging │ │ │ ├── core/ — NÚCLEO DEL SISTEMA │ │ ├── __init__.py │ │ ├── container.py — Contenedor DI con Singleton thread-safe │ │ ├── observability_setup.py — Setup de listeners de observabilidad │ │ └── tracing.py — Request tracing con IDs │ │ │ ├── cli/ — INTERFAZ DE LÍNEA DE COMANDOS │ │ ├── __init__.py │ │ ├── main.py — 12 comandos: pull, run, serve, list, search, rm, inspect, alias, login, logout, version, compliance-report │ │ └── commands/ — Comandos modularizados │ │ └── _utils.py — Utilidades compartidas CLI │ │ │ ├── api/ — SERVIDOR REST API │ │ ├── __init__.py │ │ ├── server.py — FastAPI app, CORS, disclaimer, lifespan │ │ ├── state.py — ServerState con async locks para LLM/TTS │ │ ├── streaming.py — SSE streaming helpers │ │ ├── model_loader.py — Carga dinámica de modelos │ │ ├── helpers.py — ensure_llm_loaded, ensure_tts_loaded │ │ ├── errors.py — Manejo centralizado de errores HTTP │ │ ├── middleware.py — Logging privacy-safe │ │ ├── rate_limit.py — Rate limiting por 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 — Manejo global de excepciones HFLError │ │ └── timeout.py — Decorator @with_timeout + timeout configurable │ │ │ ├── engine/ — MOTORES DE INFERENCIA │ │ ├── __init__.py │ │ ├── base.py — InferenceEngine + AudioEngine ABCs + dataclasses │ │ ├── selector.py — Selección automática de backend LLM/TTS │ │ ├── llama_cpp.py — LlamaCppEngine (GGUF, CPU/GPU) │ │ ├── transformers_engine.py — TransformersEngine (safetensors, GPU) │ │ ├── vllm_engine.py — VLLMEngine (producción GPU) │ │ ├── bark_engine.py — BarkEngine (TTS via transformers) │ │ ├── coqui_engine.py — CoquiEngine (TTS XTTS-v2) │ │ ├── async_wrapper.py — Wrapper sync→async para engines │ │ ├── model_pool.py — Pool de modelos con LRU eviction │ │ ├── dependency_check.py — Verificación de dependencias opcionales │ │ ├── failover.py — FailoverEngine (multi-engine retry con sticky routing) │ │ ├── memory.py — Tracking de memoria RAM/GPU en tiempo real │ │ ├── observability.py — Métricas de rendimiento del engine │ │ └── prompt_builder.py — Formatos de prompt + escape de delimitadores │ │ │ ├── hub/ — INTEGRACIÓN HUGGINGFACE │ │ ├── __init__.py │ │ ├── auth.py — Autenticación y tokens │ │ ├── client.py — Cliente HTTP para HF Hub │ │ ├── downloader.py — Descarga con resume y rate limiting │ │ ├── license_checker.py — Clasificación de licencias (5 niveles) │ │ └── resolver.py — Resolución inteligente de modelos │ │ │ ├── models/ — MODELOS DE DATOS │ │ ├── __init__.py │ │ ├── manifest.py — ModelManifest — metadata completa │ │ ├── provenance.py — ConversionRecord + ProvenanceLog │ │ ├── registry.py — ModelRegistry — inventario local JSON │ │ └── backends/ — Backends de almacenamiento (File + SQLite) │ │ │ ├── converter/ — CONVERSIÓN DE FORMATOS │ │ ├── __init__.py │ │ ├── formats.py — ModelFormat + ModelType enums │ │ └── gguf_converter.py — GGUFConverter — conversión + cuantización │ │ │ ├── utils/ — UTILIDADES TRANSVERSALES │ │ ├── __init__.py │ │ ├── circuit_breaker.py — Circuit breaker para resiliencia │ │ └── retry.py — Retry con exponential backoff │ │ │ └── i18n/ — INTERNACIONALIZACIÓN │ ├── __init__.py — t(), get_language(), set_language() │ └── locales/ — Archivos de traducción │ ├── en.json — Traducciones en inglés │ └── es.json — Traducciones en español │ ├── docs/ — DOCUMENTACIÓN │ ├── 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 — Documentación arquitectura │ └── tests/ — SUITE DE TESTS (~200 archivos, ~89% cobertura) ├── conftest.py — Fixtures compartidas ├── test_api*.py — Tests API (5 archivos) ├── test_cli*.py — Tests CLI (4 archivos) ├── test_engine*.py — Tests engines (8 archivos) ├── test_hub*.py — Tests HF Hub (5 archivos) ├── test_i18n*.py — Tests i18n (4 archivos) └── test_*.py — Tests unitarios y de integración │ ├── .github/ — INFRAESTRUCTURA GITHUB │ ├── workflows/ │ │ ├── ci.yml — CI: lint + test + type-check (Python 3.10/3.11/3.12) │ │ ├── pages.yml — Deploy docs a GitHub Pages │ │ ├── build-executables.yml — Build ejecutables multiplataforma │ │ ├── lint.yml — Linting con ruff │ │ ├── test.yml — Tests con pytest │ │ ├── security.yml — Auditoría de seguridad │ │ └── license-check.yml — Verificación de licencias │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml — Template para reportes de bugs │ │ └── feature_request.yml — Template para solicitudes de features │ └── PULL_REQUEST_TEMPLATE.md — Template de PR con checklist de compliance
Archivo: src/hfl/config.py
Contiene la clase HFLConfig (dataclass) que define toda la configuración global de la aplicación. Se instancia una vez como singleton (config = HFLConfig()) al importar el módulo, y se llama a ensure_dirs() para crear la estructura de directorios.
| Propiedad | Tipo | Default | Descripción |
|---|---|---|---|
home_dir | Path | ~/.hfl | Directorio raíz. Override con HFL_HOME env var |
models_dir | Path (prop) | ~/.hfl/models | Almacenamiento de modelos descargados |
cache_dir | Path (prop) | ~/.hfl/cache | Caché temporal de HuggingFace |
registry_path | Path (prop) | ~/.hfl/models.json | Registro de modelos (inventario local) |
llama_cpp_dir | Path (prop) | ~/.hfl/tools/llama.cpp | Herramientas de conversión compiladas |
host | str | 127.0.0.1 | Dirección del servidor API. Override con HFL_HOST |
port | int | 11434 | Puerto (igual que Ollama para compatibilidad). Override con HFL_PORT |
default_ctx_size | int | 0 (auto) | Tokens de contexto por defecto (0 = auto-detecta; vía HFL_DEFAULT_CTX_SIZE) |
default_n_gpu_layers | int | -1 | Capas GPU (-1 = todas) |
hf_token | str|None | env HF_TOKEN | Token de autenticación (solo memoria, nunca persiste) |
rate_limit_enabled | bool | true | Habilitar rate limiting. Override con HFL_RATE_LIMIT_ENABLED |
rate_limit_requests | int | 60 | Peticiones permitidas por ventana. Override con HFL_RATE_LIMIT_REQUESTS |
rate_limit_window | int | 60 | Ventana de tiempo en segundos. Override con HFL_RATE_LIMIT_WINDOW |
Adicionalmente, SLOConfig define los Service Level Objectives del servidor: availability target, latencia P50/P95/P99.
hf_token se lee SOLO de la variable de entorno. Nunca se persiste a disco, nunca se guarda en models.json ni en ningún archivo de configuración. Existe solo en memoria durante la ejecución del proceso.
Regla de resolución en cada fila: nombre específico HFL → alias HFL → equivalente Ollama → valor por defecto. Así, un host que ya exporta variables Ollama funciona como reemplazo drop-in. Un valor inválido se registra una vez y se ignora — el arranque nunca falla por mala configuración del operador. Referencia completa: docs/env-vars.md.
| HFL | Alias Ollama | Defecto | Qué hace |
|---|---|---|---|
OLLAMA_HOST / OLLAMA_PORT | (nativo) | — | Aceptados para host/port: OLLAMA_HOST parsea host, host:port o :port (_parse_ollama_host_env). |
HFL_LLM_LIBRARY | OLLAMA_LLM_LIBRARY | (auto) | Fija el backend: llama-cpp, transformers, vllm, mlx. El backend= por llamada sigue ganando. |
HFL_FLASH_ATTENTION | OLLAMA_FLASH_ATTENTION | (auto) | Activa flash-attention en toda la flota; la lista de seguridad por arquitectura sigue rechazando arquitecturas inseguras. |
HFL_DISABLE_MLX | — | 0 | Fuerza la ruta Metal de llama-cpp en Apple Silicon (benchmarking). |
HFL_KV_CACHE_TYPE | OLLAMA_KV_CACHE_TYPE | f16 | dtype de caché KV: f16 / q8_0 / q4_0 (reduce VRAM a la mitad / un cuarto). |
HFL_NUM_PARALLEL / HFL_QUEUE_MAX_INFLIGHT | OLLAMA_NUM_PARALLEL | 1 | Ranuras de inferencia concurrentes en el dispatcher. |
HFL_MAX_QUEUE / HFL_QUEUE_MAX_SIZE | OLLAMA_MAX_QUEUE | 16 | Profundidad de la cola de espera; al superarla las peticiones reciben 429. |
HFL_QUEUE_ACQUIRE_TIMEOUT | — | 60 | Segundos que un llamante espera una ranura antes de 503. |
HFL_KEEP_ALIVE | OLLAMA_KEEP_ALIVE | 5m | Keep-alive por defecto cuando la petición omite el campo (gramática de duración Ollama; -1=nunca). |
HFL_ORIGINS | OLLAMA_ORIGINS | (mismo origen) | Lista CORS separada por comas; * activa el modo comodín (rechaza credenciales). |
HFL_DEBUG | OLLAMA_DEBUG | (off) | Un valor truthy fuerza el logger raíz hfl a DEBUG. |
HFL_MAX_REQUEST_BYTES | — | 10 MiB | Límite del cuerpo de la petición (0 lo desactiva). Las subidas de blobs usan HFL_MAX_BLOB_BYTES (por defecto ilimitado). |
HFL_STREAM_QUEUE_PUT/GET_TIMEOUT | — | 60/30 | Timeouts de contrapresión del streaming (encolado del motor / espera del consumidor). |
HFL_REGISTRY_SQLITE_TIMEOUT | — | 30 | Busy-timeout en segundos del registro SQLite. |
OTEL_EXPORTER_OTLP_ENDPOINT | — | (off) | Variable estándar de OpenTelemetry; HFL emite spans cuando está activa (extra [otel]). |
HFLConfig.__post_init__ rechaza la combinación origen-comodín + allow_credentials en el momento de la construcción, de modo que la mala configuración aflora al arrancar y no tras horas de CORS roto.
Archivo: src/hfl/cli/main.py (~2.350 líneas)
Framework: Typer + Rich. Entry point registrado en pyproject.toml como hfl = "hfl.cli.main:app"
| Comando | Descripción | Opciones Clave |
|---|---|---|
hfl pull <modelo> | Descarga modelo desde HF Hub | --quantize Q4_K_M, --format auto|gguf|safetensors, --alias, --skip-license |
hfl run <modelo> | Chat interactivo en terminal | --backend auto|llama-cpp|transformers|vllm, --ctx, --system, --verbose |
hfl serve | Servidor API REST | --host, --port, --model (pre-carga), --api-key (autenticación) |
hfl list | Lista modelos locales con tabla Rich | Muestra nombre, alias, formato, cuantización, licencia (coloreada por riesgo), tamaño |
hfl search <query> | Búsqueda paginada interactiva en HF Hub | --gguf, --max-params, --min-params, --sort, --page-size |
hfl rm <modelo> | Elimina modelo con confirmación | Borra archivos + entrada del registro |
hfl inspect <modelo> | Detalle completo (panel Rich) | Muestra metadata, licencia, restricciones, timestamps |
hfl alias <modelo> <alias> | Asigna alias corto | Permite referir modelos por nombres simples |
hfl login | Configura token HF | --token o interactivo. Verifica con whoami() |
hfl logout | Elimina token guardado | Usa huggingface_hub.logout() |
hfl version | Muestra versión + licencia | — |
hfl compliance-report | Informe de cumplimiento legal (JSON/Markdown) | — |
run maneja Ctrl+C durante el streaming de tokens de forma limpia, preservando la respuesta parcial.
_format_size() convierte bytes a formato legible. _get_key() lee una tecla sin Enter (raw terminal). _extract_params_from_name() extrae parámetros del nombre (regex: "70b", "7b", "1.5b"). _estimate_model_size() estima tamaño en disco según parámetros y cuantización. _display_model_row() renderiza una fila de resultado de búsqueda. _get_params_value() extrae el valor numérico para filtrado.
El fichero creció a ~2.350 líneas en src/hfl/cli/main.py. Se añadieron dos familias: comandos de paridad Ollama (drop-in) y los comandos V4 de descubrimiento/operación. Los imports perezosos mantienen un arranque barato; la mayoría de los comandos V4 ejecutan internamente una corrutina con asyncio.run().
| Comando | Descripción | Opciones clave |
|---|---|---|
hfl cp <src> <dst> | Clon de registro sin copia apuntando al mismo blob (compatible Ollama) | — |
hfl stop [model] | Descarga un modelo (o todos) en un servidor activo vía POST /api/stop | --host, --port |
hfl show <model> | Resumen estilo Ollama: arquitectura, parámetros, capacidades, licencia | --modelfile, --parameters, --template, --license |
hfl ps | Lista modelos residentes en memoria vía /api/ps (NAME/ID/SIZE/PROCESSOR/UNTIL) | --host, --port |
hfl create <model> | Crea un modelo desde un Modelfile; transmite NDJSON desde POST /api/create | --file, --host, --port |
hfl mcp <action> | Model Context Protocol: connect / disconnect / list / serve (stdio o sse) | --transport, --host, --port, --capabilities |
hfl doctor | Diagnostica aceleradores (NVIDIA/Metal/ROCm), extras instalados, VRAM + num_ctx recomendado | — |
hfl check | Disponibilidad de backends (llama-cpp / transformers / vllm / mlx), GPU, deps TTS | — |
hfl debug | Volcado de sistema / versiones de dependencias / GPU / memoria para informes de bug | — |
hfl discover [query] | Filtra el catálogo en vivo del HF Hub por capacidad + popularidad (caché en disco 5 min) | --family, --task, --quant, --multimodal, --min-likes, --license, --gated/--open, --refresh |
hfl recommend | Top-N consciente del hardware (perfil RAM/VRAM/MLX + score de capacidad) | --task, --family, --quant, --top |
hfl pull-smart <model> | Descarga la variante óptima del Hub para este hardware (MLX 4-bit / quant GGUF / fallback CPU) | --max-vram-gb |
hfl verify <model> | Chequeo de 5 sondas: round-trip de tokenizer, chat-template, generación de humo, tool-parser, dim de embedding | — |
hfl bench <model> | Benchmark de TTFT + tok/s con resumen p50/p95 | --runs, --max-tokens, --lengths |
hfl snapshot <action> | Snapshot de caché KV: save / load / list / delete (omite prefill al reiniciar) | --name |
hfl lora <action> | Hot-swap de adaptadores LoRA en un modelo cargado (apply / remove / list) | --path, --id, --scale, --name |
hfl draft-recommend <model> | Elige un modelo borrador para decodificación especulativa | --max-ratio |
hfl compliance-dashboard | Panorama de riesgo de licencia / EU AI Act del registro local de un vistazo | — |
El comando serve también ganó --tray/--gui (bandeja del sistema, ver la sección tray), --log-level, --json-logs y --ctx.
compliance-report aún codifica fijo "hfl_version": "0.1.0" en su salida JSON/Markdown en vez de leer hfl.__version__ (ahora 0.14.0).
Clase ResolvedModel (dataclass) con: repo_id, revision, filename, format, quantization.
La función resolve() soporta tres formatos de entrada:
1. org/modelo → repo directo en HF
2. org/modelo:Q4_K_M → repo con cuantización estilo Ollama
3. nombre-modelo → búsqueda por nombre (top 5 por descargas)
Tras resolver, detecta si el repo tiene archivos GGUF (prefiere _select_gguf() con prioridad Q4_K_M > Q5_K_M > Q4_K_S), safetensors, o pytorch.
Función principal pull_model(resolved). Para GGUF descarga archivo individual con hf_hub_download(). Para safetensors descarga snapshot completo con snapshot_download() filtrando: *.safetensors, config.json, tokenizer*.json, tokenizer.model.
Implementa rate limiting (0.5s entre llamadas API) y User-Agent identificativo (hfl/0.14.0) para cumplir con ToS de HuggingFace.
get_hf_token() obtiene token con prioridad: 1) env var HF_TOKEN, 2) token guardado por huggingface_hub.
ensure_auth(repo_id) verifica acceso al repo. Si falla y no hay token, solicita interactivamente. Respeta el sistema de gating de HF: NO bypasea la aceptación de licencias de modelos gated.
Enum LicenseRisk: PERMISSIVE, CONDITIONAL, NON_COMMERCIAL, RESTRICTED, UNKNOWN.
Diccionario LICENSE_CLASSIFICATION con ~20 licencias conocidas. Diccionario LICENSE_RESTRICTIONS con restricciones específicas por familia (Llama: 700M MAU, attribution, etc.).
check_model_license() consulta la API de HF, clasifica el riesgo, y devuelve LicenseInfo. require_user_acceptance() presenta un panel Rich con la licencia y requiere confirmación explícita para licencias no permisivas.
Más allá del pull básico, la capa Hub expone ahora el catálogo completo de HuggingFace (1,5M+ modelos con metadatos estructurados) como una superficie consultable y consciente del hardware. Cuatro endpoints de lectura/escritura se apoyan en módulos de planificación puros y sin red, de modo que las heurísticas son testeables sin acceso a internet.
Respaldado por hfl/hub/discovery.py. search_hub() consulta HfApi.list_models (sobre-pidiendo page_size × 3, ordenado por descargas) y remodela cada ModelInfo en un DiscoveryEntry tipado: familia (llama/qwen/gemma/mistral/mixtral/phi/yi/falcon/deepseek/command-r), cuantización (gguf/awq/gptq/mlx/exl2/fp8/int4/int8), bandera multimodal, y un parameter_estimate_b de mejor esfuerzo extraído del repo id. Los post-filtros propios de HFL (likes, descargas, familia, cuant, gated, licencia) se aplican en Python porque la gramática de filtros del Hub no puede expresarlos.
Respaldado por hfl/hub/recommend.py. recommend_models() combina cuatro puntuaciones deterministas — hardware_fit 0.45, capability_fit 0.30, popularity 0.20, recency 0.05 — sobre una consulta de descubrimiento (página 60, min_likes 10). Los candidatos que desbordan el presupuesto de memoria puntúan 0 y se descartan antes de ordenar. La respuesta HTTP siempre incluye el hardware_profile para que el cliente explique las elecciones.
Respaldado por hfl/hub/smart_pull.py. build_smart_plan() resuelve un repo base a la mejor variante disponible para el host, probando forks comunitarios en orden, intersectando la escalera de cuants GGUF con los ficheros realmente publicados, y devolviendo el primer (repo, quant) que cabe en el presupuesto como un SmartPullPlan. La ruta emite un preámbulo NDJSON planning → planned y luego delega la transferencia de bytes en la maquinaria de pull existente vía iter_pull_events().
Respaldado por hfl/hub/uploader.py + routes_push.py. build_upload_plan() (puro) resuelve un manifest local a un UploadPlan; stream_push() orquesta HfApi.create_repo(exist_ok=True) + upload_folder y emite NDJSON espejo de /api/pull. Los clientes Ollama pueden pasar name como alias de destination.
hfl/hub/hw_profile.py construye un HardwareProfile barato y solo de metadatos (sin trabajo de GPU). Presupuestos de memoria usados tanto por recommend como por smart_pull:
| Host | Detección | Presupuesto |
|---|---|---|
| CUDA | torch.cuda.is_available(); mayor dispositivo visible vía get_device_properties | VRAM completa de la GPU |
| Apple Silicon (Metal) | Darwin + arm64 + mlx_lm importable (has_mlx) | 70% de la RAM unificada |
| ROCm | env HIP_VISIBLE_DEVICES / ROCM_PATH | no sondeado (None) |
| Solo CPU / desconocido | caso por defecto | 70% de la RAM del sistema (psutil) |
hfl/hub/quant_table.py convierte (params_b, quantization) en un FitEstimate conservador = (pesos + KV cache de contexto 4k + 1,0 GB de overhead) × 1,2 de seguridad. Los bits-por-peso están tabulados por cuant (q4_k_m→4,85, q8_0→8,5, mlx-4bit→4,5, …); los recuentos de capas y dimensiones ocultas del KV se agrupan por tamaño, con Qwen 3, Gemma 2 y Mixtral-8x7B (efectivo) añadidos para cerrar la brecha original solo-Llama.
_candidate_repos() genera, de más específico a menos:
Las patas MLX solo aparecen cuando has_mlx es cierto. La escalera de cuants es ["mlx-8bit","mlx-4bit","q5_k_m","q4_k_m"] en Apple Silicon, si no ["q5_k_m","q4_k_m","q4_0","q3_k_m"]; los intentos rechazados se registran en fallback_chain y se muestran tal cual. Cuando nada cabe, build_smart_plan() lanza ValueError (mapeado a un 400 con una pista "prueba --max-vram-gb o un repo más pequeño").
# POST /api/pull/smart — Apple Silicon, 16 GB unificados
{"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) indexa por la consulta serializada a JSON, TTL 300s, acotada a las 32 claves más recientes, con escrituras atómicas a fichero temporal y reseteo ante corrupción — existe para esquivar los 429 no autenticados del Hub en llamadas CLI encadenadas. En el lado del push, redact_secrets() elimina cualquier cosa que coincida con hf_[A-Za-z0-9]{20,} tanto de los logs como de los eventos de error visibles al cliente, y la subida se restringe a allow_patterns = exactamente los ficheros planificados — crítico porque un modelo respaldado por blob (FROM sha256:<digest>) tiene su local_dir apuntando al almacén compartido blobs/, así que un upload_folder sin restringir exfiltraría los bytes de todos los demás modelos.
Dataclass que almacena la metadata completa de cada modelo descargado. Es la unidad fundamental de información en el sistema.
| Campo | Tipo | Propósito |
|---|---|---|
name, repo_id | str | Identificación (nombre corto + repo HF) |
alias | str|None | Nombre personalizado por el usuario |
local_path, format | str | Ubicación y tipo (gguf/safetensors/pytorch) |
size_bytes, quantization | int, str | Tamaño en disco + nivel Q |
architecture, parameters, context_length | str, str, int | Características del modelo |
license, license_name, license_url | str | Información legal (R1) |
license_restrictions, gated, license_accepted_at | list, bool, str | Restricciones y aceptación |
gpai_classification, training_flops | str | EU AI Act (R4) |
created_at, last_used | str | Timestamps |
Gestiona el inventario local. Persiste a ~/.hfl/models.json como array JSON. Operaciones: add() (evita duplicados), get() (busca por name, alias, o repo_id), set_alias(), list_all() (ordenado por fecha), remove().
Log inmutable de conversiones en ~/.hfl/provenance.json. Cada ConversionRecord documenta: origen (repo, formato, revisión), destino (formato, path, cuantización), herramienta usada (llama.cpp + versión), licencia original, y timestamps. Sirve para trazabilidad legal y auditoría de cumplimiento (R3).
La capa de modelos se endureció y amplió notablemente. El registro en disco ganó seguridad ante concurrencia; el manifest absorbió campos derivados del Modelfile y legales; y un módulo dedicado computa ahora las capacidades estilo Ollama.
hfl/models/registry.py es ahora seguro entre hilos (RLock) y entre procesos vía bloqueo de fichero multiplataforma (fcntl / msvcrt). Mantiene tres índices dict O(1) (by_name, by_alias, by_repo_id), hace guardados atómicos a fichero temporal con copia de seguridad .json.bak, se recupera de un fichero corrupto restaurando la copia (emitiendo un evento ERROR), y añade copy() (paridad con /api/copy de Ollama — duplica el manifest, comparte el blob en disco), repair(), validate_integrity() y set_alias(). El singleton se obtiene a través de hfl.core.container, no de un global de módulo.
hfl/models/manifest.py lleva mucho más que nombre/ruta/formato: bloque de licencia (id SPDX, nombre, url, restricciones, gated, license_accepted_at), campos del Reglamento de IA de la UE (gpai_classification, training_flops), integridad (file_hash + verify_integrity() / update_hash()), y campos derivados del Modelfile: system, default_parameters, adapter_paths, messages, parent_name/parent_digest (fijados por POST /api/create), además de env_vars y declared_capabilities.
Nuevo módulo hfl/models/capabilities.py. detect_capabilities() devuelve la lista que POST /api/show emite, que los clientes del SDK de Ollama (ollama-python, Open WebUI, LangChain) usan para habilitar tool-calling, visión, embeddings y razonamiento. La detección es coincidencia permisiva de subcadenas sobre name + repo_id + architecture:
| Capacidad | Disparador |
|---|---|
completion | cualquier modelo cuyo model_type no sea tts/stt/audio |
tools | qwen, llama/llama3, mistral, mixtral, gemma4 (sincronizado con 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 — descarta completion |
thinking | gemma-4, deepseek-r1, qwen3-thinking, gpt-oss, o1/o3 |
El orden es determinista: completion/embedding primero, el resto alfabético.
hfl/models/provenance.py registra la cadena origen→conversión→resultado (~/.hfl/provenance.json) para la salvaguarda de auditoría legal: repo/formato/revisión de origen, ruta destino, cuantización, herramienta + versión, licencia original y marca de tiempo de aceptación. log_conversion() es el punto de entrada de conveniencia, llamado desde el conversor GGUF tras una cuantización exitosa.
Backend principal. Usa llama-cpp-python. Parámetros: n_ctx (con tope por arquitectura, p.ej. Gemma 3/4 → 8192), n_gpu_layers (-1=todas), n_threads (0=auto), flash_attn (consciente de la arquitectura, desactivado en arquitecturas inseguras conocidas como gemma4 salvo que se fuerce), chat_format (auto-detect), kv_cache_type (q4_0/q8_0/f16), lora_paths y draft_model_path (decodificación especulativa). Incluye supresión de stderr para silenciar logs de Metal/CUDA cuando verbose=False. Genera resultados con métricas: tokens/s, conteos de tokens prompt/eval y duraciones en nanosegundos, stop reason.
Usa modelos en formato nativo con GPU. Soporte cuantización dinámica: 4bit (NF4 double quant via BitsAndBytes) y 8bit. Streaming via TextIteratorStreamer en thread separado. Usa apply_chat_template() del tokenizer o fallback Llama-style.
EXPERIMENTAL Backend para producción GPU. Wrappea vllm.LLM con SamplingParams. Streaming real con AsyncLLMEngine, con fallback sincrono para compatibilidad. Requiere GPU NVIDIA con CUDA.
Motor con multiple backends y sticky routing — reintenta automaticamente con el siguiente engine disponible si uno falla.
Pool de modelos con espera no recursiva (bounded polling), evitando stack overflow con multiples cargas concurrentes. Tracking de memoria RAM y GPU en tiempo real mediante psutil y GPUtil.
Lógica de decisión en select_engine(model_path, backend):
Todos los imports son lazy (_get_llama_cpp_engine(), etc.) para no requerir todas las dependencias instaladas.
mlx_engine.MLXEngine es una cuarta implementación de InferenceEngine que envuelve mlx-lm de Apple y accede a Metal directamente. El docstring del módulo afirma una ventaja de 3-10% en procesado de prompt y 15-25% en decode a fp16 frente a la ruta Metal de llama-cpp para arquitecturas de la familia Llama, más precisión mixta nativa q4/q5/q8 sin conversión a GGUF. La dependencia vive tras el extra [mlx].
is_available() devuelve True solo cuando platform.system() == "Darwin", platform.machine() es arm64/aarch64 y mlx_lm importa. En cualquier otra plataforma el import es un no-op, así que un hfl[mlx] perdido en un contenedor Linux falla rápido en vez de crashear.
_build_sampling() traduce GenerationConfig a la API posterior a 0.30: un callable make_sampler() (temp / top_p / top_k) más make_logits_processors() para la penalización de repetición, inyectados en generate() / stream_generate().
mlx-lm no expone seed por llamada ni soporte de stop-strings, así que el engine cubre ambos huecos: _maybe_seed() siembra el RNG global de mlx (mx.random.seed) cuando cfg.seed >= 0 (paridad ENG-10/11 con vLLM/diffusers), y _stop_strings() + _earliest_stop() truncan la salida en el primer stop string del llamante. En generate_stream() la aplicación de stops es un filtro a nivel de carácter con buffer que retiene una cola (max_stop) que podría iniciar un marcador de stop para que la salida nunca lo sobrepase. Los conteos de tokens para el envelope de respuesta se recalculan con el tokenizer porque mlx-lm solo devuelve el texto de la completion.
select_engine() en selector.py incorpora una rama para Apple Silicon y un override a nivel de operador que el flujo antiguo no refleja.
| Control | Efecto |
|---|---|
HFL_DISABLE_MLX=1 | _mlx_preferred() devuelve False — fuerza la ruta llama-cpp clásica en Apple Silicon (paridad para benchmarking). |
HFL_LLM_LIBRARY / OLLAMA_LLM_LIBRARY | Leídos por _resolve_forced_backend(). Acepta llama-cpp, transformers, vllm, mlx; solo se aplica si el llamante pasó backend="auto" (una petición explícita por modelo siempre gana). Los valores no reconocidos registran un warning y vuelven a auto. |
GGUF permanece en llama-cpp en todos los casos — MLX no ingiere GGUF. _create_engine("mlx") conecta la petición explícita --backend mlx.
La mayoría de servidores reprocesan el prompt de sistema / prefijo few-shot en cada conversación nueva. snapshot.py expone Llama.save_state() / load_state() de llama-cpp-python como función de servidor: un operador puede capturar todo el KV-cache (tokens procesados + tensores) y restaurarlo para TTFT cero sobre el prefijo cacheado, incluso entre reinicios. Respalda POST /api/snapshot/save, /load, GET /api/snapshot y DELETE /api/snapshot/{name}.
def save_snapshot(engine, *, name, model_name) -> SnapshotMeta: ...
def load_snapshot(engine, *, name, model_name) -> SnapshotMeta: ...
El estado es un pickle de save_state() más un sidecar <name>.meta.json (model, tokens, created_at, bytes, version, mac) bajo HFL_HOME/snapshots/. Propiedades de seguridad del módulo:
pickle.loads es RCE (CWE-502), así que cada blob se autentica con HMAC-SHA256 con una clave por instalación (HFL_HOME/snapshot.key, 32 bytes aleatorios, 0600) y se verifica con hmac.compare_digest antes de despicklear. Un blob manipulado/ajeno lanza SnapshotIntegrityError. Los snapshots son una caché de la misma máquina, no portables.
La carga comprueba que el model del sidecar coincide con model_name (tensores del modelo equivocado corromperían memoria) y rechaza un SNAPSHOT_FORMAT_VERSION ajeno con SnapshotVersionMismatch.
El .state se escribe en un hermano .tmp y se hace os.replace para que un fallo no deje un blob truncado. _validate_name() rechaza separadores de ruta / .. / unicode extraño.
save_snapshot/load_snapshot resuelven save_state/load_state en el engine directamente o en su _model interno; los engines sin esa API lanzan (los snapshots de KV son exclusivos de llama-cpp-python).
El backend llama-cpp puede ejecutar un modelo borrador que propone tokens que el modelo objetivo verifica en una sola pasada. Se elige al cargar mediante el kwarg draft_model_path (proveniente de un Modelfile); GenerationConfig también lleva un campo draft_model. Dos modos comparten el kwarg:
draft_model_path="prompt-lookup" usa LlamaPromptLookupDecoding de llama-cpp-python (num_pred_tokens=10, max_ngram_size=2). Cero VRAM, un speedup documentado de 1.3-2× en prompts repetitivos (RAG, código, salida estructurada). Si no está disponible, lo registra y continúa sin especulación.
draft_model_path="<draft.gguf>" carga un segundo Llama pequeño y lo enruta por _LlamaModelDraftAdapter. Solo es seguro con un borrador de la misma familia de tokenizer (p.ej. Qwen3-14B ↔ Qwen3-0.6B). Un fallo al cargar el borrador se registra y vuelve a sin especulación.
_LlamaModelDraftAdapter adapta un Llama normal al protocolo LlamaDraftModel (un Llama desnudo devuelve un dict de completion, no un array de token-ids). Es deliberadamente incremental: _align_and_eval_suffix() halla el prefijo común más largo con los ids ya procesados y evalúa solo el sufijo, preservando el KV-cache del borrador entre pasos. Un reset ingenuo por llamada pagaría el prefill completo N veces y haría la especulación 2-3× más lenta; ante divergencia (petición nueva / predicciones rechazadas) resetea y reproduce. El Llama borrador se guarda en self._draft_model y se libera en unload() para no duplicar VRAM en una carga posterior.
lora.py aplica / elimina adaptadores LoRA sobre un modelo en ejecución sin recargar los pesos base, respaldando POST /api/lora/apply, /remove, GET /api/lora y /api/lora/{model}. La superficie pública:
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]
Un singleton de proceso protegido por lock (get_registry()) mapea adapter_id → AdapterInfo (path, name, scale, engine_id). Se particiona por _engine_id() = engine-{id(engine)} para que varios modelos cargados tengan cada uno su conjunto de adaptadores. Varios adaptadores se apilan a scale fraccional (p.ej. 0.7 + 0.3); scale se valida a [0.0, 5.0].
_set_lora() prueba primero engine.apply_lora, luego el _model.set_lora_adapter interno (llama-cpp-python ≥ 0.3) y el antiguo apply_lora_from_file. _unset_lora() lo refleja con remove_lora_adapter / unload_lora; sin API → RuntimeError (se expone como 503).
remove_lora desacopla del engine vivo primero y solo borra la entrada del registro si tiene éxito — si el engine rechaza el unset, la entrada sigue registrada para que el reintento del cliente funcione, en vez de dejar un adaptador fantasma mezclado en cada generación pero imposible de quitar. Al cargar, LlamaCppEngine.load() también acepta una lista lora_paths pero solo conecta el primero al único kwarg lora_path de llama-cpp, registrando que los extras se ignoran (el apilado se hace por la vía multi-LoRA post-carga).
El doc lista flash_attn como parámetro de carga; el backend ahora lo resuelve con una precedencia de tres niveles en LlamaCppEngine.load(), porque la ruta flash-attn de llama-cpp-python ha sido históricamente propensa a crashear en arquitecturas nuevas:
| # | Origen | Comportamiento |
|---|---|---|
| 1 | kwarg flash_attn= por carga | El valor explícito (p.ej. de un Modelfile) gana de forma absoluta. |
| 2 | HFL_FLASH_ATTENTION / OLLAMA_FLASH_ATTENTION | Falsy (0/false/...) lo desactiva globalmente. Truthy sigue respetando la lista de seguridad por arquitectura — los operadores no reciben crashes "gratis" en arquitecturas malas conocidas. |
| 3 | default por arquitectura | False para las de _ARCHITECTURE_NO_FLASH_ATTN (hoy {"gemma4"}), True en caso contrario. |
El valor resuelto se pasa al constructor de Llama junto al tope de contexto por arquitectura (_ARCHITECTURE_CTX_CAP, p.ej. Gemma 3/4 limitado a 8192 para no fijar memoria unificada en Apple Silicon) y la cuantización opcional del KV-cache (kv_cache_type → type_k/type_v q4_0/q8_0/f16).
HFL no tiene un pin()/unpin() literal en el engine; la residencia de un modelo la gobiernan el ModelPool (model_pool.py) y los deadlines de keep-alive, así que el "pinning" es una propiedad emergente, no una llamada de API. El pool mantiene varios engines cargados y los desaloja por tres señales: LRU (capacidad), timeout de inactividad (default idle_timeout_seconds=3600) y presión de memoria (estimaciones). Un bucle en background (background_eviction_interval=60s) barre modelos ociosos vía start_background_eviction().
El apply_keep_alive() de la capa de rutas traduce el keep_alive de Ollama a un deadline en ServerState. keep_alive=-1 ("nunca expira") borra el deadline, dejando el modelo efectivamente fijado en residencia; una duración positiva fija una expiración; 0 descarga tras la respuesta (unload_after_response()). keep_alive_default en config siembra la base.
_evict_over_capacity_locked(keep=...) se ejecuta tras una carga y nunca desaloja el modelo recién insertado (fijado al fondo del LRU con move_to_end), cerrando una ventana donde la carga concurrente de otro modelo podría desalojar al recién cargado contra una vista inconsistente de _models/_loading (parte de los arreglos UAF de ciclo de vida de modelo de la Fase 0).
Más allá de los tres backends de texto, engine/ incluye engines de audio, difusión y embeddings que la sección original nunca mencionó. Se cargan de forma lazy tras sus propios extras.
| Engine | Fichero / clase | Rol |
|---|---|---|
| Bark TTS | bark_engine.py · BarkEngine(AudioEngine) | Texto a voz vía transformers; synthesize() / synthesize_stream(). |
| Coqui TTS | coqui_engine.py · CoquiEngine(AudioEngine) | TTS de la familia XTTS/VITS/Tacotron. |
| Whisper STT | whisper_engine.py · WhisperEngine | Voz a texto; transcribe() devuelve WhisperResult/WhisperSegment. |
| Difusión | diffusers_engine.py · DiffusersEngine | Texto a imagen (generate() → ImageResult). Deliberadamente no implementa InferenceEngine. |
| Embeddings | embedding_engine.py · LlamaCppEmbeddingEngine, TransformersEmbeddingEngine | Embeddings de texto/imagen (EmbeddingEngine ABC + helpers de pooling). |
El módulo base define la ABC paralela AudioEngine (TTSConfig / AudioResult), y select_tts_engine() en selector.py enruta automáticamente un modelo TTS a Bark o Coqui (sniffing por nombre/arquitectura en config.json, Bark como default de pipeline transformers).
El paquete hfl.core es la capa de infraestructura transversal añadida tras el congelado de la v0.1.0. Contiene el Container de inyección de dependencias, el trazado de peticiones, el sandboxing opcional del proceso, el puente eventos→métricas y la persistencia de sesiones de chat. Su acompañante más importante es el InferenceDispatcher — la primitiva de concurrencia acotada que serializa cada llamada de inferencia contra el modelo compartido no reentrante.
Dónde vive. La clase del dispatcher se define en hfl/engine/dispatcher.py, pero se cablea como singleton dentro del contenedor del core y siempre se obtiene mediante hfl.core.get_dispatcher(). Forma parte del relato de concurrencia del core.
Los backends llama.cpp y transformers-GPU comparten una sola instancia de modelo no reentrante. Dos peticiones solapadas corrompen mutuamente su caché KV y producen respuestas vacías o truncadas. El dispatcher es por tanto un Bulkhead: limita cuántas inferencias corren a la vez y cuántas pueden esperar, descartando el resto con backpressure en vez de dejar que la carga funda el modelo.
InferenceDispatcher (hfl/engine/dispatcher.py) es async-aware y se apoya en un asyncio.Semaphore cuya capacidad sigue exactamente a max_inflight. Todos los contadores mutan bajo un único _counter_lock para que las decisiones de la ruta rápida y la lenta observen una vista consistente.
| Parámetro | Por defecto | Config / env | Significado |
|---|---|---|---|
max_inflight | 1 | queue_max_inflight ← HFL_QUEUE_MAX_INFLIGHT / HFL_NUM_PARALLEL / OLLAMA_NUM_PARALLEL | Cuántas peticiones pueden ejecutarse a la vez. El valor 1 preserva el comportamiento single-flight de V1. |
max_queued | 16 | queue_max_size ← HFL_QUEUE_MAX_SIZE / HFL_MAX_QUEUE / OLLAMA_MAX_QUEUE | Cuántas más pueden esperar en cola. Si está llena, los nuevos llamantes se rechazan de inmediato. |
acquire_timeout | 60 s | queue_acquire_timeout_seconds ← HFL_QUEUE_ACQUIRE_TIMEOUT | Tope de cuánto espera un llamante en cola por un slot antes de rendirse. |
La factoría build_default_dispatcher() lee estos valores de hfl.config; el _create_dispatcher() del contenedor lo envuelve en el singleton perezoso.
El gestor de contexto asíncrono slot() ejecuta un protocolo de cuatro fases. Bajo el counter lock decide entre la ruta rápida (capacidad ahora) y la lenta (hay que esperar); una cola de espera llena se rechaza antes de encolar, así el cliente recibe un 429 inmediato en vez de bloquearse.
async with self._counter_lock:
if self._in_flight < self._max_inflight:
self._in_flight += 1 # ruta rápida: reserva un 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 # ruta lenta: encola y espera
fast_path = False
La fase 2 adquiere el semáforo fuera del counter lock (para que los llamantes que liberan nunca causen deadlock). Los de ruta lenta envuelven el acquire en asyncio.wait_for(..., timeout=acquire_timeout); un timeout decrementa depth, incrementa rejected_timeout_total y lanza QueueTimeoutError. La seguridad ante cancelación es explícita: una cancelación mientras se espera deshace depth; una cancelación tras reservar el slot de ruta rápida revierte in_flight; un slot retenido durante la ejecución siempre se libera en el finally de la fase 4 (decrementa in_flight, luego sem.release() — elegido para que un snapshot tomado a mitad de la liberación sobre-cuente la carga en lugar de infra-contarla).
Contador incrementado una vez que un llamante retiene realmente un slot (ruta rápida o lenta).
QueueFullError → 429 con Retry-After. La cola ya estaba en max_queued.
QueueTimeoutError → 503. El llamante esperó más de acquire_timeout — el servidor está saturado.
snapshot() devuelve un DispatcherSnapshot inmutable (seis enteros: max_inflight, max_queued, in_flight, depth, accepted_total, rejected_full_total, rejected_timeout_total). Es de solo lectura y barato, por lo que se exporta en todas las superficies:
| Superficie | Fuente | Expone |
|---|---|---|
/healthz / /healthz/ready | routes_health.py | queue_depth, queue_in_flight |
/metrics (Prometheus) | metrics.py | hfl_inference_concurrency_max, …_inflight, hfl_inference_queue_depth + contadores de saturación |
| Cada respuesta HTTP | middleware.py | cabeceras X-Queue-Depth, X-Queue-In-Flight, X-Queue-Max-Inflight, X-Queue-Max-Size |
Cómo lo consumen las rutas: run_dispatched() en api/helpers.py adquiere un slot, ejecuta la llamada síncrona al motor en un hilo (asyncio.to_thread) y aplica el timeout de generación. De forma crítica retiene el slot hasta que el hilo trabajador realmente termina — una llamada síncrona al motor no se puede cancelar, así que ante un 504/cancelación del cliente el slot se mantiene (_release_when_worker_exits) para impedir que una petición en cola arranque una segunda inferencia que corrompería el modelo a mitad de generación. Las rutas de streaming pre-adquieren con acquire_stream_slot(), que devuelve o bien un gestor de contexto ya abierto o una JSONResponse 429/503 lista.
exclusive() adquiere todos los max_inflight permisos del semáforo, de modo que el bloque protegido corre sin ninguna inferencia ejecutándose. A diferencia de slot() nunca lanza QueueFull/QueueTimeout (el trabajo admin no debe descartarse — simplemente espera) y no toca los contadores de cola (una descarga no es una petición de inferencia). Los nuevos llamantes de inferencia se bloquean en el semáforo hasta que el bloque sale — exactamente la serialización que necesita un cambio de modelo.
Protege tres operaciones contra el modelo compartido:
ServerState.set_llm_engine() drena vía exclusive() antes de retirar el motor desplazado, así ninguna petición HTTP con slot está leyendo el modelo viejo cuando se libera.
routes_snapshot.py ejecuta el save_state() multi-GB + pickle + escritura bajo exclusive() + to_thread — fuera del loop y con la inferencia/swaps drenados.
load_state() escribe tensores KV en el modelo vivo; misma barrera de drenaje o corrompe el modelo a mitad de generación.
exclusive() solo cubre las rutas que retienen un slot del dispatcher. El turno de chat por WebSocket (routes_ws.py) lee el motor directamente y no retiene un slot, así que un swap concurrente podría hacer unload() del modelo a mitad del stream — un use-after-free del modelo no reentrante. ServerState cierra ese hueco con un contador de referencias.
| Miembro | Rol |
|---|---|
_engine_inuse: dict[int,int] | Refcount por motor (clave por id()) de lectores sin slot que lo están usando. |
_engine_retired: dict[int,InferenceEngine] | Motores que un swap desplazó pero no pudo descargar por estar aún fijados (pinned). |
_engine_ref_lock: asyncio.Lock | Protege ambos mapas. |
| pin_engine(engine) | Incrementa el refcount; mientras esté fijado, un hot-swap difiere la descarga. |
| unpin_engine(engine) | Decrementa; si era el último lector de un motor retirado, lo descarga ahora (fuera del loop vía to_thread). |
La lógica de swap en set_llm_engine() realiza un retirar-luego-asignar atómico: retira el motor viejo antes de asignar el nuevo, así un fallo de descarga deja el motor viejo en su sitio (los llamantes ven "el reemplazo falló", no un estado a medio actualizar). Dentro del drenaje exclusive() comprueba _engine_inuse: si el motor desplazado sigue fijado pasa a _engine_retired y la descarga se difiere al último unpin_engine; si no, se descarga de inmediato fuera del loop. El productor WS fija el motor durante toda la vida del hilo productor y lo libera en su finally (que puede sobrevivir a una cancelación del cliente, ya que la llamada al motor no se puede interrumpir).
core/container.py centraliza el estado global. Singleton[T] es un contenedor perezoso thread-safe (double-checked locking sobre una factoría, con reset() para tests). El dataclass Container tiene un Singleton por servicio compartido y expone accesores de conveniencia:
from hfl.core import get_config, get_registry, get_state, get_dispatcher
| Accesor | Singleton |
|---|---|
| get_config() | HFLConfig (la instancia global de config) |
| get_registry() | ModelRegistry |
| get_event_bus() | EventBus |
| get_state() | ServerState |
| get_metrics() | Metrics |
| get_rate_limiter() | RateLimiter (en memoria por defecto) |
| get_dispatcher() | InferenceDispatcher |
reset_container() reconstruye todo desde cero — el fixture estándar de tests para estado limpio.
Propagación de request-ID vía contextvars (sobrevive a tareas asyncio y saltos al thread-pool). set_request_id() / get_request_id() generan IDs hex cortos de 8 caracteres; RequestContext y with_request_id los acotan; format_log_prefix() renderiza el prefijo de log [abc123de].
Persistencia de chat legible bajo ~/.hfl/sessions/<name>.json (sin pickle). ChatSession guarda modelo, opciones, mensajes y system. save_session escribe atómicamente (tmp + replace) y actualiza updated_at; los nombres se validan contra un regex estricto (InvalidSessionNameError) para evitar escapar del directorio; list_sessions omite ficheros corruptos, los más nuevos primero.
Hardening opt-in del proceso para hfl serve vía --sandbox / HFL_SANDBOX. seccomp (Linux): PR_SET_NO_NEW_PRIVS + un filtro seccomp-bpf mínimo que bloquea ptrace/kexec_*/reboot/unshare/setuid; macos: pista advisory de App-Sandbox (la imposición real viene de la entitlement del DMG firmado). Totalmente defensivo — cualquier fallo registra un WARNING y continúa; apply_sandbox devuelve un SandboxResult inspeccionable.
setup_event_listeners() puentea el event bus a las métricas: ante MODEL_LOADED/MODEL_UNLOADED/GENERATION_COMPLETED/GENERATION_FAILED registra la duración de carga, los tokens/latencia de generación y el tipo de error. Idempotente (se registra una sola vez).
Enum ModelFormat: GGUF, SAFETENSORS, PYTORCH, UNKNOWN. La función detect_format(path) inspecciona extensiones (.gguf, .safetensors, .pt/.pth/.bin) tanto en archivos individuales como en directorios (rglob). find_model_file() localiza el archivo principal del modelo.
Clase GGUFConverter con pipeline de dos pasos:
ensure_tools() auto-instala llama.cpp si no existe: git clone → cmake build → pip install requirements. check_model_convertibility() valida que el modelo sea convertible (rechaza LoRA adapters, modelos de imagen, modelos sin config.json).
| Cuantización | Bits/peso | Calidad | Caso de Uso |
|---|---|---|---|
| Q2_K | ~2.5 | ~80% | Extrema compresión |
| Q3_K_M | ~3.5 | ~87% | Poca RAM |
| Q4_K_M | ~4.5 | ~92% | DEFAULT — mejor balance |
| Q5_K_M | ~5.0 | ~96% | Alta calidad |
| Q6_K | ~6.5 | ~97% | Premium |
| Q8_0 | ~8.0 | ~98%+ | Máxima calidad cuantizada |
| F16 | 16.0 | 100% | Sin cuantización |
El cluster del conversor abarca detección de formato/tipo (formats.py), el pipeline GGUF (gguf_converter.py), un parser/renderizador bidireccional completo de Modelfile (modelfile_parser.py) y un motor Go-template mínimo (go_template.py).
hfl/converter/formats.py reconoce 33 arquitecturas causal-LM en 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, …), más otras 16 familias de arquitecturas (TTS, STT, embeddings, vision-QA, seq2seq, depth, …) usadas por detect_model_type() para clasificar cualquier config.json de HF. is_mlx_quantized_repo() marca repos empaquetados con MLX (mlx en el repo id, o un objeto quantization con group_size+bits en config.json) para enrutarlos al backend MLX en lugar del conversor de llama.cpp, que rechaza grafos empaquetados con MLX.
hfl/converter/gguf_converter.py ejecuta safetensors → convert_hf_to_gguf.py (F16) → llama-quantize → GGUF final. check_model_convertibility() rechaza de entrada adaptadores LoRA, modelos de difusión/imagen y configuraciones de audio/TTS. convert_with_cache() normaliza el token de cuant antes de construir la clave de bloqueo (para que q4_k_m y Q4_K_M no compitan por el mismo fichero de salida) y serializa conversiones duplicadas con un bloqueo por clave; _gguf_variant_path() evita deliberadamente with_suffix para que los puntos de versión en los nombres de repo (Qwen2.5-7B) no se trunquen en nombres de fichero colisionantes. El intermedio F16 es reanudable, y _check_conversion_environment() sondea el Python del host en busca de una combinación compatible de transformers/huggingface_hub antes de lanzar la conversión real.
hfl/converter/modelfile_parser.py cierra el bucle para POST /api/create: parse_modelfile() maneja FROM, PARAMETER, TEMPLATE, SYSTEM, ADAPTER, LICENSE, MESSAGE, REQUIRES, ENV, CAPABILITIES, DRAFT e INCLUDE; render_modelfile_document() serializa de vuelta con un invariante de round-trip testeado. Endurecimiento:
\n \t \r \" \\ dentro de valores con comillas simples y triples; el codificador escapa las barras invertidas (y los """ incrustados) de forma simétrica para que una plantilla Jinja con un \n literal sobreviva un parse→render→parse sin cambios..., rutas absolutas, barras invertidas y NUL; se aplican detección de ciclos y un tope de profundidad 16.hfl/converter/go_template.py implementa el subconjunto de text/template de Go que usan los Modelfiles reales — {{ .Field }}, rutas anidadas, {{ range }}, {{ if }}/{{ else }}/{{ end }}, literales de cadena, y recorte de espacios {{- -}}. render_go_template() es una capa de conveniencia, nunca una barrera: ante un GoTemplateError recae en el texto literal con un aviso, y captura RecursionError explícitamente para que un {{ if }}/{{ range }} profundamente anidado y malicioso no agote la pila de Python y aflore como un 500 no manejado.
Clase con engine (InferenceEngine activo), current_model (ModelManifest cargado) y api_key (str|None para autenticación). Instanciada como singleton global. El lifespan del servidor hace cleanup al cerrar.
Orden de ejecución Starlette (outer → inner): RequestLogger → APIKey → RateLimit (condicional) → Disclaimer → CORS. El orden de add_middleware() es inverso: CORS, Disclaimer, RateLimit, APIKey, RequestLogger. APIKey se ejecuta ANTES de RateLimit, de modo que las peticiones no autenticadas son rechazadas sin consumir tokens del rate limiter.
CORSMiddleware — Lista de orígenes permitidos configurable vía HFL_ORIGINS (por defecto solo localhost); ya no permite todos los orígenes de forma incondicional.
DisclaimerMiddleware — Añade header X-AI-Disclaimer a respuestas de endpoints de generación AI (R9).
RateLimitMiddleware — Limitación de tasa por IP, configurable vía env vars. Soporta rate limiting por modelo.
APIKeyMiddleware — Autenticación opcional via --api-key. Soporta Authorization: Bearer <key> y X-API-Key: <key>. Endpoints públicos (/health, /) exentos.
RequestLogger — Logging privacy-safe. NUNCA registra: bodies (prompts/outputs), headers auth, User-Agent. Solo: método, path, status, duración.
Manejo centralizado de excepciones via register_exception_handlers(app). Mapea toda la jerarquia HFLError a respuestas HTTP con codigos apropiados (400 para validacion, 429 para rate limit, 500 para errores internos).
Todos los endpoints incluyen tags, summary y responses en sus decoradores para documentacion OpenAPI auto-generada. Tags: OpenAI, Ollama, TTS, Health, Metrics.
Todo el logging usa format strings %-style (logger.info('Model loaded: %s', name)) en lugar de f-strings, evitando evaluacion innecesaria cuando el nivel de log esta deshabilitado.
/health/deep?probe=true — Ejecuta un test de inferencia minimo para verificar que el modelo funciona correctamente. Si falla, reporta estado "degraded".
/health/sli — Service Level Indicators con metricas de disponibilidad y latencia.
/metrics — Metricas en formato Prometheus.
/metrics/json — Metricas en formato JSON.
chat_core.py)Toda superficie de chat — OpenAI /v1/chat/completions, Ollama /api/chat y Anthropic /v1/messages — debe tomar la misma decisión una vez que el motor ha producido texto: ¿el modelo emitió una tool call y, de ser así, cuál es? Históricamente la ruta OpenAI quedó por detrás de la de Ollama y dejó caer silenciosamente el tool calling. Esa decisión vive ahora en un único lugar, de modo que las rutas solo pueden diferir en su traducción al formato de cable, nunca en esta lógica.
resolve_chat_output() → ChatOutputEl dataclass inmutable ChatOutput(content, tool_calls) es el turno resuelto canónico, independiente del dialecto. resolve_chat_output() toma el texto crudo del motor, el nombre del modelo, los tools declarados y cualquier engine_tool_calls estructurado, y aplica tres reglas:
| Condición | Resultado |
|---|---|
tools_disabled (cliente envió tool_choice: "none") | Texto devuelto literal; los marcadores nunca se escanean (obligatorio, ya que el parser por familia se dispara ante un marcador independientemente de la lista de tools) |
El motor devolvió una lista tool_calls real y no vacía | Se confía tal cual; content="" |
| En otro caso | Se recurre a parsear marcadores del texto vía tool_parsers.dispatch() |
La forma canónica de tool call es {"function": {"name": str, "arguments": dict}}; cada ruta la mapea a su propio formato de cable. arguments es siempre un objeto parseado, nunca una cadena (regla C3). Cuando un turno es una tool call, content es "" (regla C4) y tool_calls es siempre una lista — vacía cuando no hay (regla 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)
El function/tool calling está implementado de forma uniforme en los tres dialectos de chat. Cada ruta mapea hacia el payload OpenAI-function del motor a la entrada, y mapea el canónico ChatOutput.tool_calls hacia su propio formato de cable a la salida.
/v1/chat/completionsroutes_openai.py. _tools_payload() respeta tool_choice: "none" descarta los tools por completo (garantía dura, sin tool_calls), {"type":"function","function":{"name":"X"}} restringe al tool X, y "auto"/"required"/sin especificar reenvían todos ("required" se comporta como "auto" — no puede forzarse sin decodificación restringida). A la salida, _to_openai_tool_calls() emite {"id","type":"function","function":{"name","arguments":str}} con arguments re-serializado a cadena JSON, content: null y finish_reason: "tool_calls".
/api/chatroutes_native.py. _build_chat_message() envuelve resolve_chat_output() y mantiene tool_calls en la forma dict canónica que Ollama espera. Los tools MCP de servidores conectados se integran vía _merge_mcp_tools(). Un turno previo de assistant con tool_calls y los resultados role=tool correspondientes se reenvían al modelo para convergencia multivuelta.
/v1/messagesroutes_anthropic.py. _anthropic_tools_to_payload() mapea {name, description, input_schema} a la forma OpenAI-function y respeta tool_choice: {"type":"none"|"tool"}. A la salida, _to_anthropic_tool_use() emite bloques de contenido tool_use (arguments→dict input, ids toolu_…) con stop_reason: "tool_use". Los bloques entrantes tool_use/tool_result se reconstruyen en la lista de mensajes del motor (ruta de Claude Code).
tool_parsers.py)Cuando un backend no expone tool_calls estructuradas por sí mismo, HFL parsea los marcadores nativos de tool-call del texto generado. dispatch(text, model_name, tools) selecciona un parser por subcadena del nombre del modelo, lo ejecuta primero y solo recurre al fallback genérico de envoltorio JSON cuando hay tools declarados (de modo que un chat normal que contenga JSON nunca se malinterprete como tool call).
| Familia | Marcador |
|---|---|
| Qwen 2.5 / Qwen 3 | <tool_call>{json}</tool_call> |
| Llama 3.x | <|python_tag|>{json}<|eom_id|> o <function=name>{json}</function> |
| Mistral / Mixtral | [TOOL_CALLS][{array json}] |
| Gemma 4 | DSL split-pipe <|tool_call>call:NAME{…}<tool_call|> (claves desnudas, cadenas envueltas en el delimitador <|"|> de Gemma 4) |
| Fallback | bloque json cercado, envoltorio {"tool_call":{…}}, o {"name","arguments"} desnudo |
El streaming con tools declarados se bufferiza: cada dialecto acumula la generación completa y emite las tool_calls estructuradas en un único delta terminal, de modo que un marcador <tool_call> crudo nunca se filtra como token de contenido (API-7).
agent_loop.py)En /api/chat, optar con agent_loop=true conduce el ciclo de uso de tools en el servidor: llama al modelo, despacha las tool_calls emitidas (solo tools MCP — el modelo no debe alucinar nombres externos), añade resultados role=tool y re-llama hasta obtener una respuesta sin tool calls o alcanzar max_iterations (por defecto 6). Las tool calls de un mismo turno se disparan en paralelo vía asyncio.gather. run_agent_loop() devuelve el mensaje final más un tool_trace para replay/depuración.
POST /v1/responses)routes_openai_responses.py. El wrapper de más alto nivel de OpenAI (la ruta que golpea client.responses.create(...)) está implementado sobre la maquinaria existente de chat-completion — sin nuevo camino de motor. No es stateful: cada petición es autocontenida y el servidor no persiste una cadena de response_id.
| Petición Responses | Equivalente chat-completion |
|---|---|
input (str | lista de dicts de mensaje) | messages vía _input_to_messages() (rol por defecto user; el texto de lista-de-partes se concatena, las partes de imagen se descartan — soporte de imagen fuera de alcance) |
instructions | mensaje system inicial |
tools | reenviados al motor; reparseados vía tool_parsers.dispatch() |
reasoning.effort (low/medium/high) | GenerationConfig.thinking_level vía _resolve_thinking() |
response_format | normalizado vía normalize_openai_response_format() |
stream: false | renderiza el envelope output[] |
stream: true | SSE con eventos response.created / response.output_text.delta / response.completed |
La respuesta sin streaming la construye _render_response() como una lista output heterogénea y ordenada: primero resúmenes de reasoning, luego el message del assistant (bloques output_text), luego cualquier function_call — más un bloque usage con input_tokens/output_tokens/total_tokens. El streaming bufferiza los turnos con tools y solo emite function_call estructuradas en el evento final response.completed; ambos modos pasan por el dispatcher de inferencia (se retiene un slot durante todo el stream).
/ws/chat (cancelable)routes_ws.py. Los endpoints HTTP de streaming son de un solo disparo: la cancelación depende del cierre TCP y no devuelve ninguna señal accionable. El endpoint WebSocket añade una conexión persistente a través de múltiples turnos de chat, un frame cancel que interrumpe la generación en vuelo, y frames de servidor a nivel de token para que el cliente renderice UI sin parsear NDJSON ni SSE.
| Dirección | Frames |
|---|---|
| cliente → servidor | {type:"chat", model, messages, options?}, {type:"cancel"}, {type:"ping"} |
| servidor → cliente | {type:"ready", model}, {type:"token", delta}, {type:"done", tokens}, {type:"cancelled", tokens}, {type:"error", message}, {type:"pong"} |
El bucle de recepción lee frames de forma continua mientras un turno corre en una asyncio.Task de fondo (_drive_chat), de modo que un cancel puede llegar en pleno vuelo. El driver compite un asyncio.Event por turno contra cada token producido vía asyncio.wait(FIRST_COMPLETED) y sale limpiamente, dejando la conexión abierta para el siguiente prompt.
El hilo productor síncrono del motor no puede ser interrumpido (llama-cpp-python no expone API de cancelación). Al cancelar, el consumidor se detiene pero el productor sigue corriendo hasta que el motor retorna por sí mismo, así que el slot del dispatcher queda ocupado. El motor se fija vía state.pin_engine() durante la vida del productor para diferir el unload de un hot-swap concurrente (guarda contra UAF de ciclo de vida del modelo), y el slot huérfano se registra y se cuenta en hfl_ws_cancel_orphans_total.
Las actualizaciones WebSocket se saltan el APIKeyMiddleware y el CORSMiddleware HTTP, así que _check_ws_auth_and_origin() replica ambos en línea: API key vía ?api_key=, Authorization: Bearer o X-API-Key (comparación de tiempo constante), y una comprobación de allow-list de Origin (mismo origen estricto por defecto). Los rechazos hacen accept-then-close con código 1008 para que el navegador muestre la razón.
La forma nativa (plana) de HFL {"error": str, "code": …, "category", "retryable", "details", "request_id"} no coincide con ningún proveedor real. errors.py:render_envelope(path, status_code, flat) la remodela según el prefijo de ruta para que los SDK reales de OpenAI/Anthropic (que ramifican según error.type) la parseen correctamente, manteniendo los campos de diagnóstico ricos de HFL como extensiones ignoradas por el SDK dentro del objeto de error.
| Prefijo de ruta | Envelope |
|---|---|
/v1/messages* (Anthropic) | {"type":"error", "error":{type, message, …}, "request_id"} — type de _ANTHROPIC_TYPE (p.ej. 401→authentication_error, 503→overloaded_error) |
/v1/* (OpenAI) | {"error":{message, type, param, code, …}} — type de _OPENAI_TYPE (p.ej. 429→rate_limit_error) |
/api/* (Ollama) + todo lo demás | cuerpo plano de HFL, sin cambios |
Cada código de error lleva una política (category, retryable) de _ERROR_POLICY (p.ej. RATE_LIMIT_EXCEEDED→reintentable, VALIDATION_ERROR→no), de modo que la lógica de reintento / circuit-breaker del cliente decide sin parsear prosa. Los helpers de fábrica (service_unavailable, model_loading, queue_full, queue_timeout, …) aceptan todos un path opcional para el renderizado por dialecto.
exception_handlers.py registra tres handlers vía register_exception_handlers(app). El handler de RequestValidationError emite un 400 por dialecto en las superficies /v1/* (OpenAI/Anthropic reales devuelven 400, no el 422 por defecto de FastAPI {"detail":[…]}), aplanando el primer error a "loc: msg"; /api/* y todo lo demás conservan el 422 nativo. El handler de HFLError y un handler catch-all de Exception también pasan sus cuerpos por render_envelope, de modo que incluso un 500 no manejado llega a un SDK OpenAI/Anthropic como un objeto de error parseable.
Toda superficie de chat enruta su llamada al motor a través del dispatcher de inferencia compartido vía helpers.py, serializando las peticiones concurrentes contra el modelo no reentrante de llama.cpp / transformers (el uso concurrente corrompe la KV cache).
run_dispatched()Punto de entrada único para handlers sin streaming: adquiere un slot, ejecuta la llamada síncrona al motor en un hilo, aplica el timeout de generación. Una llamada síncrona con timeout / cancelada no puede ser interrumpida, así que el slot se retiene hasta que el hilo trabajador realmente termina — la siguiente petición en cola no puede arrancar una segunda inferencia que corrompa el modelo a media generación.
prepare_stream_response() / acquire_stream_slot()Los endpoints de streaming pre-adquieren un slot retenido durante todo el stream. Ante QueueFullError / QueueTimeoutError el helper devuelve un envelope 429 / 503 preconstruido (con Retry-After / X-Queue-Depth), renderizado para el dialecto de la petición.
routes_metrics.py expone GET /metrics (texto Prometheus vía get_metrics().export_prometheus()) y GET /metrics/json (JSON estructurado). El middleware RequestLogger (middleware.py) alimenta cada petición a Metrics.record_request(), acumulando contadores por-endpoint / por-status / por-método más histogramas de latencia de todo el tiempo (_bucket/_sum/_count para Prometheus).
| Métrica | Significado |
|---|---|
hfl_ws_cancels_total | cada frame cancel de /ws/chat recibido |
hfl_ws_cancel_orphans_total | cancelaciones que dejaron el slot del dispatcher ocupado (motor aún corriendo) |
hfl_stream_cancel_orphans_total | streams SSE/HTTP desmontados mientras el hilo productor del motor seguía corriendo |
Los contadores de huérfanos existen porque una llamada síncrona al motor vía asyncio.to_thread no puede cancelarse — permiten a la planificación de capacidad ver con qué frecuencia un cancel/desconexión deja una inferencia corriendo hasta el final en segundo plano.
GET /healthz (orquestador, spec §5.5)Además de las sondas /health* existentes, routes_health.py añade /healthz: devuelve 200 status=ok cuando hay un motor LLM cargado y listo, si no 503 status=degraded. El cuerpo lleva models_loaded, queue_depth / queue_in_flight en vivo del snapshot del dispatcher, y uptime_seconds — un solo scrape para orquestación.
Archivo: src/hfl/i18n/__init__.py
Sistema de internacionalización completo que permite al CLI mostrar todos sus mensajes en múltiples idiomas. Utiliza archivos JSON de traducción con claves anidadas y acceso mediante notación de puntos.
| Función | Descripción |
|---|---|
t(key, **kwargs) | Traduce una clave (ej: t("commands.pull.downloading")). Soporta interpolación con .format(**kwargs). Cache con lru_cache para rendimiento. |
get_language() | Retorna el idioma actual. Lee de HFL_LANG env var, default "en" |
set_language(lang) | Cambia el idioma en runtime y limpia la caché de traducciones |
_load_translations(lang) | Carga el archivo JSON del idioma desde locales/ |
_get_nested_value(data, key) | Navega diccionarios anidados con notación de puntos |
Cada idioma tiene un archivo JSON (~441 líneas, ~294 claves) en src/hfl/i18n/locales/:
en.json (inglés) y es.json (español). Las claves siguen la estructura module.action.message, por ejemplo: commands.pull.downloading, commands.search.no_results, errors.model_not_found.
export HFL_LANG=es. Todos los comandos CLI utilizan t() para sus mensajes, permitiendo cambiar el idioma sin modificar código.
Siguen siendo exactamente dos locales — en.json y es.json — pero cada uno ha crecido a 441 líneas (~294 claves hoja), ya que cada comando nuevo (discover, recommend, bench, snapshot, lora, …) registra sus cadenas commands.<name>.description / ayuda de opciones a través de t() en tiempo de import. La resolución de idioma es HFL_LANG → locale del sistema (LC_ALL/LC_MESSAGES/LANG) → en. Una clave ausente cae a inglés y, en su defecto, devuelve la clave literal, de modo que una cadena sin traducir se degrada con elegancia en vez de tumbar el CLI.
Ficheros: src/hfl/tray/icon.py, src/hfl/tray/controller.py
Front-end opcional multiplataforma de bandeja para el servidor, accesible con hfl serve --tray (alias --gui). Construido sobre pystray + Pillow (extra [tray]); la imagen del icono se genera programáticamente — el paquete no incluye ficheros de recursos. Si falta el extra, serve imprime una pista de instalación y termina.
Ejecuta uvicorn en un hilo demonio en segundo plano con su propio bucle de eventos, exponiendo start() / stop() / status desde los callbacks no-async de la bandeja. El estado es un enum ServerStatus: STOPPED, STARTING, RUNNING, STOPPING, ERROR. stop() activa server.should_exit y hace join con un presupuesto de 35 s (30 s gracioso + 5 s de margen). Puede precargar un modelo con select_engine antes de servir.
_generate_icon_image() dibuja un círculo RGBA de 64×64 (color según estado) con una “H” centrada. El menú ofrece Start/Stop (habilitados según el estado actual), una línea de estado, la URL del servidor, la cadena HFL v{__version__} y Exit. En macOS run() debe llamarse desde el hilo principal; un poller en segundo plano transiciona el icono STARTING→RUNNING.
observability — Métricas, Firma y AuditoríaAquí conviven dos preocupaciones distintas, en dos niveles de madurez distintos. Métricas (src/hfl/metrics.py) es la vía de telemetría en producción: respalda /metrics y la vista de SLI, y ahora emite histogramas Prometheus agregables además de contadores de saturación del dispatcher. El paquete src/hfl/observability/ — firma ed25519 de manifiestos, un audit log estructurado y tracing OpenTelemetry — está completamente implementado y cubierto por tests, pero dormant: ningún módulo bajo src/hfl/ importa todavía hfl.observability, así que estos helpers se distribuyen como fontanería opt-in con cero call sites en producción. Documentamos lo que hace el código y señalamos el hueco de cableado explícitamente.
Metrics (un @dataclass thread-safe protegido por un único threading.Lock) mantiene dos familias de latencia como histogramas monotónicos de todo el histórico además de los buffers circulares acotados deque(maxlen=1000) que alimentan la vista de percentiles JSON local. Los deques encogen en silencio bajo carga (su ventana son las últimas 1000 muestras), así que la serie exportada es el array acumulativo de buckets — eso es lo que hace que histogram_quantile() y rate(..._count) sean correctos en cualquier ventana de scrape y entre réplicas.
Las fronteras de bucket (_LATENCY_BUCKETS_MS) son 13 cortes fijos en milisegundos de 5 a 60000, más un bucket de overflow +Inf. _bucket_index() halla la primera frontera >= valor; record_request() y record_generation() incrementan el bucket correspondiente y el _sum_ms acumulado. _append_histogram() renderiza el triple acumulativo _bucket{le=...} / _sum / _count.
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) # overflow +Inf
Se exportan dos series de histograma: hfl_request_latency_ms y hfl_generation_latency_ms, cada una con las líneas canónicas _bucket/_sum/_count. La vista JSON (export_json()) y get_sli() siguen calculando p50/p95/p99 localmente sobre el deque mediante _percentile() con interpolación lineal.
En cada scrape, export_prometheus() obtiene un DispatcherSnapshot (de src/hfl/engine/dispatcher.py, un dataclass frozen de seis enteros) vía get_dispatcher().snapshot() y exporta las señales de load-shedding que antes no eran alertables. Leer el snapshot es barato, así que se hace inline bajo un try/except best-effort (la config puede no estar cargada en algunos tests).
| Métrica | Tipo | Campo origen | Significado |
|---|---|---|---|
hfl_inference_concurrency_max | gauge | max_inflight | Máximo configurado de inferencias in-flight |
hfl_inference_concurrency_inflight | gauge | in_flight | Peticiones ejecutándose ahora mismo |
hfl_inference_queue_depth | gauge | depth | Peticiones esperando un slot |
hfl_inference_accepted_total | counter | accepted_total | Peticiones admitidas al dispatcher |
hfl_inference_rejected_full_total | counter | rejected_full_total | Rechazadas por cola de espera llena (HTTP 429) |
hfl_inference_rejected_timeout_total | counter | rejected_timeout_total | Rechazadas tras esperar demasiado por un slot (HTTP 503) |
Junto a ellas, el exporter publica diagnósticos de cancelación de streaming: hfl_ws_cancels_total / hfl_ws_cancel_orphans_total (frames de cancel WebSocket, y el subconjunto que dejó una llamada al engine corriendo en background con el slot del dispatcher aún ocupado) y el contador de paridad SSE/HTTP hfl_stream_cancel_orphans_total.
src/hfl/observability/signing.py implementa procedencia opt-in sobre los manifiestos de modelo. manifest_digest() talla un subconjunto estable del envelope (name, repo_id, file_hash, hash_algorithm, size_bytes, quantization, architecture, adapter_paths, parent_digest) — descartando deliberadamente metadata mutable como last_used para que un hfl show nunca invalide una firma — y le aplica SHA-256 a su JSON canónico. sign_manifest_envelope() adjunta un bloque {alg:"ed25519", key_id, digest, sig}; verify_manifest_envelope() lo verifica contra un keyring TrustRoot que el operador cura en ~/.hfl/trusted-publishers.json (key_id → clave pública base64url).
El backend criptográfico se elige en runtime: prueba pynacl primero, luego cryptography, lanzando SignatureUnavailableError si ninguno es importable; un bloque inválido/no autorizado lanza SignatureInvalidError (pensado para convertirse en un 400 en modo estricto). Los manifiestos sin firmar siguen siendo válidos — verify_manifest_envelope() simplemente devuelve False.
Ningún código bajo src/hfl/ importa signing; solo lo ejercitan tests/test_signing.py y tests/test_signing_edge_cases.py. En particular, el digest que emite /api/ps lo calcula un helper separado y local _manifest_digest() en src/hfl/api/routes_ps.py (que estampa el file_hash almacenado del manifiesto), no este módulo.
src/hfl/observability/audit.py escribe un objeto JSON por línea en HFL_AUDIT_LOG_PATH mediante un RotatingFileHandler (por defecto 100 MB × 5 backups, ajustable con HFL_AUDIT_LOG_MAX_BYTES / HFL_AUDIT_LOG_BACKUPS). Es opt-in: sin ruta configurada, audit_event() es un no-op de coste cero. El envelope de evento lleva ts (UTC, microsegundos, sufijo Z), event, actor (un prefijo SHA-256, nunca la API key cruda), resource, metadata y outcome. El catálogo es un frozenset AUDIT_EVENTS — model.create/delete/copy/pull/stop/unload/load, blob.upload, mcp.connect/disconnect, api_key.mint/revoke. El emisor nunca lanza; un nombre de evento desconocido registra un warning pero igualmente emite.
audit_event() no tiene call site en producción bajo src/hfl/; pese a que el docstring afirma que "cada ruta/comando CLI privilegiado registra exactamente un evento", la instrumentación aún no está cableada. Cubierto solo por tests/test_audit.py / tests/test_audit_log.py.
src/hfl/observability/tracing.py envuelve el SDK de OTEL tras un context manager trace_span(name, attributes=...) que se vuelve un no-op nullcontext() cuando el tracing está apagado — así los call sites nunca tienen que comprobar is_enabled(). configure_tracing() se activa con HFL_OTEL_ENABLED; cuando está activo construye un TracerProvider con un BatchSpanProcessor + exporter OTLP/HTTP (endpoint HFL_OTEL_EXPORTER_ENDPOINT, por defecto http://localhost:4318/v1/traces; nombre de servicio HFL_OTEL_SERVICE_NAME, por defecto hfl). El SDK viaja tras el extra [otel]; si falta, la configuración registra un warning y queda deshabilitada.
No existen call sites de trace_span() / configure_tracing() bajo src/hfl/ fuera del propio módulo. El helper está listo pero ninguna operación está instrumentada todavía; ejercitado por tests/test_tracing.py / tests/test_otel_tracing.py.
tools y mcpLa superficie agéntica. src/hfl/tools/ aporta las dos tools integradas — búsqueda web y fetch de URL — expuestas de forma compatible con Ollama en /api/web_search y /api/web_fetch (src/hfl/api/routes_web.py). src/hfl/mcp/ implementa ambas mitades del Model Context Protocol: HFL como host que se conecta a servidores MCP externos y mezcla sus tools en el chat, y HFL como servidor que publica sus propias tools a otros hosts. Ambos están cableados en producción; MCP viaja tras el extra opcional [mcp].
src/hfl/tools/)web_search.py expone una única search(query, max_results) async que despacha a un backend elegido por HFL_WEB_SEARCH_BACKEND. Todos los backends devuelven el mismo envelope Ollama {"results":[{title,url,content}]}. max_results se acota a [1,10].
web_fetch.py expone fetch(url) async devolviendo {title, content, links, url} extraídos por un único HTMLParser de la stdlib (sin BeautifulSoup). Los errores a nivel HTTP nunca lanzan — solo los fallos de protocolo/seguridad/timeout lanzan WebFetchError (convertido en un 400).
Los backends de búsqueda forman un pequeño registro tras una WebSearchBackend abstracta:
| Backend | Endpoint | Clave |
|---|---|---|
duckduckgo (por defecto, alias ddg) | html.duckduckgo.com/html/ (scrapeado con regex) | ninguna |
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() resuelve el nombre (cayendo a DuckDuckGo con un warning ante valores desconocidos, así el servidor nunca da un 500 al arrancar). Los wrappers de redirect /l/?uddg= de DDG se desenvuelven a la URL destino real.
web_fetch está endurecido en profundidad contra server-side request forgery. El flujo:
http/https (_ALLOWED_SCHEMES)._resolve_and_validate() llama a socket.getaddrinfo() y rechaza la URL si cualquier dirección resuelta es privada, loopback, link-local, multicast, reserved o unspecified (_is_private_ip(); una dirección no parseable se trata como insegura). Esto bloquea el endpoint de cloud-metadata 169.254.169.254 y los servicios internos._pinned_request() reescribe el host de la URL al literal de la IP verificada preservando la autoridad original en la cabecera Host y, para HTTPS, la extensión TLS sni_hostname — así httpx no puede re-resolver al conectar y un atacante con DNS autoritativo TTL-0 no puede responder público-aquí / privado-a-httpx. El userinfo (user:pass@) se preserva para que los fetch con Basic-auth sigan funcionando.follow_redirects=False; hasta _MAX_REDIRECTS (3) saltos se siguen a mano, cada uno re-pasado por el guard completo de esquema + IP privada y re-pineado, así una URL pública no puede hacer 302 hacia una dirección interna/metadata.resp.aiter_bytes() con un tope duro max_bytes (5 MiB por defecto). Un get no-streaming bufferizaría toda la URL (controlada por el modelo) en RAM y podría hacer OOM al proceso; el streaming acota la memoria real, no solo la entrada del parser.def _is_private_ip(ip_str: str) -> bool:
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
return True # no parseable == inseguro
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)
La capa de ruta (routes_web.py) valida los cuerpos con Pydantic (query 1–2048 chars; max_results 1–10; url 1–2048 chars), mapea WebSearchError/WebFetchError a HTTP 400, y un crash a 500 con un mensaje que no filtra detalles.
src/hfl/mcp/)El SDK oficial mcp de Python es una dependencia opcional. Ambos módulos se importan incondicionalmente y degradan con gracia: un SDK ausente lanza MCPClientUnavailableError / MCPServerUnavailableError solo en el punto de uso, nunca al importar, así que las instalaciones sin el extra [mcp] siguen funcionando.
mcp/client.pyMCPClient (singleton vía get_client()) mantiene sesiones vivas a cada servidor configurado. connect(server_id, target) acepta dos transportes — stdio://<cmd> <args> (lanza un subproceso que habla JSON-RPC) y sse://<url> (HTTP + Server-Sent Events) — llama a session.initialize() + list_tools() y envuelve cada resultado como una MCPTool. Los nombres de tool se namespacean como server_id__tool (qualified_name) para evitar colisiones entre servidores, y to_ollama_tool() los convierte al esquema {type:"function", function:{...}} que ve el modelo. autoload_servers() conecta cada entrada del fichero JSON apuntado por HFL_MCP_AUTOLOAD; una entrada rota se registra pero nunca aborta hfl serve.
Integración en chat (src/hfl/api/routes_native.py): _merge_mcp_tools() pliega cada tool MCP conectada en el array tools de una petición /api/chat que no las declaró ya (best-effort — una lectura fallida se registra y se ignora). Cuando agent_loop=true, la ruta pasa mcp_client.call_tool como el mcp_caller a run_agent_loop(), que despacha las llamadas a tools y re-somete los resultados hasta una respuesta sin tools o max_iterations (6 por defecto).
mcp/server.pyHFLMCPServer publica un registro mínimo y seguro por defecto HFL_TOOLS: web_search, web_fetch, model_list y model_show. Las dos primeras delegan en hfl.tools; las dos últimas leen hfl.models.registry. --capabilities estrecha el conjunto publicado (_filter_tools()). build_server() cablea los handlers @server.list_tools() / @server.call_tool() del SDK; un ValueError del handler se convierte en una respuesta MCP IsError TextContent, cualquier otra excepción se registra y se expone como un error interno genérico. Se sirven dos transportes: serve_stdio() (para hosts por subproceso) y serve_sse(host, port) (una app Starlette bajo uvicorn, /sse + /messages/).
hfl mcpEl comando mcp en src/hfl/cli/main.py controla ambas mitades:
| Comando | Efecto |
|---|---|
hfl mcp list | Imprime cada tool de los servidores conectados (nombre cualificado + descripción) |
hfl mcp connect <id> stdio://… | sse://… | Abre una sesión y enumera sus tools |
hfl mcp disconnect <id> | Cierra la sesión (idempotente) |
hfl mcp serve --transport stdio | Ejecuta HFL como servidor MCP por stdio |
hfl mcp serve --transport sse --host … --port … --capabilities web_search,web_fetch | Ejecuta como servidor SSE con un conjunto de tools estrechado |
Todas heredan de HFLError(message, details) con __str__ que combina ambos. Cada excepción incluye datos contextuales específicos (model_name, repo_id, required_gb, etc.) para diagnóstico detallado.
HFL implementa un sistema exhaustivo de cumplimiento legal documentado con referencias a auditoría (R1-R9):
Verificación obligatoria antes de descarga. Clasificación en 5 niveles de riesgo. Presentación al usuario con panel visual. Aceptación explícita requerida para licencias no permisivas. Metadata de licencia persistida en ModelManifest.
ProvenanceLog inmutable registra cada conversión: origen, destino, herramienta, versión, licencia, timestamp. Advertencia legal en pantalla durante conversión: "la licencia original sigue vigente".
Campos en ModelManifest para clasificación GPAI: gpai_classification ("gpai", "gpai-systemic", "exempt") y training_flops. Archivo NOTICE-EU-AI-ACT.md.
Token HF solo en memoria. Logging privacy-safe: NUNCA se registran prompts, outputs AI, tokens, User-Agent. Solo metadata (método, path, status, duración). Archivo PRIVACY.md.
Rate limiting (0.5s entre llamadas API). User-Agent identificativo (hfl/0.14.0). Respeto al sistema de gating: NO se bypasea la aceptación de licencias. El usuario debe aceptar en huggingface.co primero.
Middleware DisclaimerMiddleware añade header X-AI-Disclaimer a todas las respuestas de endpoints AI. Disclaimer en chat CLI: "Los modelos AI pueden generar información incorrecta..."
LICENSE | Apache-2.0 |
LICENSE-FAQ.md | Texto de la licencia Apache-2.0 |
LICENSE-DEPENDENCIES.md | Licencias de todas las dependencias de terceros |
PRIVACY.md | Política de privacidad (no se recopilan datos) |
NOTICE-EU-AI-ACT.md | Cumplimiento con EU AI Act |
DISCLAIMER.md | Exención de responsabilidad general |
La interfaz InferenceEngine (ABC) define el contrato. Tres implementaciones concretas (LlamaCpp, Transformers, vLLM) son intercambiables. El selector.py actúa como factory que elige la estrategia correcta según contexto.
Todas las dependencias pesadas (torch, transformers, vllm, llama-cpp-python) se importan solo cuando se necesitan. Los imports dentro de funciones evitan que una instalación mínima falle por dependencias opcionales ausentes.
core/container.py implementa un contenedor DI con singletons thread-safe (double-checked locking). Provee get_config(), get_registry(), get_state(), get_event_bus(), get_metrics(). Facilita testing con reset_container().
ChatMessage, GenerationConfig, GenerationResult, ModelManifest, ResolvedModel, ConversionRecord, LicenseInfo — todos son dataclasses inmutables o semi-inmutables que encapsulan datos.
El comando pull implementa un pipeline secuencial de 7 pasos: resolver → verificar licencia → descargar → detectar formato → convertir (condicional) → crear manifest → registrar. Cada paso es un componente desacoplado.
Los mismos motores de inferencia se exponen a través de dos interfaces API diferentes (OpenAI y Ollama) mediante routers separados que adaptan los formatos de request/response al formato esperado por cada ecosistema.
utils/circuit_breaker.py implementa circuit breaker para llamadas externas. utils/retry.py provee retry con exponential backoff configurable. Ambos mejoran la resiliencia ante fallos de red o servicios externos.
events.py implementa un EventBus interno para comunicación desacoplada entre componentes. Permite suscribirse a eventos como model_loaded, inference_complete, download_progress sin crear dependencias directas.
Las decisiones arquitectónicas importantes están documentadas en docs/adr/ siguiendo el formato estándar ADR:
| ADR | Título | Estado |
|---|---|---|
0001 | Singleton Pattern para Config y Registry | Accepted |
0002 | API Async con Engines Sync (sync_to_thread) | Accepted |
0003 | GGUF como Formato Default | Accepted |
0004 | Compatibilidad API Ollama | Accepted |
0005 | Clasificación de Licencias (5 niveles) | Accepted |
0006 | Estrategia de Rate Limiting | Accepted |
engine/dispatcher.py — InferenceDispatcher serializa el acceso al modelo no reentrante tras una primitiva de concurrencia acotada y cola de espera acotada: max_inflight (defecto 1), max_queued (16), acquire_timeout (60 s). Una cola llena lanza QueueFullError → HTTP 429 + Retry-After; un timeout de ranura lanza QueueTimeoutError → 503. El estado en vivo se expone con snapshot() para /healthz y la cabecera X-Queue-Depth.
api/state.py · set_llm_engine envuelve el retiro-y-asignación del motor previo dentro de dispatcher.exclusive(), que espera a que se vacíen todas las ranuras de inferencia antes de liberar el modelo viejo. El desmontaje corre fuera del bucle vía asyncio.to_thread(prev.unload), de modo que una limpieza lenta del contexto GPU/Metal nunca congela /healthz ni /metrics. El swap es atómico: un fallo de unload deja el motor viejo en su sitio.
Las rutas de inferencia que no sostienen una ranura del dispatcher — en particular el turno de chat por WebSocket — llaman a pin_engine() / unpin_engine() alrededor de su uso del motor. Mientras un motor está fijado (pinned), un hot-swap concurrente aplaza su unload (guárdandolo en _engine_retired) hasta que el último lector lo libera. Esto cierra la ventana de use-after-free sobre el modelo compartido no reentrante que un drenaje por sí solo no cubriría.
api/chat_core.py — resolve_chat_output() es el único punto de decisión agnóstico al dialecto: dado el texto crudo del motor produce un ChatOutput(content, tool_calls) canónico, respetando tool_choice:"none" y prefiriendo los tool-calls estructurados del motor frente al parseo de marcadores. Las rutas OpenAI, Ollama y Anthropic ahora sólo difieren en la traducción de formato de cable — el bug histórico por el que la ruta OpenAI descartaba silenciosamente el tool-calling ya no puede repetirse.
La matriz de extras creció mucho más allá de los cuatro originales. Las deps nativas pesadas llevan ahora cotas superiores (tope en el siguiente major) por ser la mayor superficie de vulnerabilidades/rupturas. [all] = llama,transformers,vllm,convert,tts,coqui,audio,tray,mcp,mlx,stt,imagegen.
| Extra | Arrastra | Propósito |
|---|---|---|
[vulkan] | referencia [llama] | Backend GPU Vulkan (Intel Arc, AMD sin ROCm); compilado con CMAKE_ARGS="-DGGML_VULKAN=ON". |
[rocm] | referencia [llama] | Ruta AMD HIP; compilado con -DGGML_HIPBLAS=ON. Mismo wheel, distinto flag CMake. |
[mlx] | mlx-lm>=0.31.2,<1.0 (darwin/arm64) | Backend nativo de Apple Silicon. |
[coqui] | coqui-tts, torch, torchaudio, soundfile | Motor Coqui TTS (junto al [tts] de Bark vía transformers). |
[audio] | sounddevice, soundfile | Reproducción de audio local. |
[tray] | pystray, Pillow | GUI de bandeja para serve --tray. |
[mcp] | mcp>=1.0.0 | Cliente/servidor de Model Context Protocol. |
[stt] | faster-whisper>=1.0.0,<2.0 | Voz a texto (Whisper). |
[imagegen] | diffusers, torch, safetensors, Pillow, accelerate | Generación de imágenes SDXL / FLUX (~2 GB de wheels). |
[otel] | opentelemetry api/sdk + exporter OTLP-HTTP | Trazado distribuido. |
Subidas de pins: el core apunta ahora a transformers>=5.0,<6.0 + torch>=2.5,<3.0 (la línea 5.x / hub-1.x), huggingface-hub>=1.5,<2.0, fastapi~=0.135, starlette>=1.3.1 y python-multipart>=0.0.31 (ambos suelos fijados por correcciones de CVE). llama-cpp-python>=0.3.20 (soporte gemma4). [dev] añadió mypy>=1.10 y numpy>=1.24.
pytest + pytest-asyncio (asyncio_mode = "auto"). ~200 archivos de test con fixtures compartidas en conftest.py.
test_config.py | HFLConfig, paths, env vars |
test_container.py | DI container, Singleton pattern |
test_exceptions.py | Jerarquía de excepciones |
test_events.py | EventBus, pub/sub |
test_metrics.py | Métricas de rendimiento |
test_validators.py | Validación de datos |
test_security.py | Seguridad y sanitización |
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 | Carga dinámica de modelos |
test_helpers.py | ensure_llm/tts_loaded |
test_middleware.py | Privacy logger, disclaimer |
test_engine*.py (2) | Base, LlamaCpp |
test_selector*.py (3) | Auto-selección backend |
test_transformers*.py (2) | TransformersEngine |
test_vllm_engine.py | vLLM con mocks |
test_tts_*.py (2) | Bark, Coqui TTS |
test_async_wrapper.py | Sync→Async wrapper |
test_model_pool.py | Pool de modelos |
test_hub*.py (2) | Integración HF Hub |
test_downloader*.py (2) | Descarga con resume |
test_resolver*.py (2) | Resolución de modelos |
test_license_checker.py | Clasificación licencias |
test_auth.py | Autenticación HF |
test_cli*.py (4) | Comandos Typer |
test_converter*.py (3) | GGUF conversion |
test_i18n*.py (4) | Internacionalización |
test_circuit_breaker.py | Circuit breaker |
test_retry.py | Retry con backoff |
test_integration.py | Flujos end-to-end completos (pull→run→serve) |
test_concurrency.py | Concurrencia, thread safety, race conditions |
test_network_errors.py | Timeouts, disconnects, retry logic |
test_edge_cases.py | Inputs malformados, estados límite |
test_server_lifecycle.py | Startup, shutdown, cleanup |
| Signal handling CLI | Ctrl+C durante streaming preserva respuesta parcial |
| Middleware order | Verifica orden correcto de ejecucion del middleware stack |
| Exception handlers | Mapeo HFLError → HTTP status codes |
| Health probe | Deep health check con inferencia minima |
| Config env vars | Override de configuracion via variables de entorno |
| Model pool wait | Espera no recursiva (bounded polling) |
| Stress tests | Concurrent streaming, model pool stress |
| Timeout | Decorator @with_timeout y timeout configurable |
| Failover | Multi-engine retry con sticky routing |
| Per-model rate limit | Rate limiting diferenciado por modelo |
| Conversion caching | Cache de conversiones GGUF |
Fixtures principales (conftest.py): tmp_hfl_home (directorio temporal), mock_hf_api (mock HfApi), sample_manifest (modelo ejemplo), populated_registry (registro pre-populado), mock_engine (engine de inferencia mockeado), test_client (FastAPI TestClient).
# Ejecutar tests con cobertura
pytest --cov=hfl --cov-report=html --cov-report=term-missing # fail_under=75 (pyproject)
# Tests por categoría
pytest tests/test_api*.py -v # Solo API
pytest tests/test_engine*.py -v # Solo engines
pytest -k "not slow" -v # Excluir tests lentos
Configurado en pyproject.toml. Packages source en src/hfl. Entry point: hfl = "hfl.cli.main:app". Instalación con pip install . o pip install .[all] para todas las dependencias opcionales.
Spec para generar ejecutable standalone. Permite distribuir HFL como binario único sin requerir Python instalado. Se genera con pyinstaller hfl.spec y el resultado queda en dist/hfl.
~/.hfl/
├── models/ # Archivos de modelos descargados
│ └── org--model/ # Directorio por modelo (org--model format)
├── cache/ # Caché de HuggingFace Hub
├── tools/
│ └── llama.cpp/ # Herramientas compiladas para conversión
│ └── build/bin/ # Binarios: llama-quantize, etc.
├── models.json # Registro de modelos (array de ModelManifest)
└── provenance.json # Log inmutable de conversiones
| Workflow | Trigger | Descripción |
|---|---|---|
ci.yml | Manual (workflow_dispatch) | Pipeline completo: lint (ruff) + tests (pytest matrix Python 3.10/3.11/3.12) + type-check. Sin disparador push/PR (política de no-CI/CD automático). |
pages.yml | Manual (workflow_dispatch) | Deploy de docs HTML a GitHub Pages (manual; sin auto-deploy en push) |
build-executables.yml | Release/manual | Build ejecutables multiplataforma con PyInstaller |
lint.yml | Manual (workflow_dispatch) | Linting con ruff (E, F, W, I) |
test.yml | Push/PR | Tests con pytest + coverage |
security.yml | Programado/manual | Auditoría de seguridad de dependencias |
license-check.yml | Push/PR | Verificación de licencias de dependencias |
Issue Templates: bug_report.yml (formulario estructurado con info de sistema, pasos para reproducir, logs) y feature_request.yml (problema, solución propuesta, componente afectado).
PR Template: Checklist que incluye verificación de los 5 Compliance Modules (licencia, provenance, disclaimer, privacidad, gating) como normas de uso responsable del proyecto.
Archivos de comunidad: CONTRIBUTING.md (guía de desarrollo, estilo de código, proceso de PR), CODE_OF_CONDUCT.md (Contributor Covenant v2.1), SECURITY.md (política de reporte de vulnerabilidades).
Cada uno de los 14 workflows bajo .github/workflows/ está ahora declarado on: workflow_dispatch — los disparadores automáticos push / pull_request / schedule / por tag se eliminaron deliberadamente. El propietario ejecuta cada pipeline a mano desde la pestaña Actions. No hay publicación automática: publish-pypi.yml tiene su disparador por tag eliminado y corre solo por dispatch manual (con un input testpypi), usando PyPI Trusted Publisher (OIDC, sin token almacenado) más una atestación Sigstore. build-executables.yml además conserva un disparador release: para subir artefactos.
| Workflow | Disparador | Qué ejecuta |
|---|---|---|
ci.yml | dispatch | Matriz de tests (ubuntu+macos × Py 3.10/3.11/3.12) con cobertura, lint+format de ruff, mypy sobre api/+cli/. |
test.yml / lint.yml | dispatch | pytest+cobertura / ruff por separado (trabajos heredados). |
security.yml | dispatch | Auditoría de seguridad de dependencias. |
license-check.yml | dispatch | Verificación de licencias de dependencias. |
publish-pypi.yml | dispatch | Build + twine check + publicación OIDC a PyPI/TestPyPI + Sigstore. |
docker.yml / homebrew.yml | dispatch | Imágenes de contenedor / fórmula Homebrew. |
macos-dmg.yml / windows-msi.yml / build-executables.yml | dispatch (+ release) | Instaladores nativos y bundles de PyInstaller. |
pages.yml / release.yml | dispatch | Docs a GitHub Pages / empaquetado de release. |
HFL v0.14.0 — Documentación de Arquitectura Exhaustiva
Actualizado el 19 de junio de 2026 — Todos los diagramas son SVG interactivos
Licencia: Apache-2.0 — Copyright © 2026 Gabriel Galán Pelayo