aegra-api 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 (64) hide show
  1. aegra_api/__init__.py +3 -0
  2. aegra_api/api/__init__.py +1 -0
  3. aegra_api/api/assistants.py +235 -0
  4. aegra_api/api/runs.py +1110 -0
  5. aegra_api/api/store.py +200 -0
  6. aegra_api/api/threads.py +761 -0
  7. aegra_api/config.py +204 -0
  8. aegra_api/constants.py +5 -0
  9. aegra_api/core/__init__.py +0 -0
  10. aegra_api/core/app_loader.py +91 -0
  11. aegra_api/core/auth_ctx.py +65 -0
  12. aegra_api/core/auth_deps.py +186 -0
  13. aegra_api/core/auth_handlers.py +248 -0
  14. aegra_api/core/auth_middleware.py +331 -0
  15. aegra_api/core/database.py +123 -0
  16. aegra_api/core/health.py +131 -0
  17. aegra_api/core/orm.py +165 -0
  18. aegra_api/core/route_merger.py +69 -0
  19. aegra_api/core/serializers/__init__.py +7 -0
  20. aegra_api/core/serializers/base.py +22 -0
  21. aegra_api/core/serializers/general.py +54 -0
  22. aegra_api/core/serializers/langgraph.py +102 -0
  23. aegra_api/core/sse.py +178 -0
  24. aegra_api/main.py +303 -0
  25. aegra_api/middleware/__init__.py +4 -0
  26. aegra_api/middleware/double_encoded_json.py +74 -0
  27. aegra_api/middleware/logger_middleware.py +95 -0
  28. aegra_api/models/__init__.py +76 -0
  29. aegra_api/models/assistants.py +81 -0
  30. aegra_api/models/auth.py +62 -0
  31. aegra_api/models/enums.py +29 -0
  32. aegra_api/models/errors.py +29 -0
  33. aegra_api/models/runs.py +124 -0
  34. aegra_api/models/store.py +67 -0
  35. aegra_api/models/threads.py +152 -0
  36. aegra_api/observability/__init__.py +1 -0
  37. aegra_api/observability/base.py +88 -0
  38. aegra_api/observability/otel.py +133 -0
  39. aegra_api/observability/setup.py +27 -0
  40. aegra_api/observability/targets/__init__.py +11 -0
  41. aegra_api/observability/targets/base.py +18 -0
  42. aegra_api/observability/targets/langfuse.py +33 -0
  43. aegra_api/observability/targets/otlp.py +38 -0
  44. aegra_api/observability/targets/phoenix.py +24 -0
  45. aegra_api/services/__init__.py +0 -0
  46. aegra_api/services/assistant_service.py +569 -0
  47. aegra_api/services/base_broker.py +59 -0
  48. aegra_api/services/broker.py +141 -0
  49. aegra_api/services/event_converter.py +157 -0
  50. aegra_api/services/event_store.py +196 -0
  51. aegra_api/services/graph_streaming.py +433 -0
  52. aegra_api/services/langgraph_service.py +456 -0
  53. aegra_api/services/streaming_service.py +362 -0
  54. aegra_api/services/thread_state_service.py +128 -0
  55. aegra_api/settings.py +124 -0
  56. aegra_api/utils/__init__.py +3 -0
  57. aegra_api/utils/assistants.py +23 -0
  58. aegra_api/utils/run_utils.py +60 -0
  59. aegra_api/utils/setup_logging.py +122 -0
  60. aegra_api/utils/sse_utils.py +26 -0
  61. aegra_api/utils/status_compat.py +57 -0
  62. aegra_api-0.1.0.dist-info/METADATA +244 -0
  63. aegra_api-0.1.0.dist-info/RECORD +64 -0
  64. aegra_api-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,248 @@
1
+ """Authorization handler support for @auth.on.* decorators.
2
+
3
+ This module provides integration with authorization handlers,
4
+ allowing users to define fine-grained access control rules using @auth.on.*
5
+ decorators in their auth.py files.
6
+ """
7
+
8
+ from typing import Any
9
+
10
+ from fastapi import HTTPException
11
+ from langgraph_sdk import Auth
12
+ from langgraph_sdk.auth.types import AuthContext as LangGraphAuthContext
13
+
14
+ from aegra_api.core.auth_middleware import get_auth_instance
15
+ from aegra_api.models.auth import User
16
+
17
+
18
+ class AuthContextWrapper:
19
+ """Wrapper to convert Aegra User model to AuthContext.
20
+
21
+ AuthContext expects a BaseUser-compatible object. Our User model
22
+ implements the BaseUser protocol (identity, permissions, display_name, __getitem__),
23
+ so we can use it directly after ensuring compatibility.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ user: User,
29
+ resource: str,
30
+ action: str,
31
+ ):
32
+ """Initialize auth context wrapper.
33
+
34
+ Args:
35
+ user: Authenticated user from Aegra's User model
36
+ resource: Resource being accessed (e.g., "threads", "assistants")
37
+ action: Action being performed (e.g., "create", "read", "update")
38
+ """
39
+ self.user = user
40
+ self.resource = resource
41
+ self.action = action
42
+ self.permissions = user.permissions or []
43
+
44
+ def to_langgraph_context(self) -> LangGraphAuthContext:
45
+ """Convert to LangGraph AuthContext.
46
+
47
+ Our User model implements the BaseUser protocol (identity, permissions,
48
+ display_name, __getitem__, __contains__, __iter__), so it's compatible
49
+ with LangGraph's AuthContext.
50
+
51
+ Returns:
52
+ AuthContext instance compatible with @auth.on handlers
53
+ """
54
+ return LangGraphAuthContext(
55
+ user=self.user, # Our User model implements BaseUser protocol
56
+ resource=self.resource, # type: ignore
57
+ action=self.action, # type: ignore
58
+ permissions=self.permissions,
59
+ )
60
+
61
+
62
+ async def handle_event(
63
+ ctx: AuthContextWrapper | None,
64
+ value: dict[str, Any],
65
+ ) -> dict[str, Any] | None:
66
+ """Call the appropriate @auth.on.* handler for authorization.
67
+
68
+ This function resolves the most specific handler for the given resource
69
+ and action, calls it with the auth context and value, and interprets
70
+ the result.
71
+
72
+ **Default Behavior (Non-Interruptive):**
73
+ - If no auth is configured → allows by default (returns None)
74
+ - If no handlers are defined → allows by default (returns None)
75
+ - If handler returns None/True → allows (returns None)
76
+ - If handler returns dict → allows with filters applied (returns dict)
77
+ - **Only interrupts if handler returns False or raises exception**
78
+
79
+ This ensures that developers using raw Aegra without custom authorization
80
+ handlers will have a working system out-of-the-box. Handlers are purely
81
+ additive - they can inject metadata, apply filters, or deny access, but
82
+ they don't interrupt the flow unless explicitly configured to do so.
83
+
84
+ Handler resolution priority (most specific first):
85
+ 1. Resource+action specific (e.g., "threads", "create")
86
+ 2. Resource-specific (e.g., "threads", "*")
87
+ 3. Action-specific (e.g., "*", "create")
88
+ 4. Global handler ("*", "*")
89
+
90
+ Args:
91
+ ctx: Auth context wrapper with user, resource, and action
92
+ value: The data being authorized (request body, search filters, etc.)
93
+ This dict may be modified by the handler (e.g., injecting metadata)
94
+
95
+ Returns:
96
+ None: Request allowed, no filters to apply
97
+ dict: Filter dict to apply to queries (e.g., {"user_id": "123"})
98
+
99
+ Raises:
100
+ HTTPException(403): If handler returns False or raises AssertionError
101
+ HTTPException(500): If handler raises unexpected exception or returns invalid type
102
+ """
103
+ if ctx is None:
104
+ # No auth context means no authorization check needed
105
+ # This allows the request to proceed normally
106
+ return None
107
+
108
+ auth = get_auth_instance()
109
+ if auth is None:
110
+ # No auth configured, allow by default
111
+ # This ensures raw Aegra works out-of-the-box without interruption
112
+ return None
113
+
114
+ # Convert to AuthContext
115
+ auth_ctx = ctx.to_langgraph_context()
116
+
117
+ # Find the most specific handler
118
+ handler = _get_handler(auth, auth_ctx.resource, auth_ctx.action)
119
+ if handler is None:
120
+ # No handler for this resource/action, allow by default
121
+ # Developers can use Aegra without defining handlers - it won't break
122
+ return None
123
+
124
+ try:
125
+ # Call the handler with context and value
126
+ result = await handler(ctx=auth_ctx, value=value)
127
+ except Auth.exceptions.HTTPException as e:
128
+ # Handler raised HTTP exception, convert to FastAPI HTTPException
129
+ raise HTTPException(
130
+ status_code=e.status_code,
131
+ detail=e.detail,
132
+ headers=dict(e.headers) if hasattr(e, "headers") and e.headers else None,
133
+ ) from e
134
+ except AssertionError as e:
135
+ # Handler used assert for authorization check
136
+ raise HTTPException(status_code=403, detail=str(e)) from e
137
+ except Exception as e:
138
+ # Unexpected error in handler
139
+ raise HTTPException(status_code=500, detail=f"Authorization error: {str(e)}") from e
140
+
141
+ # Interpret handler result
142
+ if result in (None, True):
143
+ # Allow request, no filters
144
+ return None
145
+
146
+ if result is False:
147
+ # Deny request
148
+ raise HTTPException(status_code=403, detail="Forbidden")
149
+
150
+ if isinstance(result, dict):
151
+ # Return filter dict to apply to queries
152
+ return result
153
+
154
+ # Invalid return type
155
+ raise HTTPException(
156
+ status_code=500,
157
+ detail=f"Auth handler returned invalid type: {type(result)}. Expected dict, None, True, or False.",
158
+ )
159
+
160
+
161
+ def _get_handler(
162
+ auth: Auth,
163
+ resource: str,
164
+ action: str,
165
+ ) -> Any | None:
166
+ """Find the most specific handler for resource+action.
167
+
168
+ Handler resolution follows this priority order (most specific first):
169
+ 1. (resource, action) - e.g., ("threads", "create")
170
+ 2. (resource, "*") - e.g., ("threads", "*")
171
+ 3. ("*", action) - e.g., ("*", "create")
172
+ 4. ("*", "*") - global handler
173
+
174
+ Args:
175
+ auth: Auth instance with registered handlers
176
+ resource: Resource name (e.g., "threads", "assistants")
177
+ action: Action name (e.g., "create", "read", "update")
178
+
179
+ Returns:
180
+ Handler function or None if no handler found
181
+ """
182
+ # Check cache first
183
+ key = (resource, action)
184
+ if key in auth._handler_cache:
185
+ return auth._handler_cache[key]
186
+
187
+ # Priority order (most specific first)
188
+ keys = [
189
+ (resource, action), # Most specific: exact resource+action
190
+ (resource, "*"), # Resource-specific: all actions on resource
191
+ ("*", action), # Action-specific: all resources for action
192
+ ("*", "*"), # Global: all resources and actions
193
+ ]
194
+
195
+ # Find first matching handler
196
+ for check_key in keys:
197
+ if check_key in auth._handlers and auth._handlers[check_key]:
198
+ # Get the last registered handler (most recent wins)
199
+ handler = auth._handlers[check_key][-1]
200
+ # Cache the result
201
+ auth._handler_cache[key] = handler
202
+ return handler
203
+
204
+ # Check global handlers (fallback for backward compatibility)
205
+ if auth._global_handlers:
206
+ handler = auth._global_handlers[-1]
207
+ auth._handler_cache[key] = handler
208
+ return handler
209
+
210
+ return None
211
+
212
+
213
+ def build_auth_context(
214
+ user: User,
215
+ resource: str,
216
+ action: str,
217
+ ) -> AuthContextWrapper:
218
+ """Build AuthContextWrapper from user and operation info.
219
+
220
+ This is a convenience function to create an AuthContextWrapper with
221
+ the correct resource and action for a given operation.
222
+
223
+ Args:
224
+ user: Authenticated user from require_auth dependency
225
+ resource: Resource being accessed (e.g., "threads", "assistants")
226
+ action: Action being performed (e.g., "create", "read", "update")
227
+
228
+ Returns:
229
+ AuthContextWrapper instance ready to pass to handle_event()
230
+
231
+ Example:
232
+ ```python
233
+ @router.post("/threads")
234
+ async def create_thread(
235
+ request: ThreadCreate,
236
+ user: User = Depends(require_auth),
237
+ ):
238
+ ctx = build_auth_context(user, "threads", "create")
239
+ value = request.model_dump()
240
+ filters = await handle_event(ctx, value)
241
+ # Use filters or modified value...
242
+ ```
243
+ """
244
+ return AuthContextWrapper(
245
+ user=user,
246
+ resource=resource,
247
+ action=action,
248
+ )
@@ -0,0 +1,331 @@
1
+ """
2
+ Authentication middleware integration for Aegra.
3
+
4
+ This module integrates authentication system with FastAPI
5
+ using Starlette's AuthenticationMiddleware.
6
+ """
7
+
8
+ import functools
9
+ import importlib
10
+ import importlib.util
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import structlog
16
+ from langgraph_sdk import Auth
17
+ from langgraph_sdk.auth.types import MinimalUserDict
18
+ from starlette.authentication import (
19
+ AuthCredentials,
20
+ AuthenticationBackend,
21
+ AuthenticationError,
22
+ BaseUser,
23
+ )
24
+ from starlette.requests import HTTPConnection
25
+ from starlette.responses import JSONResponse
26
+
27
+ from aegra_api.config import get_config_dir, load_auth_config
28
+ from aegra_api.models.errors import AgentProtocolError
29
+ from aegra_api.settings import settings
30
+
31
+ logger = structlog.getLogger(__name__)
32
+
33
+
34
+ class LangGraphUser(BaseUser):
35
+ """
36
+ User wrapper that implements Starlette's BaseUser interface
37
+ while preserving auth data.
38
+ """
39
+
40
+ def __init__(self, user_data: Auth.types.MinimalUserDict):
41
+ self._user_data = user_data
42
+
43
+ @property
44
+ def identity(self) -> str:
45
+ return self._user_data["identity"]
46
+
47
+ @property
48
+ def is_authenticated(self) -> bool:
49
+ return self._user_data.get("is_authenticated", True)
50
+
51
+ @property
52
+ def display_name(self) -> str:
53
+ return self._user_data.get("display_name", self.identity)
54
+
55
+ def __getattr__(self, name: str) -> Any:
56
+ """Allow access to any additional fields from auth data"""
57
+ if name in self._user_data:
58
+ return self._user_data[name]
59
+ raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
60
+
61
+ def to_dict(self) -> MinimalUserDict:
62
+ """Return the underlying user data dict"""
63
+ return self._user_data.copy()
64
+
65
+
66
+ class LangGraphAuthBackend(AuthenticationBackend):
67
+ """
68
+ Authentication backend that uses the auth system.
69
+
70
+ This bridges @auth.authenticate handlers with
71
+ Starlette's AuthenticationMiddleware.
72
+ """
73
+
74
+ def __init__(self) -> None:
75
+ self.auth_instance = self._load_auth_instance()
76
+
77
+ def _load_auth_instance(self) -> Auth | None:
78
+ """Load the auth instance from config or fallback to hardcoded candidates.
79
+
80
+ Resolution order:
81
+ 1. Load from aegra.json auth.path config
82
+ 2. If no auth file found, returns None (noop handled directly in authenticate())
83
+
84
+ Returns:
85
+ Auth instance or None if not found (noop handled in authenticate() method)
86
+ """
87
+ # 1. Try loading from config
88
+ try:
89
+ auth_config = load_auth_config()
90
+ if auth_config and "path" in auth_config:
91
+ auth_path = auth_config["path"]
92
+ logger.info(f"Loading auth from config path: {auth_path}")
93
+ auth_instance = self._load_from_path(auth_path)
94
+ if auth_instance:
95
+ return auth_instance
96
+ logger.warning(f"Failed to load auth from config path: {auth_path}")
97
+ except Exception as e:
98
+ logger.warning(f"Error loading auth config: {e}")
99
+
100
+ logger.debug("No auth instance found from config")
101
+ return None
102
+
103
+ def _load_from_path(self, path: str) -> Auth | None:
104
+ """Load auth instance from path in format './file.py:var' or 'module:var'.
105
+
106
+ Relative paths are resolved from the config file directory.
107
+
108
+ Args:
109
+ path: Import path in format './file.py:variable' or 'module.path:variable'
110
+
111
+ Returns:
112
+ Auth instance or None if loading fails
113
+ """
114
+ if ":" not in path:
115
+ logger.error(f"Invalid auth path format (missing ':'): {path}")
116
+ return None
117
+
118
+ module_path, var_name = path.rsplit(":", 1)
119
+
120
+ # Handle file path format: ./file.py or ./path/to/file.py or ../file.py
121
+ is_file_path = module_path.endswith(".py") or module_path.startswith("./") or module_path.startswith("../")
122
+ if is_file_path:
123
+ file_path = Path(module_path)
124
+
125
+ # Resolve relative paths from config directory
126
+ if not file_path.is_absolute():
127
+ config_dir = get_config_dir()
128
+ if config_dir:
129
+ file_path = (config_dir / file_path).resolve()
130
+ else:
131
+ # Fallback to CWD if no config found
132
+ file_path = (Path.cwd() / file_path).resolve()
133
+
134
+ return self._load_from_file(file_path, var_name)
135
+
136
+ # Handle module format: module.path
137
+ return self._load_from_module(module_path, var_name)
138
+
139
+ def _load_from_file(self, file_path: Path, var_name: str) -> Auth | None:
140
+ """Load auth instance from a file path.
141
+
142
+ Args:
143
+ file_path: Path to the Python file
144
+ var_name: Name of the variable to load
145
+
146
+ Returns:
147
+ Auth instance or None if loading fails
148
+ """
149
+ try:
150
+ if not file_path.exists():
151
+ logger.warning(f"Auth file not found: {file_path}")
152
+ return None
153
+
154
+ if not file_path.is_file():
155
+ logger.warning(f"Auth path is not a file: {file_path} (is directory: {file_path.is_dir()})")
156
+ return None
157
+
158
+ # Create a unique module name based on the file path
159
+ module_name = f"auth_module_{file_path.stem}"
160
+
161
+ spec = importlib.util.spec_from_file_location(module_name, str(file_path))
162
+ if spec is None or spec.loader is None:
163
+ logger.error(f"Could not load auth module from {file_path}")
164
+ return None
165
+
166
+ auth_module = importlib.util.module_from_spec(spec)
167
+ sys.modules[module_name] = auth_module
168
+ spec.loader.exec_module(auth_module)
169
+
170
+ auth_instance = getattr(auth_module, var_name, None)
171
+ if not isinstance(auth_instance, Auth):
172
+ logger.error(f"Variable '{var_name}' in {file_path} is not an Auth instance")
173
+ return None
174
+
175
+ logger.info(f"Successfully loaded auth instance from {file_path}:{var_name}")
176
+ return auth_instance
177
+
178
+ except Exception as e:
179
+ logger.error(f"Error loading auth from {file_path}: {e}", exc_info=True)
180
+ return None
181
+
182
+ def _load_from_module(self, module_path: str, var_name: str) -> Auth | None:
183
+ """Load auth instance from an installed module.
184
+
185
+ Args:
186
+ module_path: Dotted module path (e.g., 'mypackage.auth')
187
+ var_name: Name of the variable to load
188
+
189
+ Returns:
190
+ Auth instance or None if loading fails
191
+ """
192
+ try:
193
+ module = importlib.import_module(module_path)
194
+ auth_instance = getattr(module, var_name, None)
195
+
196
+ if not isinstance(auth_instance, Auth):
197
+ logger.error(f"Variable '{var_name}' in module {module_path} is not an Auth instance")
198
+ return None
199
+
200
+ logger.info(f"Successfully loaded auth instance from {module_path}:{var_name}")
201
+ return auth_instance
202
+
203
+ except ImportError as e:
204
+ logger.error(f"Could not import module {module_path}: {e}")
205
+ return None
206
+ except Exception as e:
207
+ logger.error(f"Error loading auth from {module_path}: {e}", exc_info=True)
208
+ return None
209
+
210
+ async def authenticate(self, conn: HTTPConnection) -> tuple[AuthCredentials, BaseUser] | None:
211
+ """
212
+ Authenticate request using the configured auth system.
213
+
214
+ Args:
215
+ conn: HTTP connection containing request headers
216
+
217
+ Returns:
218
+ Tuple of (credentials, user) if authenticated, None otherwise
219
+
220
+ Raises:
221
+ AuthenticationError: If authentication fails
222
+ """
223
+ # Handle noop auth when no auth instance is configured
224
+ # Default to noop (anonymous) authentication when no auth file is found,
225
+ # regardless of AUTH_TYPE setting. This ensures the server works out-of-the-box.
226
+ if self.auth_instance is None:
227
+ logger.debug("No auth file configured, defaulting to noop (anonymous) authentication")
228
+ # Return anonymous user when no auth is configured
229
+ user_data: Auth.types.MinimalUserDict = {
230
+ "identity": "anonymous",
231
+ "display_name": "Anonymous User",
232
+ "is_authenticated": True,
233
+ }
234
+ credentials = AuthCredentials([])
235
+ user = LangGraphUser(user_data)
236
+ return credentials, user
237
+
238
+ if self.auth_instance._authenticate_handler is None:
239
+ logger.warning("No authenticate handler configured, skipping authentication")
240
+ return None
241
+
242
+ try:
243
+ # Convert headers to dict format expected by auth handlers
244
+ headers = {
245
+ key.decode() if isinstance(key, bytes) else key: value.decode() if isinstance(value, bytes) else value
246
+ for key, value in conn.headers.items()
247
+ }
248
+
249
+ # Call the authenticate handler
250
+ user_data = await self.auth_instance._authenticate_handler(headers)
251
+
252
+ if not user_data or not isinstance(user_data, dict):
253
+ raise AuthenticationError("Invalid user data returned from auth handler")
254
+
255
+ if "identity" not in user_data:
256
+ raise AuthenticationError("Auth handler must return 'identity' field")
257
+
258
+ # Extract permissions for credentials
259
+ permissions = user_data.get("permissions", [])
260
+ if isinstance(permissions, str):
261
+ permissions = [permissions]
262
+
263
+ # Create Starlette-compatible user and credentials
264
+ credentials = AuthCredentials(permissions)
265
+ user = LangGraphUser(user_data)
266
+
267
+ logger.debug(f"Successfully authenticated user: {user.identity}")
268
+ return credentials, user
269
+
270
+ except Auth.exceptions.HTTPException as e:
271
+ logger.warning(f"Authentication failed: {e.detail}")
272
+ raise AuthenticationError(e.detail) from e
273
+
274
+ except Exception as e:
275
+ logger.error(f"Unexpected error during authentication: {e}", exc_info=True)
276
+ raise AuthenticationError("Authentication system error") from e
277
+
278
+
279
+ def get_auth_backend() -> AuthenticationBackend:
280
+ """
281
+ Get authentication backend based on AUTH_TYPE environment variable.
282
+
283
+ Returns:
284
+ AuthenticationBackend instance
285
+ """
286
+ auth_type = settings.app.AUTH_TYPE
287
+
288
+ if auth_type in ["noop", "custom"]:
289
+ logger.debug(f"Using auth backend with type: {auth_type}")
290
+ return LangGraphAuthBackend()
291
+ else:
292
+ logger.warning(f"Unknown AUTH_TYPE: {auth_type}, using noop")
293
+ return LangGraphAuthBackend()
294
+
295
+
296
+ def on_auth_error(conn: HTTPConnection, exc: AuthenticationError) -> JSONResponse:
297
+ """
298
+ Handle authentication errors in Agent Protocol format.
299
+
300
+ Args:
301
+ conn: HTTP connection
302
+ exc: Authentication error
303
+
304
+ Returns:
305
+ JSON response with Agent Protocol error format
306
+ """
307
+ logger.warning(f"Authentication error for {conn.url}: {exc}")
308
+
309
+ return JSONResponse(
310
+ status_code=401,
311
+ content=AgentProtocolError(
312
+ error="unauthorized",
313
+ message=str(exc),
314
+ details={"authentication_required": True},
315
+ ).model_dump(),
316
+ )
317
+
318
+
319
+ @functools.lru_cache(maxsize=1)
320
+ def get_auth_instance() -> Auth | None:
321
+ """Get cached Auth instance for use by other modules.
322
+
323
+ Uses LRU cache to ensure only one Auth instance is loaded per process.
324
+ This allows other modules to access the same Auth instance used by
325
+ the middleware without re-loading it.
326
+
327
+ Returns:
328
+ Auth instance or None if not configured/found
329
+ """
330
+ backend = LangGraphAuthBackend()
331
+ return backend.auth_instance
@@ -0,0 +1,123 @@
1
+ """Database manager with LangGraph integration"""
2
+
3
+ import structlog
4
+ from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
5
+ from langgraph.store.postgres.aio import AsyncPostgresStore
6
+ from psycopg.rows import dict_row
7
+ from psycopg_pool import AsyncConnectionPool
8
+ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
9
+
10
+ from aegra_api.config import load_store_config
11
+ from aegra_api.settings import settings
12
+
13
+ logger = structlog.get_logger(__name__)
14
+
15
+
16
+ class DatabaseManager:
17
+ """Manages database connections and LangGraph persistence components"""
18
+
19
+ def __init__(self) -> None:
20
+ self.engine: AsyncEngine | None = None
21
+
22
+ # Shared pool for LangGraph components (Checkpointer + Store)
23
+ self.lg_pool: AsyncConnectionPool | None = None
24
+ self._checkpointer: AsyncPostgresSaver | None = None
25
+ self._store: AsyncPostgresStore | None = None
26
+ self._database_url = settings.db.database_url
27
+
28
+ async def initialize(self) -> None:
29
+ """Initialize database connections and LangGraph components"""
30
+ # Idempotency check: if already initialized, do nothing
31
+ if self.engine:
32
+ return
33
+
34
+ # 1. SQLAlchemy Engine (app metadata, uses asyncpg)
35
+ # We strictly limit this pool because the main load
36
+ # is handled by LangGraph components.
37
+ self.engine = create_async_engine(
38
+ self._database_url,
39
+ pool_size=settings.pool.SQLALCHEMY_POOL_SIZE,
40
+ max_overflow=settings.pool.SQLALCHEMY_MAX_OVERFLOW,
41
+ pool_pre_ping=True,
42
+ echo=settings.db.DB_ECHO_LOG,
43
+ )
44
+
45
+ lg_max = settings.pool.LANGGRAPH_MAX_POOL_SIZE
46
+ lg_kwargs = {
47
+ "autocommit": True,
48
+ "prepare_threshold": 0, # Optimization for PgBouncer/Kubernetes compatibility
49
+ "row_factory": dict_row, # LangGraph requires dictionary rows, not tuples
50
+ }
51
+
52
+ # Create a single shared pool.
53
+ # 'open=False' is important to avoid RuntimeWarning; we open it explicitly below.
54
+ self.lg_pool = AsyncConnectionPool(
55
+ conninfo=settings.db.database_url_sync,
56
+ min_size=settings.pool.LANGGRAPH_MIN_POOL_SIZE,
57
+ max_size=lg_max,
58
+ open=False,
59
+ kwargs=lg_kwargs,
60
+ check=AsyncConnectionPool.check_connection,
61
+ )
62
+
63
+ # Explicitly open the pool
64
+ await self.lg_pool.open()
65
+
66
+ # 2. Initialize LangGraph components using the shared pool
67
+ # Passing 'conn=self.lg_pool' prevents components from creating their own pools.
68
+
69
+ logger.info(f"Initializing LangGraph components with shared pool (max {lg_max} conns)...")
70
+
71
+ self._checkpointer = AsyncPostgresSaver(conn=self.lg_pool)
72
+ await self._checkpointer.setup() # Ensure tables exist
73
+
74
+ # Load store configuration for semantic search (if configured)
75
+ store_config = load_store_config()
76
+ index_config = store_config.get("index") if store_config else None
77
+
78
+ self._store = AsyncPostgresStore(conn=self.lg_pool, index=index_config)
79
+ await self._store.setup() # Ensure tables exist
80
+
81
+ if index_config:
82
+ embed_model = index_config.get("embed", "unknown")
83
+ logger.info(f"Semantic store enabled with embeddings: {embed_model}")
84
+
85
+ logger.info("✅ Database and LangGraph components initialized")
86
+
87
+ async def close(self) -> None:
88
+ """Close database connections"""
89
+ # Close SQLAlchemy engine
90
+ if self.engine:
91
+ await self.engine.dispose()
92
+ self.engine = None
93
+
94
+ # Close shared LangGraph pool
95
+ if self.lg_pool:
96
+ await self.lg_pool.close()
97
+ self.lg_pool = None
98
+ self._checkpointer = None
99
+ self._store = None
100
+
101
+ logger.info("✅ Database connections closed")
102
+
103
+ def get_checkpointer(self) -> AsyncPostgresSaver:
104
+ """Return the live AsyncPostgresSaver instance."""
105
+ if self._checkpointer is None:
106
+ raise RuntimeError("Database not initialized")
107
+ return self._checkpointer
108
+
109
+ def get_store(self) -> AsyncPostgresStore:
110
+ """Return the live AsyncPostgresStore instance."""
111
+ if self._store is None:
112
+ raise RuntimeError("Database not initialized")
113
+ return self._store
114
+
115
+ def get_engine(self) -> AsyncEngine:
116
+ """Get the SQLAlchemy engine for metadata tables"""
117
+ if not self.engine:
118
+ raise RuntimeError("Database not initialized")
119
+ return self.engine
120
+
121
+
122
+ # Global database manager instance
123
+ db_manager = DatabaseManager()