privaite 0.2.3__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.
- privaite/__init__.py +1 -0
- privaite/__main__.py +3 -0
- privaite/api/__init__.py +0 -0
- privaite/api/chat.py +126 -0
- privaite/api/completions.py +108 -0
- privaite/api/dependencies.py +22 -0
- privaite/api/embeddings.py +71 -0
- privaite/api/health.py +48 -0
- privaite/api/models.py +25 -0
- privaite/api/router.py +12 -0
- privaite/app.py +78 -0
- privaite/cli.py +45 -0
- privaite/config/__init__.py +4 -0
- privaite/config/loader.py +53 -0
- privaite/config/schema.py +184 -0
- privaite/middleware/__init__.py +0 -0
- privaite/middleware/auth.py +61 -0
- privaite/middleware/limits.py +103 -0
- privaite/pii/__init__.py +0 -0
- privaite/pii/anonymizer.py +68 -0
- privaite/pii/deanonymizer.py +66 -0
- privaite/pii/detector_base.py +23 -0
- privaite/pii/detector_bert_ner.py +93 -0
- privaite/pii/detector_mlmodel.py +116 -0
- privaite/pii/detector_onnx.py +262 -0
- privaite/pii/detector_presidio.py +158 -0
- privaite/pii/engine.py +315 -0
- privaite/pii/entity.py +88 -0
- privaite/pii/faker_providers.py +83 -0
- privaite/pii/mapping.py +43 -0
- privaite/pii/recognizer_context.py +106 -0
- privaite/pii/recognizer_custom.py +45 -0
- privaite/pii/recognizer_fr_date.py +60 -0
- privaite/pii/recognizer_location.py +58 -0
- privaite/pii/tracker.py +63 -0
- privaite/providers/__init__.py +0 -0
- privaite/providers/router.py +58 -0
- privaite/streaming/__init__.py +0 -0
- privaite/streaming/buffer.py +80 -0
- privaite/streaming/handler.py +77 -0
- privaite/streaming/sse.py +32 -0
- privaite/utils/__init__.py +0 -0
- privaite/utils/errors.py +59 -0
- privaite/utils/logging.py +26 -0
- privaite/utils/security.py +18 -0
- privaite-0.2.3.dist-info/METADATA +351 -0
- privaite-0.2.3.dist-info/RECORD +51 -0
- privaite-0.2.3.dist-info/WHEEL +5 -0
- privaite-0.2.3.dist-info/entry_points.txt +2 -0
- privaite-0.2.3.dist-info/licenses/LICENSE +28 -0
- privaite-0.2.3.dist-info/top_level.txt +1 -0
privaite/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.3"
|
privaite/__main__.py
ADDED
privaite/api/__init__.py
ADDED
|
File without changes
|
privaite/api/chat.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, Request
|
|
7
|
+
from fastapi.responses import StreamingResponse
|
|
8
|
+
|
|
9
|
+
from privaite.api.dependencies import get_config, get_pii_engine, get_provider_router
|
|
10
|
+
from privaite.config.schema import PrivAiTeConfig
|
|
11
|
+
from privaite.pii.engine import UnsupportedContentError
|
|
12
|
+
from privaite.providers.router import ProviderRouter
|
|
13
|
+
from privaite.utils.errors import openai_error, provider_error_response
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("privaite.api.chat")
|
|
16
|
+
|
|
17
|
+
router = APIRouter(prefix="/v1")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.post("/chat/completions", response_model=None)
|
|
21
|
+
async def chat_completions(
|
|
22
|
+
request: Request,
|
|
23
|
+
config: PrivAiTeConfig = Depends(get_config),
|
|
24
|
+
pii_engine: Any = Depends(get_pii_engine),
|
|
25
|
+
provider_router: ProviderRouter = Depends(get_provider_router),
|
|
26
|
+
):
|
|
27
|
+
body = await request.json()
|
|
28
|
+
model = body.get("model")
|
|
29
|
+
messages = body.get("messages", [])
|
|
30
|
+
stream = body.get("stream", False)
|
|
31
|
+
|
|
32
|
+
if not model:
|
|
33
|
+
return openai_error("model is required", "invalid_request_error", 400)
|
|
34
|
+
|
|
35
|
+
if not provider_router.has_model(model):
|
|
36
|
+
return openai_error(f"Model '{model}' not found", "not_found_error", 404)
|
|
37
|
+
|
|
38
|
+
mapping = None
|
|
39
|
+
|
|
40
|
+
if config.pii.enabled and pii_engine is not None:
|
|
41
|
+
try:
|
|
42
|
+
messages, mapping = await pii_engine.process_request(messages)
|
|
43
|
+
tracker = getattr(request.app.state, "pii_tracker", None)
|
|
44
|
+
if tracker and mapping and not mapping.is_empty:
|
|
45
|
+
session_id = request.headers.get(
|
|
46
|
+
"x-session-id",
|
|
47
|
+
request.headers.get("authorization", "anonymous"),
|
|
48
|
+
)
|
|
49
|
+
counts: dict[str, int] = {}
|
|
50
|
+
for orig in mapping._original_to_fake:
|
|
51
|
+
t = mapping.get_entity_type(orig)
|
|
52
|
+
if t:
|
|
53
|
+
counts[t] = counts.get(t, 0) + 1
|
|
54
|
+
tracker.record(session_id, counts)
|
|
55
|
+
except UnsupportedContentError as exc:
|
|
56
|
+
return openai_error(str(exc), "invalid_request_error", 400)
|
|
57
|
+
except Exception:
|
|
58
|
+
logger.exception("PII processing failed")
|
|
59
|
+
if config.pii.on_error == "block":
|
|
60
|
+
return openai_error(
|
|
61
|
+
"PII anonymization failed. Request blocked for privacy.",
|
|
62
|
+
"server_error",
|
|
63
|
+
500,
|
|
64
|
+
"pii_error",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
kwargs = {k: v for k, v in body.items() if k not in ("model", "messages", "stream")}
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
if stream:
|
|
71
|
+
litellm_stream = await provider_router.streaming_completion(
|
|
72
|
+
model_alias=model, messages=messages, **kwargs
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
from privaite.streaming.handler import StreamingHandler
|
|
76
|
+
|
|
77
|
+
deanon_config = config.pii.deanonymization if config.pii.enabled else None
|
|
78
|
+
generator = StreamingHandler.stream_response(
|
|
79
|
+
litellm_stream=litellm_stream,
|
|
80
|
+
mapping=mapping,
|
|
81
|
+
deanonymizer_config=deanon_config,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return StreamingResponse(
|
|
85
|
+
generator,
|
|
86
|
+
media_type="text/event-stream",
|
|
87
|
+
headers={
|
|
88
|
+
"Cache-Control": "no-cache",
|
|
89
|
+
"Connection": "keep-alive",
|
|
90
|
+
"X-Accel-Buffering": "no",
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
response = await provider_router.completion(
|
|
95
|
+
model_alias=model, messages=messages, **kwargs
|
|
96
|
+
)
|
|
97
|
+
except Exception as exc:
|
|
98
|
+
logger.exception("Provider error for model %s", model)
|
|
99
|
+
return provider_error_response(exc)
|
|
100
|
+
|
|
101
|
+
if (
|
|
102
|
+
mapping
|
|
103
|
+
and config.pii.deanonymization.enabled
|
|
104
|
+
and pii_engine is not None
|
|
105
|
+
):
|
|
106
|
+
response_dict = response.model_dump() if hasattr(response, "model_dump") else dict(response)
|
|
107
|
+
for choice in response_dict.get("choices", []):
|
|
108
|
+
msg = choice.get("message", {})
|
|
109
|
+
content = msg.get("content")
|
|
110
|
+
if content:
|
|
111
|
+
msg["content"] = await pii_engine.process_response(content, mapping)
|
|
112
|
+
tool_calls = msg.get("tool_calls")
|
|
113
|
+
if tool_calls:
|
|
114
|
+
msg["tool_calls"] = await pii_engine.process_response_tool_calls(
|
|
115
|
+
tool_calls, mapping
|
|
116
|
+
)
|
|
117
|
+
function_call = msg.get("function_call")
|
|
118
|
+
if function_call:
|
|
119
|
+
msg["function_call"] = await pii_engine.process_response_function_call(
|
|
120
|
+
function_call, mapping
|
|
121
|
+
)
|
|
122
|
+
return response_dict
|
|
123
|
+
|
|
124
|
+
if hasattr(response, "model_dump"):
|
|
125
|
+
return response.model_dump()
|
|
126
|
+
return dict(response)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, Request
|
|
7
|
+
from fastapi.responses import StreamingResponse
|
|
8
|
+
|
|
9
|
+
from privaite.api.dependencies import get_config, get_pii_engine, get_provider_router
|
|
10
|
+
from privaite.config.schema import PrivAiTeConfig
|
|
11
|
+
from privaite.pii.engine import UnsupportedContentError
|
|
12
|
+
from privaite.providers.router import ProviderRouter
|
|
13
|
+
from privaite.utils.errors import openai_error, provider_error_response
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("privaite.api.completions")
|
|
16
|
+
|
|
17
|
+
router = APIRouter(prefix="/v1")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.post("/completions", response_model=None)
|
|
21
|
+
async def completions(
|
|
22
|
+
request: Request,
|
|
23
|
+
config: PrivAiTeConfig = Depends(get_config),
|
|
24
|
+
pii_engine: Any = Depends(get_pii_engine),
|
|
25
|
+
provider_router: ProviderRouter = Depends(get_provider_router),
|
|
26
|
+
):
|
|
27
|
+
body = await request.json()
|
|
28
|
+
model = body.get("model")
|
|
29
|
+
prompt = body.get("prompt", "")
|
|
30
|
+
stream = body.get("stream", False)
|
|
31
|
+
|
|
32
|
+
if not model:
|
|
33
|
+
return openai_error("model is required", "invalid_request_error", 400)
|
|
34
|
+
|
|
35
|
+
if not provider_router.has_model(model):
|
|
36
|
+
return openai_error(f"Model '{model}' not found", "not_found_error", 404)
|
|
37
|
+
|
|
38
|
+
mapping = None
|
|
39
|
+
|
|
40
|
+
if config.pii.enabled and pii_engine is not None:
|
|
41
|
+
try:
|
|
42
|
+
if isinstance(prompt, list) and all(isinstance(p, str) for p in prompt):
|
|
43
|
+
msgs = [{"role": "user", "content": p} for p in prompt]
|
|
44
|
+
msgs, mapping = await pii_engine.process_request(msgs)
|
|
45
|
+
prompt = [m["content"] for m in msgs]
|
|
46
|
+
else:
|
|
47
|
+
msgs = [{"role": "user", "content": prompt}]
|
|
48
|
+
msgs, mapping = await pii_engine.process_request(msgs)
|
|
49
|
+
prompt = msgs[0]["content"]
|
|
50
|
+
except UnsupportedContentError as exc:
|
|
51
|
+
return openai_error(str(exc), "invalid_request_error", 400)
|
|
52
|
+
except Exception:
|
|
53
|
+
logger.exception("PII processing failed")
|
|
54
|
+
if config.pii.on_error == "block":
|
|
55
|
+
return openai_error(
|
|
56
|
+
"PII anonymization failed. Request blocked for privacy.",
|
|
57
|
+
"server_error", 500, "pii_error",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
kwargs = {k: v for k, v in body.items() if k not in ("model", "prompt", "stream")}
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
if stream:
|
|
64
|
+
litellm_stream = await provider_router.streaming_completion(
|
|
65
|
+
model_alias=model,
|
|
66
|
+
messages=[{"role": "user", "content": prompt}],
|
|
67
|
+
**kwargs,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
from privaite.streaming.handler import StreamingHandler
|
|
71
|
+
|
|
72
|
+
deanon_config = config.pii.deanonymization if config.pii.enabled else None
|
|
73
|
+
generator = StreamingHandler.stream_response(
|
|
74
|
+
litellm_stream=litellm_stream,
|
|
75
|
+
mapping=mapping,
|
|
76
|
+
deanonymizer_config=deanon_config,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return StreamingResponse(
|
|
80
|
+
generator,
|
|
81
|
+
media_type="text/event-stream",
|
|
82
|
+
headers={
|
|
83
|
+
"Cache-Control": "no-cache",
|
|
84
|
+
"Connection": "keep-alive",
|
|
85
|
+
"X-Accel-Buffering": "no",
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
response = await provider_router.completion(
|
|
90
|
+
model_alias=model,
|
|
91
|
+
messages=[{"role": "user", "content": prompt}],
|
|
92
|
+
**kwargs,
|
|
93
|
+
)
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
logger.exception("Provider error for model %s", model)
|
|
96
|
+
return provider_error_response(exc)
|
|
97
|
+
|
|
98
|
+
if mapping and config.pii.deanonymization.enabled and pii_engine is not None:
|
|
99
|
+
response_dict = response.model_dump() if hasattr(response, "model_dump") else dict(response)
|
|
100
|
+
for choice in response_dict.get("choices", []):
|
|
101
|
+
text = choice.get("text")
|
|
102
|
+
if text:
|
|
103
|
+
choice["text"] = await pii_engine.process_response(text, mapping)
|
|
104
|
+
return response_dict
|
|
105
|
+
|
|
106
|
+
if hasattr(response, "model_dump"):
|
|
107
|
+
return response.model_dump()
|
|
108
|
+
return dict(response)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from fastapi import Request
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from privaite.config.schema import PrivAiTeConfig
|
|
9
|
+
from privaite.pii.engine import PIIEngine
|
|
10
|
+
from privaite.providers.router import ProviderRouter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_config(request: Request) -> PrivAiTeConfig:
|
|
14
|
+
return request.app.state.config
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_pii_engine(request: Request) -> PIIEngine | None:
|
|
18
|
+
return getattr(request.app.state, "pii_engine", None)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_provider_router(request: Request) -> ProviderRouter:
|
|
22
|
+
return request.app.state.provider_router
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, Request
|
|
7
|
+
|
|
8
|
+
from privaite.api.dependencies import get_config, get_pii_engine, get_provider_router
|
|
9
|
+
from privaite.config.schema import PrivAiTeConfig
|
|
10
|
+
from privaite.pii.engine import UnsupportedContentError
|
|
11
|
+
from privaite.providers.router import ProviderRouter
|
|
12
|
+
from privaite.utils.errors import openai_error, provider_error_response
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("privaite.api.embeddings")
|
|
15
|
+
|
|
16
|
+
router = APIRouter(prefix="/v1")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.post("/embeddings", response_model=None)
|
|
20
|
+
async def embeddings(
|
|
21
|
+
request: Request,
|
|
22
|
+
config: PrivAiTeConfig = Depends(get_config),
|
|
23
|
+
pii_engine: Any = Depends(get_pii_engine),
|
|
24
|
+
provider_router: ProviderRouter = Depends(get_provider_router),
|
|
25
|
+
):
|
|
26
|
+
body = await request.json()
|
|
27
|
+
model = body.get("model")
|
|
28
|
+
input_text = body.get("input", "")
|
|
29
|
+
|
|
30
|
+
if not model:
|
|
31
|
+
return openai_error("model is required", "invalid_request_error", 400)
|
|
32
|
+
|
|
33
|
+
if not provider_router.has_model(model):
|
|
34
|
+
return openai_error(f"Model '{model}' not found", "not_found_error", 404)
|
|
35
|
+
|
|
36
|
+
if config.pii.enabled and pii_engine is not None:
|
|
37
|
+
try:
|
|
38
|
+
if isinstance(input_text, str):
|
|
39
|
+
msgs = [{"role": "user", "content": input_text}]
|
|
40
|
+
msgs, _ = await pii_engine.process_request(msgs)
|
|
41
|
+
input_text = msgs[0]["content"]
|
|
42
|
+
elif isinstance(input_text, list):
|
|
43
|
+
anonymized = []
|
|
44
|
+
for text in input_text:
|
|
45
|
+
msgs = [{"role": "user", "content": text}]
|
|
46
|
+
msgs, _ = await pii_engine.process_request(msgs)
|
|
47
|
+
anonymized.append(msgs[0]["content"])
|
|
48
|
+
input_text = anonymized
|
|
49
|
+
except UnsupportedContentError as exc:
|
|
50
|
+
return openai_error(str(exc), "invalid_request_error", 400)
|
|
51
|
+
except Exception:
|
|
52
|
+
logger.exception("PII processing failed")
|
|
53
|
+
if config.pii.on_error == "block":
|
|
54
|
+
return openai_error(
|
|
55
|
+
"PII anonymization failed. Request blocked for privacy.",
|
|
56
|
+
"server_error", 500, "pii_error",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
kwargs = {k: v for k, v in body.items() if k not in ("model", "input")}
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
response = await provider_router.embedding(
|
|
63
|
+
model_alias=model, input_text=input_text, **kwargs
|
|
64
|
+
)
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
logger.exception("Provider error for model %s", model)
|
|
67
|
+
return provider_error_response(exc)
|
|
68
|
+
|
|
69
|
+
if hasattr(response, "model_dump"):
|
|
70
|
+
return response.model_dump()
|
|
71
|
+
return dict(response)
|
privaite/api/health.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Request
|
|
4
|
+
|
|
5
|
+
router = APIRouter()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@router.get("/health")
|
|
9
|
+
async def health() -> dict:
|
|
10
|
+
return {"status": "ok"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.get("/ready")
|
|
14
|
+
async def ready(request: Request) -> dict:
|
|
15
|
+
checks: dict[str, bool] = {}
|
|
16
|
+
|
|
17
|
+
provider_router = getattr(request.app.state, "provider_router", None)
|
|
18
|
+
checks["providers_configured"] = (
|
|
19
|
+
provider_router is not None and len(provider_router.models) > 0
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
pii_engine = getattr(request.app.state, "pii_engine", None)
|
|
23
|
+
if pii_engine is not None:
|
|
24
|
+
checks["pii_engine_ready"] = pii_engine.is_ready
|
|
25
|
+
else:
|
|
26
|
+
checks["pii_engine_ready"] = True
|
|
27
|
+
|
|
28
|
+
all_ready = all(checks.values())
|
|
29
|
+
return {"ready": all_ready, "checks": checks}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.get("/stats")
|
|
33
|
+
async def stats(request: Request) -> dict:
|
|
34
|
+
tracker = getattr(request.app.state, "pii_tracker", None)
|
|
35
|
+
if tracker is None:
|
|
36
|
+
return {"enabled": False}
|
|
37
|
+
|
|
38
|
+
sessions = {}
|
|
39
|
+
with tracker._lock:
|
|
40
|
+
for sid, s in tracker._sessions.items():
|
|
41
|
+
label = sid[:16] + "..." if len(sid) > 16 else sid
|
|
42
|
+
sessions[label] = {
|
|
43
|
+
"requests": s.request_count,
|
|
44
|
+
"total_pii": s.total_pii,
|
|
45
|
+
"by_type": dict(s.pii_count),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {"enabled": True, "sessions": sessions}
|
privaite/api/models.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends
|
|
6
|
+
|
|
7
|
+
from privaite.api.dependencies import get_provider_router
|
|
8
|
+
from privaite.providers.router import ProviderRouter
|
|
9
|
+
|
|
10
|
+
router = APIRouter(prefix="/v1")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@router.get("/models")
|
|
14
|
+
async def list_models(
|
|
15
|
+
provider_router: ProviderRouter = Depends(get_provider_router),
|
|
16
|
+
) -> dict:
|
|
17
|
+
models = []
|
|
18
|
+
for model_name in provider_router.models:
|
|
19
|
+
models.append({
|
|
20
|
+
"id": model_name,
|
|
21
|
+
"object": "model",
|
|
22
|
+
"created": int(time.time()),
|
|
23
|
+
"owned_by": "privaite",
|
|
24
|
+
})
|
|
25
|
+
return {"object": "list", "data": models}
|
privaite/api/router.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter
|
|
4
|
+
|
|
5
|
+
from privaite.api import chat, completions, embeddings, health, models
|
|
6
|
+
|
|
7
|
+
api_router = APIRouter()
|
|
8
|
+
api_router.include_router(health.router, tags=["health"])
|
|
9
|
+
api_router.include_router(models.router, tags=["models"])
|
|
10
|
+
api_router.include_router(chat.router, tags=["chat"])
|
|
11
|
+
api_router.include_router(completions.router, tags=["completions"])
|
|
12
|
+
api_router.include_router(embeddings.router, tags=["embeddings"])
|
privaite/app.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
|
|
9
|
+
from privaite.api.router import api_router
|
|
10
|
+
from privaite.config.schema import PrivAiTeConfig
|
|
11
|
+
from privaite.middleware.auth import AuthMiddleware
|
|
12
|
+
from privaite.middleware.limits import RequestSizeLimitMiddleware
|
|
13
|
+
from privaite.providers.router import ProviderRouter
|
|
14
|
+
from privaite.utils.logging import setup_logging
|
|
15
|
+
from privaite.utils.security import get_api_keys
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("privaite.app")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@asynccontextmanager
|
|
21
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
22
|
+
config: PrivAiTeConfig = app.state.config
|
|
23
|
+
|
|
24
|
+
app.state.provider_router = ProviderRouter(config.providers)
|
|
25
|
+
logger.info(
|
|
26
|
+
"Provider router ready with %d model(s)", len(config.providers)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if config.auth.enabled and not get_api_keys():
|
|
30
|
+
logger.warning(
|
|
31
|
+
"Auth is enabled but PRIVAITE_API_KEYS is empty; all requests will be "
|
|
32
|
+
"rejected with 401 until a key is set (or set auth.enabled=false)."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if config.pii.enabled:
|
|
36
|
+
from privaite.pii.engine import PIIEngine
|
|
37
|
+
from privaite.pii.tracker import PIITracker
|
|
38
|
+
|
|
39
|
+
engine = PIIEngine(config.pii)
|
|
40
|
+
await engine.initialize()
|
|
41
|
+
app.state.pii_engine = engine
|
|
42
|
+
app.state.pii_tracker = PIITracker()
|
|
43
|
+
logger.info("PII engine initialized")
|
|
44
|
+
else:
|
|
45
|
+
app.state.pii_engine = None
|
|
46
|
+
app.state.pii_tracker = None
|
|
47
|
+
logger.info("PII processing disabled")
|
|
48
|
+
|
|
49
|
+
yield
|
|
50
|
+
|
|
51
|
+
if app.state.pii_engine is not None:
|
|
52
|
+
await app.state.pii_engine.shutdown()
|
|
53
|
+
logger.info("PrivAiTe shutdown complete")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def create_app(config: PrivAiTeConfig | None = None) -> FastAPI:
|
|
57
|
+
if config is None:
|
|
58
|
+
from privaite.config.loader import load_config
|
|
59
|
+
config = load_config()
|
|
60
|
+
|
|
61
|
+
setup_logging(level=config.logging.level, fmt=config.logging.format)
|
|
62
|
+
|
|
63
|
+
app = FastAPI(
|
|
64
|
+
title="PrivAiTe",
|
|
65
|
+
description="Privacy-first LLM proxy with transparent PII anonymization",
|
|
66
|
+
version="0.2.3",
|
|
67
|
+
lifespan=lifespan,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
app.state.config = config
|
|
71
|
+
|
|
72
|
+
app.add_middleware(AuthMiddleware)
|
|
73
|
+
app.add_middleware(
|
|
74
|
+
RequestSizeLimitMiddleware, max_bytes=config.server.max_request_bytes
|
|
75
|
+
)
|
|
76
|
+
app.include_router(api_router)
|
|
77
|
+
|
|
78
|
+
return app
|
privaite/cli.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import uvicorn
|
|
7
|
+
|
|
8
|
+
from privaite.config.loader import load_config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.command()
|
|
12
|
+
@click.option("--config", "config_path", default=None, help="Path to config YAML file")
|
|
13
|
+
@click.option("--host", default=None, help="Override server host")
|
|
14
|
+
@click.option("--port", default=None, type=int, help="Override server port")
|
|
15
|
+
@click.option("--reload", is_flag=True, help="Auto-reload on file changes (dev mode)")
|
|
16
|
+
def main(config_path: str | None, host: str | None, port: int | None, reload: bool) -> None:
|
|
17
|
+
# uvicorn re-imports create_app() in the worker process, so the path has to
|
|
18
|
+
# travel through the environment to reach the factory's load_config().
|
|
19
|
+
if config_path:
|
|
20
|
+
os.environ["PRIVAITE_CONFIG_PATH"] = config_path
|
|
21
|
+
config = load_config(config_path)
|
|
22
|
+
|
|
23
|
+
run_host = host or config.server.host
|
|
24
|
+
run_port = port or config.server.port
|
|
25
|
+
|
|
26
|
+
click.echo(f"Starting PrivAiTe on {run_host}:{run_port}")
|
|
27
|
+
click.echo(f"PII processing: {'enabled' if config.pii.enabled else 'disabled'}")
|
|
28
|
+
click.echo(f"Providers: {len(config.providers)} configured")
|
|
29
|
+
if reload:
|
|
30
|
+
click.echo("Auto-reload enabled (dev mode)")
|
|
31
|
+
|
|
32
|
+
uvicorn.run(
|
|
33
|
+
"privaite.app:create_app",
|
|
34
|
+
host=run_host,
|
|
35
|
+
port=run_port,
|
|
36
|
+
workers=config.server.workers,
|
|
37
|
+
log_level=config.server.log_level,
|
|
38
|
+
factory=True,
|
|
39
|
+
reload=reload,
|
|
40
|
+
reload_dirs=["privaite", "config"] if reload else None,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
main()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
|
|
10
|
+
from privaite.config.schema import PrivAiTeConfig
|
|
11
|
+
|
|
12
|
+
_ENV_VAR_PATTERN = re.compile(r"\$\{([^}]+)}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _interpolate_env_vars(obj: object) -> object:
|
|
16
|
+
if isinstance(obj, str):
|
|
17
|
+
def _replace(match: re.Match) -> str:
|
|
18
|
+
var_name = match.group(1)
|
|
19
|
+
value = os.environ.get(var_name)
|
|
20
|
+
if value is None:
|
|
21
|
+
raise ValueError(f"Environment variable '{var_name}' is not set")
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
return _ENV_VAR_PATTERN.sub(_replace, obj)
|
|
25
|
+
|
|
26
|
+
if isinstance(obj, dict):
|
|
27
|
+
return {k: _interpolate_env_vars(v) for k, v in obj.items()}
|
|
28
|
+
|
|
29
|
+
if isinstance(obj, list):
|
|
30
|
+
return [_interpolate_env_vars(item) for item in obj]
|
|
31
|
+
|
|
32
|
+
return obj
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_config(path: str | Path | None = None) -> PrivAiTeConfig:
|
|
36
|
+
# override=False so the explicit environment (e.g. PRIVAITE_CONFIG_PATH set by
|
|
37
|
+
# the CLI from --config) wins over .env; .env only fills in variables that are
|
|
38
|
+
# not already set, such as OPENAI_API_KEY.
|
|
39
|
+
load_dotenv(override=False)
|
|
40
|
+
|
|
41
|
+
if path is None:
|
|
42
|
+
path = os.environ.get("PRIVAITE_CONFIG_PATH", "config/privaite.yaml")
|
|
43
|
+
|
|
44
|
+
path = Path(path)
|
|
45
|
+
|
|
46
|
+
if not path.exists():
|
|
47
|
+
return PrivAiTeConfig()
|
|
48
|
+
|
|
49
|
+
with open(path) as f:
|
|
50
|
+
raw = yaml.safe_load(f) or {}
|
|
51
|
+
|
|
52
|
+
interpolated = _interpolate_env_vars(raw)
|
|
53
|
+
return PrivAiTeConfig.model_validate(interpolated)
|