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.
- hfl/__init__.py +5 -0
- hfl/api/__init__.py +3 -0
- hfl/api/converters.py +145 -0
- hfl/api/deprecation.py +132 -0
- hfl/api/errors.py +278 -0
- hfl/api/exception_handlers.py +39 -0
- hfl/api/helpers.py +550 -0
- hfl/api/middleware.py +260 -0
- hfl/api/model_loader.py +180 -0
- hfl/api/rate_limit.py +431 -0
- hfl/api/routes_health.py +274 -0
- hfl/api/routes_metrics.py +45 -0
- hfl/api/routes_native.py +260 -0
- hfl/api/routes_openai.py +285 -0
- hfl/api/routes_tts.py +302 -0
- hfl/api/schemas/__init__.py +41 -0
- hfl/api/schemas/ollama.py +97 -0
- hfl/api/schemas/openai.py +151 -0
- hfl/api/schemas/tts.py +138 -0
- hfl/api/server.py +232 -0
- hfl/api/state.py +321 -0
- hfl/api/streaming.py +195 -0
- hfl/api/timeout.py +107 -0
- hfl/cli/__init__.py +3 -0
- hfl/cli/commands/__init__.py +6 -0
- hfl/cli/commands/_utils.py +207 -0
- hfl/cli/main.py +1134 -0
- hfl/config.py +194 -0
- hfl/converter/__init__.py +3 -0
- hfl/converter/formats.py +678 -0
- hfl/converter/gguf_converter.py +538 -0
- hfl/core/__init__.py +49 -0
- hfl/core/container.py +270 -0
- hfl/core/observability_setup.py +74 -0
- hfl/core/tracing.py +214 -0
- hfl/engine/__init__.py +3 -0
- hfl/engine/async_wrapper.py +228 -0
- hfl/engine/bark_engine.py +354 -0
- hfl/engine/base.py +257 -0
- hfl/engine/coqui_engine.py +389 -0
- hfl/engine/dependency_check.py +138 -0
- hfl/engine/failover.py +168 -0
- hfl/engine/llama_cpp.py +252 -0
- hfl/engine/memory.py +252 -0
- hfl/engine/model_pool.py +452 -0
- hfl/engine/observability.py +386 -0
- hfl/engine/prompt_builder.py +310 -0
- hfl/engine/selector.py +275 -0
- hfl/engine/transformers_engine.py +239 -0
- hfl/engine/vllm_engine.py +239 -0
- hfl/events.py +227 -0
- hfl/exceptions.py +304 -0
- hfl/hub/__init__.py +3 -0
- hfl/hub/auth.py +102 -0
- hfl/hub/client.py +190 -0
- hfl/hub/downloader.py +170 -0
- hfl/hub/license_checker.py +327 -0
- hfl/hub/resolver.py +178 -0
- hfl/i18n/__init__.py +189 -0
- hfl/i18n/locales/en.json +259 -0
- hfl/i18n/locales/es.json +259 -0
- hfl/logging_config.py +213 -0
- hfl/metrics.py +359 -0
- hfl/models/__init__.py +3 -0
- hfl/models/backends/__init__.py +23 -0
- hfl/models/backends/base.py +88 -0
- hfl/models/backends/file.py +174 -0
- hfl/models/backends/sqlite.py +189 -0
- hfl/models/manifest.py +154 -0
- hfl/models/provenance.py +190 -0
- hfl/models/registry.py +450 -0
- hfl/plugins.py +215 -0
- hfl/security.py +378 -0
- hfl/utils/__init__.py +3 -0
- hfl/utils/circuit_breaker.py +196 -0
- hfl/utils/retry.py +177 -0
- hfl/validators.py +450 -0
- hfl-0.1.0.dist-info/METADATA +796 -0
- hfl-0.1.0.dist-info/RECORD +85 -0
- hfl-0.1.0.dist-info/WHEEL +4 -0
- hfl-0.1.0.dist-info/entry_points.txt +2 -0
- hfl-0.1.0.dist-info/licenses/LICENSE +733 -0
- hfl-0.1.0.dist-info/licenses/LICENSE-DEPENDENCIES.md +63 -0
- hfl-0.1.0.dist-info/licenses/LICENSE-FAQ.md +73 -0
- hfl-0.1.0.dist-info/licenses/NOTICE-EU-AI-ACT.md +92 -0
hfl/__init__.py
ADDED
hfl/api/__init__.py
ADDED
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)
|