hfl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. hfl/__init__.py +5 -0
  2. hfl/api/__init__.py +3 -0
  3. hfl/api/converters.py +145 -0
  4. hfl/api/deprecation.py +132 -0
  5. hfl/api/errors.py +278 -0
  6. hfl/api/exception_handlers.py +39 -0
  7. hfl/api/helpers.py +550 -0
  8. hfl/api/middleware.py +260 -0
  9. hfl/api/model_loader.py +180 -0
  10. hfl/api/rate_limit.py +431 -0
  11. hfl/api/routes_health.py +274 -0
  12. hfl/api/routes_metrics.py +45 -0
  13. hfl/api/routes_native.py +260 -0
  14. hfl/api/routes_openai.py +285 -0
  15. hfl/api/routes_tts.py +302 -0
  16. hfl/api/schemas/__init__.py +41 -0
  17. hfl/api/schemas/ollama.py +97 -0
  18. hfl/api/schemas/openai.py +151 -0
  19. hfl/api/schemas/tts.py +138 -0
  20. hfl/api/server.py +232 -0
  21. hfl/api/state.py +321 -0
  22. hfl/api/streaming.py +195 -0
  23. hfl/api/timeout.py +107 -0
  24. hfl/cli/__init__.py +3 -0
  25. hfl/cli/commands/__init__.py +6 -0
  26. hfl/cli/commands/_utils.py +207 -0
  27. hfl/cli/main.py +1134 -0
  28. hfl/config.py +194 -0
  29. hfl/converter/__init__.py +3 -0
  30. hfl/converter/formats.py +678 -0
  31. hfl/converter/gguf_converter.py +538 -0
  32. hfl/core/__init__.py +49 -0
  33. hfl/core/container.py +270 -0
  34. hfl/core/observability_setup.py +74 -0
  35. hfl/core/tracing.py +214 -0
  36. hfl/engine/__init__.py +3 -0
  37. hfl/engine/async_wrapper.py +228 -0
  38. hfl/engine/bark_engine.py +354 -0
  39. hfl/engine/base.py +257 -0
  40. hfl/engine/coqui_engine.py +389 -0
  41. hfl/engine/dependency_check.py +138 -0
  42. hfl/engine/failover.py +168 -0
  43. hfl/engine/llama_cpp.py +252 -0
  44. hfl/engine/memory.py +252 -0
  45. hfl/engine/model_pool.py +452 -0
  46. hfl/engine/observability.py +386 -0
  47. hfl/engine/prompt_builder.py +310 -0
  48. hfl/engine/selector.py +275 -0
  49. hfl/engine/transformers_engine.py +239 -0
  50. hfl/engine/vllm_engine.py +239 -0
  51. hfl/events.py +227 -0
  52. hfl/exceptions.py +304 -0
  53. hfl/hub/__init__.py +3 -0
  54. hfl/hub/auth.py +102 -0
  55. hfl/hub/client.py +190 -0
  56. hfl/hub/downloader.py +170 -0
  57. hfl/hub/license_checker.py +327 -0
  58. hfl/hub/resolver.py +178 -0
  59. hfl/i18n/__init__.py +189 -0
  60. hfl/i18n/locales/en.json +259 -0
  61. hfl/i18n/locales/es.json +259 -0
  62. hfl/logging_config.py +213 -0
  63. hfl/metrics.py +359 -0
  64. hfl/models/__init__.py +3 -0
  65. hfl/models/backends/__init__.py +23 -0
  66. hfl/models/backends/base.py +88 -0
  67. hfl/models/backends/file.py +174 -0
  68. hfl/models/backends/sqlite.py +189 -0
  69. hfl/models/manifest.py +154 -0
  70. hfl/models/provenance.py +190 -0
  71. hfl/models/registry.py +450 -0
  72. hfl/plugins.py +215 -0
  73. hfl/security.py +378 -0
  74. hfl/utils/__init__.py +3 -0
  75. hfl/utils/circuit_breaker.py +196 -0
  76. hfl/utils/retry.py +177 -0
  77. hfl/validators.py +450 -0
  78. hfl-0.1.0.dist-info/METADATA +796 -0
  79. hfl-0.1.0.dist-info/RECORD +85 -0
  80. hfl-0.1.0.dist-info/WHEEL +4 -0
  81. hfl-0.1.0.dist-info/entry_points.txt +2 -0
  82. hfl-0.1.0.dist-info/licenses/LICENSE +733 -0
  83. hfl-0.1.0.dist-info/licenses/LICENSE-DEPENDENCIES.md +63 -0
  84. hfl-0.1.0.dist-info/licenses/LICENSE-FAQ.md +73 -0
  85. hfl-0.1.0.dist-info/licenses/NOTICE-EU-AI-ACT.md +92 -0
hfl/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ # SPDX-License-Identifier: HRUL-1.0
2
+ # Copyright (c) 2026 Gabriel Galán Pelayo
3
+ """hfl - Run HuggingFace models locally like Ollama."""
4
+
5
+ __version__ = "0.1.0"
hfl/api/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ # SPDX-License-Identifier: HRUL-1.0
2
+ # Copyright (c) 2026 Gabriel Galán Pelayo
3
+ """API server module for hfl."""
hfl/api/converters.py ADDED
@@ -0,0 +1,145 @@
1
+ # SPDX-License-Identifier: HRUL-1.0
2
+ # Copyright (c) 2026 Gabriel Galán Pelayo
3
+ """Request/response converters for API compatibility.
4
+
5
+ Consolidates conversion logic between different API formats:
6
+ - OpenAI API format
7
+ - Ollama API format
8
+ - Internal GenerationConfig
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from hfl.engine.base import GenerationConfig
16
+
17
+ if TYPE_CHECKING:
18
+ from hfl.api.schemas import ChatCompletionRequest, CompletionRequest
19
+
20
+
21
+ def clamp(value: float | int, min_val: float | int, max_val: float | int) -> float | int:
22
+ """Clamp value to range [min_val, max_val].
23
+
24
+ Args:
25
+ value: Value to clamp
26
+ min_val: Minimum allowed value
27
+ max_val: Maximum allowed value
28
+
29
+ Returns:
30
+ Clamped value
31
+ """
32
+ return max(min_val, min(max_val, value))
33
+
34
+
35
+ def openai_to_generation_config(
36
+ req: ChatCompletionRequest | CompletionRequest,
37
+ ) -> GenerationConfig:
38
+ """Convert OpenAI API request to GenerationConfig.
39
+
40
+ Args:
41
+ req: OpenAI-style request (ChatCompletionRequest or CompletionRequest)
42
+
43
+ Returns:
44
+ GenerationConfig for inference
45
+ """
46
+ # Handle stop sequences - can be string or list
47
+ stop = req.stop if isinstance(req.stop, list) else ([req.stop] if req.stop else None)
48
+
49
+ return GenerationConfig(
50
+ temperature=req.temperature,
51
+ top_p=req.top_p,
52
+ max_tokens=req.max_tokens or 2048,
53
+ stop=stop,
54
+ seed=req.seed or -1,
55
+ )
56
+
57
+
58
+ def ollama_to_generation_config(options: dict[str, Any] | None) -> GenerationConfig:
59
+ """Convert Ollama options dict to GenerationConfig.
60
+
61
+ Validates and clamps all values to safe ranges per Ollama API spec.
62
+
63
+ Args:
64
+ options: Ollama-style options dict (may be None)
65
+
66
+ Returns:
67
+ GenerationConfig for inference
68
+ """
69
+ opts = options or {}
70
+
71
+ # Validate and clamp all values to safe ranges
72
+ temperature = clamp(float(opts.get("temperature", 0.7)), 0.0, 2.0)
73
+ top_p = clamp(float(opts.get("top_p", 0.9)), 0.0, 1.0)
74
+ top_k = clamp(int(opts.get("top_k", 40)), 1, 1000)
75
+ max_tokens = clamp(int(opts.get("num_predict", 2048)), 1, 128000)
76
+ repeat_penalty = clamp(float(opts.get("repeat_penalty", 1.1)), 0.0, 2.0)
77
+ seed = int(opts.get("seed", -1))
78
+
79
+ return GenerationConfig(
80
+ temperature=temperature,
81
+ top_p=top_p,
82
+ top_k=int(top_k),
83
+ max_tokens=int(max_tokens),
84
+ repeat_penalty=repeat_penalty,
85
+ seed=seed,
86
+ stop=opts.get("stop"),
87
+ )
88
+
89
+
90
+ def generation_config_to_openai(config: GenerationConfig) -> dict[str, Any]:
91
+ """Convert GenerationConfig to OpenAI-style options dict.
92
+
93
+ Useful for logging or returning config in API responses.
94
+
95
+ Args:
96
+ config: Internal generation config
97
+
98
+ Returns:
99
+ OpenAI-style options dict
100
+ """
101
+ result: dict[str, Any] = {}
102
+
103
+ if config.temperature is not None:
104
+ result["temperature"] = config.temperature
105
+ if config.top_p is not None:
106
+ result["top_p"] = config.top_p
107
+ if config.max_tokens is not None:
108
+ result["max_tokens"] = config.max_tokens
109
+ if config.stop:
110
+ result["stop"] = config.stop
111
+ if config.seed is not None and config.seed != -1:
112
+ result["seed"] = config.seed
113
+
114
+ return result
115
+
116
+
117
+ def generation_config_to_ollama(config: GenerationConfig) -> dict[str, Any]:
118
+ """Convert GenerationConfig to Ollama-style options dict.
119
+
120
+ Useful for logging or returning config in API responses.
121
+
122
+ Args:
123
+ config: Internal generation config
124
+
125
+ Returns:
126
+ Ollama-style options dict
127
+ """
128
+ result: dict[str, Any] = {}
129
+
130
+ if config.temperature is not None:
131
+ result["temperature"] = config.temperature
132
+ if config.top_p is not None:
133
+ result["top_p"] = config.top_p
134
+ if config.top_k is not None:
135
+ result["top_k"] = config.top_k
136
+ if config.max_tokens is not None:
137
+ result["num_predict"] = config.max_tokens
138
+ if config.repeat_penalty is not None:
139
+ result["repeat_penalty"] = config.repeat_penalty
140
+ if config.stop:
141
+ result["stop"] = config.stop
142
+ if config.seed is not None and config.seed != -1:
143
+ result["seed"] = config.seed
144
+
145
+ return result
hfl/api/deprecation.py ADDED
@@ -0,0 +1,132 @@
1
+ # SPDX-License-Identifier: HRUL-1.0
2
+ # Copyright (c) 2026 Gabriel Galán Pelayo
3
+ """
4
+ Deprecation utilities for API endpoints.
5
+
6
+ Provides helpers for marking endpoints as deprecated with standard
7
+ HTTP headers (Deprecation, Sunset) per RFC 8594.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from datetime import datetime
13
+ from functools import wraps
14
+ from typing import Any, Callable
15
+
16
+ from fastapi import Response
17
+
18
+
19
+ def add_deprecation_headers(
20
+ response: Response,
21
+ deprecated_at: str | None = None,
22
+ sunset: str | None = None,
23
+ alternative: str | None = None,
24
+ ) -> None:
25
+ """Add deprecation headers to a response.
26
+
27
+ Implements RFC 8594 deprecation header format.
28
+
29
+ Args:
30
+ response: FastAPI/Starlette Response object
31
+ deprecated_at: ISO 8601 date when deprecation started
32
+ sunset: ISO 8601 date when endpoint will be removed
33
+ alternative: URL or path to the replacement endpoint
34
+
35
+ Example:
36
+ >>> response = Response()
37
+ >>> add_deprecation_headers(
38
+ ... response,
39
+ ... deprecated_at="2026-01-01",
40
+ ... sunset="2027-01-01",
41
+ ... alternative="/v2/chat/completions"
42
+ ... )
43
+ """
44
+ response.headers["Deprecation"] = "true"
45
+
46
+ if deprecated_at:
47
+ # RFC 8594 format: "@" followed by Unix timestamp or HTTP date
48
+ try:
49
+ dt = datetime.fromisoformat(deprecated_at)
50
+ response.headers["Deprecation"] = f"@{int(dt.timestamp())}"
51
+ except ValueError:
52
+ response.headers["Deprecation"] = "true"
53
+
54
+ if sunset:
55
+ # Sunset header uses HTTP date format
56
+ try:
57
+ dt = datetime.fromisoformat(sunset)
58
+ response.headers["Sunset"] = dt.strftime("%a, %d %b %Y %H:%M:%S GMT")
59
+ except ValueError:
60
+ pass
61
+
62
+ if alternative:
63
+ # Link header for the replacement
64
+ response.headers["Link"] = f'<{alternative}>; rel="successor-version"'
65
+
66
+
67
+ def deprecated_endpoint(
68
+ deprecated_at: str | None = None,
69
+ sunset: str | None = None,
70
+ alternative: str | None = None,
71
+ message: str | None = None,
72
+ ) -> Callable:
73
+ """Decorator to mark an endpoint as deprecated.
74
+
75
+ Adds deprecation headers to the response and includes
76
+ deprecation warning in the response body if applicable.
77
+
78
+ Args:
79
+ deprecated_at: ISO 8601 date when deprecation started
80
+ sunset: ISO 8601 date when endpoint will be removed
81
+ alternative: URL or path to the replacement endpoint
82
+ message: Custom deprecation message for response body
83
+
84
+ Example:
85
+ >>> @deprecated_endpoint(
86
+ ... sunset="2027-01-01",
87
+ ... alternative="/v2/generate",
88
+ ... message="Use /v2/generate instead"
89
+ ... )
90
+ ... async def old_generate():
91
+ ... return {"text": "..."}
92
+ """
93
+
94
+ def decorator(func: Callable) -> Callable:
95
+ @wraps(func)
96
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
97
+ result = await func(*args, **kwargs)
98
+
99
+ # Find response object if available
100
+ response = kwargs.get("response")
101
+ if response is None and hasattr(result, "headers"):
102
+ response = result
103
+
104
+ if response is not None:
105
+ add_deprecation_headers(
106
+ response,
107
+ deprecated_at=deprecated_at,
108
+ sunset=sunset,
109
+ alternative=alternative,
110
+ )
111
+
112
+ # If result is a dict, we can add deprecation warning
113
+ if isinstance(result, dict) and message:
114
+ result["_deprecation_warning"] = message
115
+
116
+ return result
117
+
118
+ return wrapper
119
+
120
+ return decorator
121
+
122
+
123
+ def format_sunset_date(date: datetime) -> str:
124
+ """Format a datetime as an RFC 7231 HTTP date.
125
+
126
+ Args:
127
+ date: datetime object
128
+
129
+ Returns:
130
+ HTTP date string (e.g., "Mon, 01 Jan 2027 00:00:00 GMT")
131
+ """
132
+ return date.strftime("%a, %d %b %Y %H:%M:%S GMT")
hfl/api/errors.py ADDED
@@ -0,0 +1,278 @@
1
+ # SPDX-License-Identifier: HRUL-1.0
2
+ # Copyright (c) 2026 Gabriel Galán Pelayo
3
+ """
4
+ Consistent error responses for HFL API.
5
+
6
+ Provides structured error response format and factory functions
7
+ for common error types.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from fastapi import HTTPException
15
+ from fastapi.responses import JSONResponse
16
+ from pydantic import BaseModel
17
+
18
+ from hfl.logging_config import get_request_id
19
+
20
+
21
+ class ErrorDetail(BaseModel):
22
+ """Structured error response model.
23
+
24
+ Used for consistent error response format across all endpoints.
25
+ """
26
+
27
+ error: str
28
+ code: str
29
+ details: dict[str, Any] | None = None
30
+ request_id: str | None = None
31
+
32
+ model_config = {"extra": "forbid"}
33
+
34
+
35
+ class HFLHTTPException(HTTPException):
36
+ """HTTP exception with structured error details.
37
+
38
+ Extends FastAPI's HTTPException with structured error format.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ status_code: int,
44
+ error: str,
45
+ code: str,
46
+ details: dict[str, Any] | None = None,
47
+ ):
48
+ """Create structured HTTP exception.
49
+
50
+ Args:
51
+ status_code: HTTP status code
52
+ error: Human-readable error message
53
+ code: Machine-readable error code
54
+ details: Additional error details
55
+ """
56
+ detail = ErrorDetail(
57
+ error=error,
58
+ code=code,
59
+ details=details,
60
+ request_id=get_request_id(),
61
+ )
62
+ super().__init__(status_code=status_code, detail=detail.model_dump())
63
+ self.error_code = code
64
+
65
+
66
+ # Common error factory functions
67
+
68
+
69
+ def model_not_found(model_name: str) -> HFLHTTPException:
70
+ """Create 404 error for model not found.
71
+
72
+ Args:
73
+ model_name: Name of the model
74
+
75
+ Returns:
76
+ HFLHTTPException with 404 status
77
+ """
78
+ return HFLHTTPException(
79
+ status_code=404,
80
+ error=f"Model not found: {model_name}",
81
+ code="MODEL_NOT_FOUND",
82
+ details={"model": model_name},
83
+ )
84
+
85
+
86
+ def model_type_mismatch(expected: str, got: str, model: str | None = None) -> HFLHTTPException:
87
+ """Create 400 error for model type mismatch.
88
+
89
+ Args:
90
+ expected: Expected model type
91
+ got: Actual model type
92
+ model: Model name (optional)
93
+
94
+ Returns:
95
+ HFLHTTPException with 400 status
96
+ """
97
+ details = {"expected": expected, "got": got}
98
+ if model:
99
+ details["model"] = model
100
+
101
+ return HFLHTTPException(
102
+ status_code=400,
103
+ error=f"Expected {expected} model, got {got}",
104
+ code="MODEL_TYPE_MISMATCH",
105
+ details=details,
106
+ )
107
+
108
+
109
+ def validation_error(message: str, field: str | None = None) -> HFLHTTPException:
110
+ """Create 400 error for validation failure.
111
+
112
+ Args:
113
+ message: Validation error message
114
+ field: Field that failed validation (optional)
115
+
116
+ Returns:
117
+ HFLHTTPException with 400 status
118
+ """
119
+ return HFLHTTPException(
120
+ status_code=400,
121
+ error=message,
122
+ code="VALIDATION_ERROR",
123
+ details={"field": field} if field else None,
124
+ )
125
+
126
+
127
+ def rate_limit_exceeded(retry_after: int, window_seconds: int | None = None) -> JSONResponse:
128
+ """Create 429 response for rate limit exceeded.
129
+
130
+ Args:
131
+ retry_after: Seconds until rate limit resets
132
+ window_seconds: Rate limit window (optional)
133
+
134
+ Returns:
135
+ JSONResponse with 429 status and Retry-After header
136
+ """
137
+ details = {"retry_after_seconds": retry_after}
138
+ if window_seconds:
139
+ details["window_seconds"] = window_seconds
140
+
141
+ return JSONResponse(
142
+ status_code=429,
143
+ content=ErrorDetail(
144
+ error="Rate limit exceeded",
145
+ code="RATE_LIMIT_EXCEEDED",
146
+ details=details,
147
+ request_id=get_request_id(),
148
+ ).model_dump(),
149
+ headers={"Retry-After": str(retry_after)},
150
+ )
151
+
152
+
153
+ def unauthorized(message: str = "Authentication required") -> JSONResponse:
154
+ """Create 401 response for unauthorized access.
155
+
156
+ Args:
157
+ message: Error message
158
+
159
+ Returns:
160
+ JSONResponse with 401 status
161
+ """
162
+ return JSONResponse(
163
+ status_code=401,
164
+ content=ErrorDetail(
165
+ error=message,
166
+ code="UNAUTHORIZED",
167
+ request_id=get_request_id(),
168
+ ).model_dump(),
169
+ headers={"WWW-Authenticate": "Bearer"},
170
+ )
171
+
172
+
173
+ def forbidden(message: str = "Access denied") -> JSONResponse:
174
+ """Create 403 response for forbidden access.
175
+
176
+ Args:
177
+ message: Error message
178
+
179
+ Returns:
180
+ JSONResponse with 403 status
181
+ """
182
+ return JSONResponse(
183
+ status_code=403,
184
+ content=ErrorDetail(
185
+ error=message,
186
+ code="FORBIDDEN",
187
+ request_id=get_request_id(),
188
+ ).model_dump(),
189
+ )
190
+
191
+
192
+ def internal_error(
193
+ message: str = "Internal server error",
194
+ error_type: str | None = None,
195
+ ) -> HFLHTTPException:
196
+ """Create 500 error for internal server errors.
197
+
198
+ Args:
199
+ message: Error message
200
+ error_type: Type of error (optional)
201
+
202
+ Returns:
203
+ HFLHTTPException with 500 status
204
+ """
205
+ return HFLHTTPException(
206
+ status_code=500,
207
+ error=message,
208
+ code="INTERNAL_ERROR",
209
+ details={"type": error_type} if error_type else None,
210
+ )
211
+
212
+
213
+ def service_unavailable(
214
+ message: str = "Service temporarily unavailable",
215
+ retry_after: int | None = None,
216
+ ) -> JSONResponse:
217
+ """Create 503 response for service unavailable.
218
+
219
+ Args:
220
+ message: Error message
221
+ retry_after: Seconds until service may be available
222
+
223
+ Returns:
224
+ JSONResponse with 503 status
225
+ """
226
+ headers = {}
227
+ if retry_after:
228
+ headers["Retry-After"] = str(retry_after)
229
+
230
+ return JSONResponse(
231
+ status_code=503,
232
+ content=ErrorDetail(
233
+ error=message,
234
+ code="SERVICE_UNAVAILABLE",
235
+ details={"retry_after": retry_after} if retry_after else None,
236
+ request_id=get_request_id(),
237
+ ).model_dump(),
238
+ headers=headers if headers else None,
239
+ )
240
+
241
+
242
+ def model_loading(model_name: str) -> JSONResponse:
243
+ """Create 503 response for model currently loading.
244
+
245
+ Args:
246
+ model_name: Name of the loading model
247
+
248
+ Returns:
249
+ JSONResponse with 503 status
250
+ """
251
+ return JSONResponse(
252
+ status_code=503,
253
+ content=ErrorDetail(
254
+ error=f"Model '{model_name}' is currently loading",
255
+ code="MODEL_LOADING",
256
+ details={"model": model_name},
257
+ request_id=get_request_id(),
258
+ ).model_dump(),
259
+ headers={"Retry-After": "5"},
260
+ )
261
+
262
+
263
+ def timeout_error(operation: str, timeout_seconds: float) -> HFLHTTPException:
264
+ """Create 504 error for operation timeout.
265
+
266
+ Args:
267
+ operation: Name of the timed out operation
268
+ timeout_seconds: Timeout duration
269
+
270
+ Returns:
271
+ HFLHTTPException with 504 status
272
+ """
273
+ return HFLHTTPException(
274
+ status_code=504,
275
+ error=f"Operation '{operation}' timed out",
276
+ code="TIMEOUT",
277
+ details={"operation": operation, "timeout_seconds": timeout_seconds},
278
+ )
@@ -0,0 +1,39 @@
1
+ # SPDX-License-Identifier: HRUL-1.0
2
+ # Copyright (c) 2026 Gabriel Galán Pelayo
3
+ """Global exception handlers for the FastAPI app.
4
+
5
+ Maps domain exceptions (HFLError hierarchy) to HTTP responses,
6
+ so routes can raise domain exceptions instead of HTTPException.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+
13
+ from fastapi import FastAPI, Request
14
+ from fastapi.responses import JSONResponse
15
+
16
+ from hfl.exceptions import HFLError
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def register_exception_handlers(app: FastAPI) -> None:
22
+ """Register global exception handlers on the FastAPI app."""
23
+
24
+ @app.exception_handler(HFLError)
25
+ async def hfl_error_handler(request: Request, exc: HFLError) -> JSONResponse:
26
+ """Convert HFLError subclasses to structured HTTP responses."""
27
+ status_code = getattr(exc, "status_code", 500)
28
+
29
+ body: dict = {
30
+ "error": exc.message,
31
+ "code": type(exc).__name__,
32
+ }
33
+ if exc.details:
34
+ body["details"] = exc.details
35
+
36
+ if status_code >= 500:
37
+ logger.error("Unhandled HFLError: %s", exc)
38
+
39
+ return JSONResponse(status_code=status_code, content=body)