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.
Files changed (51) hide show
  1. privaite/__init__.py +1 -0
  2. privaite/__main__.py +3 -0
  3. privaite/api/__init__.py +0 -0
  4. privaite/api/chat.py +126 -0
  5. privaite/api/completions.py +108 -0
  6. privaite/api/dependencies.py +22 -0
  7. privaite/api/embeddings.py +71 -0
  8. privaite/api/health.py +48 -0
  9. privaite/api/models.py +25 -0
  10. privaite/api/router.py +12 -0
  11. privaite/app.py +78 -0
  12. privaite/cli.py +45 -0
  13. privaite/config/__init__.py +4 -0
  14. privaite/config/loader.py +53 -0
  15. privaite/config/schema.py +184 -0
  16. privaite/middleware/__init__.py +0 -0
  17. privaite/middleware/auth.py +61 -0
  18. privaite/middleware/limits.py +103 -0
  19. privaite/pii/__init__.py +0 -0
  20. privaite/pii/anonymizer.py +68 -0
  21. privaite/pii/deanonymizer.py +66 -0
  22. privaite/pii/detector_base.py +23 -0
  23. privaite/pii/detector_bert_ner.py +93 -0
  24. privaite/pii/detector_mlmodel.py +116 -0
  25. privaite/pii/detector_onnx.py +262 -0
  26. privaite/pii/detector_presidio.py +158 -0
  27. privaite/pii/engine.py +315 -0
  28. privaite/pii/entity.py +88 -0
  29. privaite/pii/faker_providers.py +83 -0
  30. privaite/pii/mapping.py +43 -0
  31. privaite/pii/recognizer_context.py +106 -0
  32. privaite/pii/recognizer_custom.py +45 -0
  33. privaite/pii/recognizer_fr_date.py +60 -0
  34. privaite/pii/recognizer_location.py +58 -0
  35. privaite/pii/tracker.py +63 -0
  36. privaite/providers/__init__.py +0 -0
  37. privaite/providers/router.py +58 -0
  38. privaite/streaming/__init__.py +0 -0
  39. privaite/streaming/buffer.py +80 -0
  40. privaite/streaming/handler.py +77 -0
  41. privaite/streaming/sse.py +32 -0
  42. privaite/utils/__init__.py +0 -0
  43. privaite/utils/errors.py +59 -0
  44. privaite/utils/logging.py +26 -0
  45. privaite/utils/security.py +18 -0
  46. privaite-0.2.3.dist-info/METADATA +351 -0
  47. privaite-0.2.3.dist-info/RECORD +51 -0
  48. privaite-0.2.3.dist-info/WHEEL +5 -0
  49. privaite-0.2.3.dist-info/entry_points.txt +2 -0
  50. privaite-0.2.3.dist-info/licenses/LICENSE +28 -0
  51. 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
@@ -0,0 +1,3 @@
1
+ from privaite.cli import main
2
+
3
+ main()
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,4 @@
1
+ from privaite.config.loader import load_config
2
+ from privaite.config.schema import PrivAiTeConfig
3
+
4
+ __all__ = ["load_config", "PrivAiTeConfig"]
@@ -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)