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
aegra_api/config.py ADDED
@@ -0,0 +1,204 @@
1
+ """Configuration management for Aegra HTTP settings"""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import TypedDict
6
+
7
+ import structlog
8
+
9
+ from aegra_api.settings import settings
10
+
11
+ logger = structlog.get_logger(__name__)
12
+
13
+
14
+ class CorsConfig(TypedDict, total=False):
15
+ """CORS configuration options"""
16
+
17
+ allow_origins: list[str]
18
+ allow_methods: list[str]
19
+ allow_headers: list[str]
20
+ allow_credentials: bool
21
+ expose_headers: list[str]
22
+ max_age: int
23
+
24
+
25
+ class HttpConfig(TypedDict, total=False):
26
+ """HTTP configuration options for custom routes"""
27
+
28
+ app: str
29
+ """Import path for custom Starlette/FastAPI app to mount"""
30
+ enable_custom_route_auth: bool
31
+ """Apply Aegra authentication dependency to custom routes (uses FastAPI dependencies, not middleware)"""
32
+ cors: CorsConfig | None
33
+ """Custom CORS configuration"""
34
+
35
+
36
+ class StoreIndexConfig(TypedDict, total=False):
37
+ """Configuration for vector embeddings in store.
38
+
39
+ Enables semantic similarity search using pgvector.
40
+ See: https://github.com/ibbybuilds/aegra/issues/104
41
+ """
42
+
43
+ dims: int
44
+ """Embedding vector dimensions (e.g., 1536 for OpenAI text-embedding-3-small)"""
45
+ embed: str
46
+ """Embedding model in format '<provider>:<model-id>'
47
+ Examples:
48
+ - openai:text-embedding-3-small (1536 dims)
49
+ - openai:text-embedding-3-large (3072 dims)
50
+ - bedrock:amazon.titan-embed-text-v2:0 (1024 dims)
51
+ - cohere:embed-english-v3.0 (1024 dims)
52
+ """
53
+ fields: list[str] | None
54
+ """JSON fields to embed. Defaults to ["$"] (entire document).
55
+ Examples:
56
+ - ["$"] - Embed entire document as one unit
57
+ - ["text", "summary"] - Embed specific top-level fields
58
+ - ["metadata.title", "content.text"] - JSON path notation
59
+ """
60
+
61
+
62
+ class StoreConfig(TypedDict, total=False):
63
+ """Store configuration options"""
64
+
65
+ index: StoreIndexConfig | None
66
+ """Vector index configuration for semantic search"""
67
+
68
+
69
+ class AuthConfig(TypedDict, total=False):
70
+ """Auth configuration options."""
71
+
72
+ path: str
73
+ """Import path for auth handler in format './file.py:variable' or 'module:variable'.
74
+ Examples:
75
+ - './auth.py:auth' - Load 'auth' from auth.py in project root
76
+ - './src/auth/firebase.py:auth' - Load from nested path
77
+ - 'mypackage.auth:auth' - Load from installed package
78
+ """
79
+ disable_studio_auth: bool
80
+ """Disable authentication for LangGraph Studio connections"""
81
+
82
+
83
+ def _resolve_config_path() -> Path | None:
84
+ """Resolve config file path using standard resolution order.
85
+
86
+ Resolution order:
87
+ 1) AEGRA_CONFIG env var (absolute or relative path) - returned even if doesn't exist
88
+ 2) aegra.json in CWD
89
+ 3) langgraph.json in CWD (fallback for compatibility)
90
+
91
+ Returns:
92
+ Path to config file or None if not found
93
+ """
94
+ # 1) Env var override - return even if doesn't exist (let caller handle error)
95
+ if env_path := settings.app.AEGRA_CONFIG:
96
+ return Path(env_path)
97
+
98
+ # 2) aegra.json if present
99
+ aegra_path = Path("aegra.json")
100
+ if aegra_path.exists():
101
+ return aegra_path
102
+
103
+ # 3) fallback to langgraph.json
104
+ langgraph_path = Path("langgraph.json")
105
+ if langgraph_path.exists():
106
+ return langgraph_path
107
+
108
+ return None
109
+
110
+
111
+ def load_config() -> dict | None:
112
+ """Load full config file using standard resolution order.
113
+
114
+ Returns:
115
+ Full config dict or None if not found
116
+ """
117
+ config_path = _resolve_config_path()
118
+ if not config_path:
119
+ return None
120
+
121
+ try:
122
+ with config_path.open() as f:
123
+ return json.load(f)
124
+ except Exception as e:
125
+ logger.warning(f"Failed to load config from {config_path}: {e}")
126
+ return None
127
+
128
+
129
+ def load_http_config() -> HttpConfig | None:
130
+ """Load HTTP config from aegra.json or langgraph.json.
131
+
132
+ Uses standard config resolution order.
133
+
134
+ Returns:
135
+ HTTP configuration dict or None if not found
136
+ """
137
+ config = load_config()
138
+ if config is None:
139
+ return None
140
+
141
+ http_config = config.get("http")
142
+ if http_config:
143
+ config_path = _resolve_config_path()
144
+ logger.info(f"Loaded HTTP config from {config_path}")
145
+ return http_config
146
+
147
+ return None
148
+
149
+
150
+ def load_store_config() -> StoreConfig | None:
151
+ """Load store config from aegra.json or langgraph.json.
152
+
153
+ Uses standard config resolution order.
154
+
155
+ Returns:
156
+ Store configuration dict or None if not found
157
+ """
158
+ config = load_config()
159
+ if config is None:
160
+ return None
161
+
162
+ store_config = config.get("store")
163
+ if store_config:
164
+ config_path = _resolve_config_path()
165
+ logger.info(f"Loaded store config from {config_path}")
166
+ return store_config
167
+
168
+ return None
169
+
170
+
171
+ def load_auth_config() -> AuthConfig | None:
172
+ """Load auth config from aegra.json or langgraph.json.
173
+
174
+ Uses standard config resolution order.
175
+
176
+ Returns:
177
+ Auth configuration dict or None if not found
178
+ """
179
+ config = load_config()
180
+ if config is None:
181
+ return None
182
+
183
+ auth_config = config.get("auth")
184
+ if auth_config:
185
+ config_path = _resolve_config_path()
186
+ logger.info(f"Loaded auth config from {config_path}")
187
+ return auth_config
188
+
189
+ return None
190
+
191
+
192
+ def get_config_dir() -> Path | None:
193
+ """Get the directory containing the config file.
194
+
195
+ This is used to resolve relative paths in the config file
196
+ (graphs, http.app, auth.path) relative to the config location.
197
+
198
+ Returns:
199
+ Path to config directory or None if no config found
200
+ """
201
+ config_path = _resolve_config_path()
202
+ if config_path and config_path.exists():
203
+ return config_path.parent.resolve()
204
+ return None
aegra_api/constants.py ADDED
@@ -0,0 +1,5 @@
1
+ from uuid import UUID
2
+
3
+ # Standard namespace UUID for deriving deterministic assistant IDs from graph IDs.
4
+ # IMPORTANT: Do not change after initial deploy unless you plan a data migration.
5
+ ASSISTANT_NAMESPACE_UUID = UUID("6ba7b821-9dad-11d1-80b4-00c04fd430c8")
File without changes
@@ -0,0 +1,91 @@
1
+ """Custom application loader for dynamic FastAPI/Starlette app imports"""
2
+
3
+ import importlib
4
+ import importlib.util
5
+ from pathlib import Path
6
+
7
+ import structlog
8
+ from fastapi import FastAPI
9
+
10
+ logger = structlog.get_logger(__name__)
11
+
12
+
13
+ def load_custom_app(app_import: str, base_dir: Path | None = None) -> FastAPI | None:
14
+ """Load custom FastAPI app from import path.
15
+
16
+ Supports both file-based and module-based imports:
17
+ - File path: "./custom_routes.py:app" or "/path/to/file.py:app"
18
+ - Module path: "my_package.custom:app"
19
+
20
+ Args:
21
+ app_import: Import path in format "path/to/file.py:variable" or "module.path:variable"
22
+ base_dir: Base directory for resolving relative file paths (e.g., config file directory)
23
+
24
+ Returns:
25
+ Loaded FastAPI app instance or None if path is invalid
26
+
27
+ Raises:
28
+ ImportError: If the module or file cannot be imported
29
+ AttributeError: If the specified variable is not found in the module
30
+ TypeError: If the loaded object is not a FastAPI application
31
+ """
32
+ logger.info(f"Loading custom app from {app_import}")
33
+
34
+ if ":" not in app_import:
35
+ raise ValueError(
36
+ f"Invalid app import path format: {app_import}. "
37
+ "Expected format: 'path/to/file.py:variable' or 'module.path:variable'"
38
+ )
39
+
40
+ path, name = app_import.rsplit(":", 1)
41
+
42
+ try:
43
+ # Determine if it's a file path or module path
44
+ path_obj = Path(path)
45
+ is_file_path = path_obj.suffix == ".py" or path.startswith("./") or path.startswith("../")
46
+
47
+ if is_file_path:
48
+ # Resolve relative paths from base_dir if provided
49
+ if not path_obj.is_absolute() and base_dir is not None:
50
+ path_obj = (base_dir / path_obj).resolve()
51
+
52
+ # Import from file path
53
+ if not path_obj.exists():
54
+ raise FileNotFoundError(f"Custom app file not found: {path_obj}")
55
+
56
+ spec = importlib.util.spec_from_file_location("custom_app_module", str(path_obj))
57
+ if spec is None or spec.loader is None:
58
+ raise ImportError(f"Cannot load spec from {path_obj}")
59
+
60
+ module = importlib.util.module_from_spec(spec)
61
+ spec.loader.exec_module(module)
62
+ else:
63
+ # Import as a normal module
64
+ module = importlib.import_module(path)
65
+
66
+ # Get the app instance from the module
67
+ if not hasattr(module, name):
68
+ raise AttributeError(
69
+ f"App '{name}' not found in module '{path}'. "
70
+ f"Available attributes: {[attr for attr in dir(module) if not attr.startswith('_')]}"
71
+ )
72
+
73
+ user_app = getattr(module, name)
74
+
75
+ # Validate it's a FastAPI application
76
+ if not isinstance(user_app, FastAPI):
77
+ raise TypeError(
78
+ f"Object '{name}' in module '{path}' is not a FastAPI application. "
79
+ "Custom apps must be FastAPI instances for proper OpenAPI support.\n"
80
+ "Please initialize your app using:\n\n"
81
+ "from fastapi import FastAPI\n\n"
82
+ "app = FastAPI()\n\n"
83
+ )
84
+
85
+ logger.info(f"Successfully loaded custom app '{name}' from {path}")
86
+ return user_app
87
+
88
+ except ImportError as e:
89
+ raise ImportError(f"Failed to import app module '{path}': {e}") from e
90
+ except AttributeError as e:
91
+ raise AttributeError(f"App '{name}' not found in module '{path}'") from e
@@ -0,0 +1,65 @@
1
+ """Lightweight context-var helpers for passing authenticated user info into graphs.
2
+
3
+ Graph nodes can access the current request's authentication context by calling
4
+ `get_auth_ctx()`. The server sets the context for the lifetime of a single run
5
+ (using an async context-manager) so the information is automatically scoped and
6
+ cleaned up.
7
+
8
+ The structure follows the standard auth context format so that
9
+ libraries expecting `Auth.types.BaseAuthContext` work unchanged.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextvars
15
+ from collections.abc import AsyncIterator
16
+ from contextlib import asynccontextmanager
17
+
18
+ from langgraph_sdk import Auth # type: ignore
19
+ from starlette.authentication import AuthCredentials, BaseUser
20
+
21
+ # Internal context-var storing the current auth context (or None when absent)
22
+ _AuthCtx: contextvars.ContextVar[Auth.types.BaseAuthContext | None] = contextvars.ContextVar( # type: ignore[attr-defined]
23
+ "AuthContext", default=None
24
+ )
25
+
26
+
27
+ def get_auth_ctx() -> Auth.types.BaseAuthContext | None: # type: ignore[attr-defined]
28
+ """Return the current authentication context or ``None`` if not set."""
29
+ return _AuthCtx.get()
30
+
31
+
32
+ @asynccontextmanager
33
+ async def with_auth_ctx(
34
+ user: BaseUser | None,
35
+ permissions: list[str] | AuthCredentials | None = None,
36
+ ) -> AsyncIterator[None]:
37
+ """Temporarily set the auth context for the duration of an async block.
38
+
39
+ Parameters
40
+ ----------
41
+ user
42
+ The authenticated user (or ``None`` for anonymous access).
43
+ permissions
44
+ Either a Starlette ``AuthCredentials`` instance or a list of permission
45
+ strings. ``None`` means no permissions.
46
+ """
47
+ # Normalize the permissions list
48
+ scopes: list[str] = []
49
+ if isinstance(permissions, AuthCredentials):
50
+ scopes = list(permissions.scopes)
51
+ elif isinstance(permissions, list):
52
+ scopes = permissions
53
+
54
+ if user is None and not scopes:
55
+ token = _AuthCtx.set(None)
56
+ else:
57
+ token = _AuthCtx.set(
58
+ Auth.types.BaseAuthContext( # type: ignore[attr-defined]
59
+ user=user, permissions=scopes
60
+ )
61
+ )
62
+ try:
63
+ yield
64
+ finally:
65
+ _AuthCtx.reset(token)
@@ -0,0 +1,186 @@
1
+ """Authentication dependencies for FastAPI endpoints"""
2
+
3
+ from typing import Annotated, Any
4
+
5
+ from fastapi import Depends, HTTPException, Request
6
+
7
+ from aegra_api.core.auth_middleware import get_auth_backend
8
+ from aegra_api.models.auth import User
9
+
10
+
11
+ def _extract_user_data(user_obj: Any) -> dict[str, Any]:
12
+ """Extract user data from various object types.
13
+
14
+ Handles dict, objects with to_dict(), and objects with dict() methods.
15
+
16
+ Args:
17
+ user_obj: User object from authentication middleware
18
+
19
+ Returns:
20
+ Dictionary containing user data
21
+ """
22
+ if isinstance(user_obj, dict):
23
+ return user_obj
24
+ if hasattr(user_obj, "to_dict"):
25
+ return user_obj.to_dict()
26
+ if hasattr(user_obj, "dict"):
27
+ return user_obj.dict()
28
+ # Fallback: try to extract known attributes
29
+ return {
30
+ "identity": getattr(user_obj, "identity", str(user_obj)),
31
+ "is_authenticated": getattr(user_obj, "is_authenticated", True),
32
+ }
33
+
34
+
35
+ def _to_user_model(user: Any) -> User:
36
+ """Convert auth result to User model.
37
+
38
+ Args:
39
+ user: User object from auth backend (LangGraphUser, dict, etc.)
40
+
41
+ Returns:
42
+ User model instance with all fields preserved
43
+ """
44
+ user_data = _extract_user_data(user)
45
+
46
+ # Ensure identity exists
47
+ if "identity" not in user_data:
48
+ raise HTTPException(status_code=401, detail="User identity not provided")
49
+
50
+ # Set display_name default if not provided
51
+ if user_data.get("display_name") is None:
52
+ user_data["display_name"] = user_data["identity"]
53
+
54
+ # Pass all fields through to User model (extra fields allowed via ConfigDict)
55
+ return User(**user_data)
56
+
57
+
58
+ async def require_auth(request: Request) -> User:
59
+ """FastAPI dependency for authentication.
60
+
61
+ Replaces Starlette AuthenticationMiddleware by calling the auth backend directly.
62
+ This allows FastAPI to properly track dependencies for OpenAPI generation.
63
+
64
+ Args:
65
+ request: FastAPI request object
66
+
67
+ Returns:
68
+ User object with authentication context including any extra fields
69
+
70
+ Raises:
71
+ HTTPException: If user is not authenticated
72
+ """
73
+ backend = get_auth_backend()
74
+
75
+ try:
76
+ result = await backend.authenticate(request)
77
+ except Exception as e:
78
+ raise HTTPException(status_code=401, detail=str(e)) from e
79
+
80
+ if result is None:
81
+ # No auth configured - for now, require auth if backend exists
82
+ # (This will be handled by noop auth in the backend)
83
+ raise HTTPException(status_code=401, detail="Authentication required")
84
+
85
+ credentials, user = result
86
+
87
+ # Set request.scope for backward compatibility
88
+ # (Some code might still read request.scope["user"] or request.user)
89
+ request.scope["user"] = user
90
+ request.scope["auth"] = credentials
91
+ # Also set request.user for Starlette compatibility
92
+ if not hasattr(request, "user"):
93
+ request.user = user
94
+
95
+ # Convert to User model
96
+ return _to_user_model(user)
97
+
98
+
99
+ # Type alias for cleaner route signatures
100
+ AuthenticatedUser = Annotated[User, Depends(require_auth)]
101
+
102
+ # For applying to entire routers
103
+ auth_dependency = [Depends(require_auth)]
104
+
105
+
106
+ def get_current_user(request: Request) -> User:
107
+ """
108
+ Legacy: Extract current user from request context set by middleware or dependency.
109
+
110
+ This function reads from request.scope["user"] which is set by either:
111
+ - The new require_auth() dependency (preferred)
112
+ - The old AuthenticationMiddleware (for backward compatibility)
113
+
114
+ This function passes ALL fields from auth handlers through to the User model,
115
+ allowing custom auth handlers to return extra fields (e.g., subscription_tier,
116
+ team_id) that will be accessible on the User object.
117
+
118
+ Args:
119
+ request: FastAPI request object
120
+
121
+ Returns:
122
+ User object with authentication context including any extra fields
123
+
124
+ Raises:
125
+ HTTPException: If user is not authenticated
126
+ """
127
+ # Try reading from request.scope first (set by require_auth dependency)
128
+ user = request.scope.get("user")
129
+ if user is None:
130
+ # Fallback to request.user (set by middleware)
131
+ if not hasattr(request, "user") or request.user is None:
132
+ raise HTTPException(status_code=401, detail="Authentication required")
133
+ user = request.user
134
+
135
+ if hasattr(user, "is_authenticated") and not user.is_authenticated:
136
+ raise HTTPException(status_code=401, detail="Invalid authentication")
137
+
138
+ # Convert to User model
139
+ return _to_user_model(user)
140
+
141
+
142
+ def get_user_id(user: User = Depends(get_current_user)) -> str:
143
+ """
144
+ Helper dependency to get user ID safely.
145
+
146
+ Args:
147
+ user: User object from get_current_user dependency
148
+
149
+ Returns:
150
+ User identity string
151
+ """
152
+ return user.identity
153
+
154
+
155
+ def require_permission(permission: str):
156
+ """
157
+ Create a dependency that requires a specific permission.
158
+
159
+ Args:
160
+ permission: Required permission string
161
+
162
+ Returns:
163
+ Dependency function that checks for the permission
164
+
165
+ Example:
166
+ @app.get("/admin")
167
+ def admin_endpoint(user: User = Depends(require_permission("admin"))):
168
+ return {"message": "Admin access granted"}
169
+ """
170
+
171
+ def permission_dependency(user: User = Depends(get_current_user)) -> User:
172
+ if permission not in user.permissions:
173
+ raise HTTPException(status_code=403, detail=f"Permission '{permission}' required")
174
+ return user
175
+
176
+ return permission_dependency
177
+
178
+
179
+ def require_authenticated(request: Request) -> User:
180
+ """
181
+ Simplified dependency that just ensures user is authenticated.
182
+
183
+ This is equivalent to get_current_user but with a clearer name
184
+ for endpoints that just need any authenticated user.
185
+ """
186
+ return get_current_user(request)