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,456 @@
1
+ """LangGraph integration service.
2
+
3
+ Architecture:
4
+ - Base graph definitions are cached (safe, immutable)
5
+ - Each request gets a fresh graph copy with checkpointer/store injected
6
+ - Thread-safe by design without locks
7
+ """
8
+
9
+ import importlib.util
10
+ import json
11
+ import sys
12
+ from collections.abc import AsyncIterator
13
+ from contextlib import asynccontextmanager
14
+ from pathlib import Path
15
+ from typing import Any, TypeVar
16
+ from uuid import uuid5
17
+
18
+ import structlog
19
+ from langgraph.graph import StateGraph
20
+ from langgraph.pregel import Pregel
21
+
22
+ from aegra_api.constants import ASSISTANT_NAMESPACE_UUID
23
+ from aegra_api.observability.base import (
24
+ get_tracing_callbacks,
25
+ get_tracing_metadata,
26
+ )
27
+ from aegra_api.settings import settings
28
+
29
+ State = TypeVar("State")
30
+ logger = structlog.get_logger(__name__)
31
+
32
+
33
+ class LangGraphService:
34
+ """Service to work with LangGraph CLI configuration and graphs.
35
+
36
+ Architecture:
37
+ - Caches base graph definitions (raw StateGraph/Pregel before checkpointer)
38
+ - Yields fresh copies per-request with checkpointer/store injected
39
+ - Thread-safe without locks via immutable cached state
40
+ """
41
+
42
+ def __init__(self, config_path: str = "aegra.json"):
43
+ # Default path can be overridden via AEGRA_CONFIG or by placing aegra.json
44
+ self.config_path = Path(config_path)
45
+ self.config: dict[str, Any] | None = None
46
+ self._graph_registry: dict[str, Any] = {}
47
+ # Cache for base graph definitions (without checkpointer/store)
48
+ self._base_graph_cache: dict[str, Pregel] = {}
49
+
50
+ async def initialize(self):
51
+ """Load configuration file and setup graph registry.
52
+
53
+ Uses shared config loading logic to ensure consistency.
54
+ Resolution order:
55
+ 1) AEGRA_CONFIG env var (absolute or relative path)
56
+ 2) Explicit self.config_path if it exists
57
+ 3) aegra.json in CWD
58
+ 4) langgraph.json in CWD (fallback)
59
+ """
60
+ from aegra_api.config import _resolve_config_path
61
+
62
+ # Priority: env var > explicit config_path > shared resolution
63
+ resolved_path: Path | None = None
64
+
65
+ # 1) Check env var first (via shared resolution logic)
66
+ env_resolved = _resolve_config_path()
67
+
68
+ # 2) If env var was set, use it (even if file doesn't exist yet - let error happen later)
69
+ env_path = settings.app.AEGRA_CONFIG
70
+ if env_path and env_resolved and env_resolved == Path(env_path):
71
+ resolved_path = env_resolved
72
+ # 3) Otherwise check explicit config_path (if provided and exists)
73
+ elif self.config_path and Path(self.config_path).exists():
74
+ resolved_path = Path(self.config_path)
75
+ # 4) Otherwise use shared resolution (fallback to aegra.json/langgraph.json)
76
+ else:
77
+ resolved_path = env_resolved
78
+
79
+ if not resolved_path or not resolved_path.exists():
80
+ raise ValueError(
81
+ "Configuration file not found. Expected one of: AEGRA_CONFIG path, ./aegra.json, or ./langgraph.json"
82
+ )
83
+
84
+ # Persist selected path for later reference
85
+ self.config_path = resolved_path
86
+
87
+ with self.config_path.open() as f:
88
+ self.config = json.load(f)
89
+
90
+ # Setup dependency paths before loading graphs
91
+ self._setup_dependencies()
92
+
93
+ # Load graph registry from config
94
+ self._load_graph_registry()
95
+
96
+ # Pre-register assistants for each graph using deterministic UUIDs so
97
+ # clients can pass graph_id directly.
98
+ await self._ensure_default_assistants()
99
+
100
+ def _load_graph_registry(self):
101
+ """Load graph definitions from aegra.json"""
102
+ graphs_config = self.config.get("graphs", {})
103
+
104
+ for graph_id, graph_path in graphs_config.items():
105
+ # Parse path format: "./graphs/weather_agent.py:graph"
106
+ if ":" not in graph_path:
107
+ raise ValueError(f"Invalid graph path format: {graph_path}")
108
+
109
+ file_path, export_name = graph_path.split(":", 1)
110
+ self._graph_registry[graph_id] = {
111
+ "file_path": file_path,
112
+ "export_name": export_name,
113
+ }
114
+
115
+ def _setup_dependencies(self) -> None:
116
+ """Add dependency paths to sys.path for graph imports.
117
+
118
+ Supports paths from the 'dependencies' config key, similar to LangGraph CLI.
119
+ Paths are resolved relative to the config file location.
120
+ """
121
+ dependencies = self.config.get("dependencies", [])
122
+ if not dependencies:
123
+ return
124
+
125
+ config_dir = self.config_path.parent
126
+
127
+ # Iterate in reverse so first dependency in config has highest priority
128
+ for dep in reversed(dependencies):
129
+ dep_path = Path(dep)
130
+
131
+ # Resolve relative paths from config directory
132
+ if not dep_path.is_absolute():
133
+ dep_path = (config_dir / dep_path).resolve()
134
+ else:
135
+ dep_path = dep_path.resolve()
136
+
137
+ # Add to sys.path if exists and not already present
138
+ path_str = str(dep_path)
139
+ if dep_path.exists() and path_str not in sys.path:
140
+ sys.path.insert(0, path_str)
141
+ logger.info(f"Added dependency path to sys.path: {path_str}")
142
+ elif not dep_path.exists():
143
+ logger.warning(f"Dependency path does not exist: {path_str}")
144
+
145
+ async def _ensure_default_assistants(self) -> None:
146
+ """Create a default assistant per graph with deterministic UUID.
147
+
148
+ Uses uuid5 with a fixed namespace so that the same graph_id maps
149
+ to the same assistant_id across restarts. Idempotent.
150
+ """
151
+ from sqlalchemy import select
152
+
153
+ from aegra_api.core.orm import Assistant as AssistantORM
154
+ from aegra_api.core.orm import AssistantVersion as AssistantVersionORM
155
+ from aegra_api.core.orm import get_session
156
+
157
+ # Fixed namespace used to derive assistant IDs from graph IDs
158
+ NS = ASSISTANT_NAMESPACE_UUID
159
+ session_gen = get_session()
160
+ session = await anext(session_gen)
161
+ try:
162
+ for graph_id in self._graph_registry:
163
+ assistant_id = str(uuid5(NS, graph_id))
164
+ existing = await session.scalar(select(AssistantORM).where(AssistantORM.assistant_id == assistant_id))
165
+ if existing:
166
+ continue
167
+ session.add(
168
+ AssistantORM(
169
+ assistant_id=assistant_id,
170
+ name=graph_id,
171
+ description=f"Default assistant for graph '{graph_id}'",
172
+ graph_id=graph_id,
173
+ config={},
174
+ user_id="system",
175
+ metadata_dict={"created_by": "system"},
176
+ )
177
+ )
178
+ session.add(
179
+ AssistantVersionORM(
180
+ assistant_id=assistant_id,
181
+ version=1,
182
+ name=graph_id,
183
+ description=f"Default assistant for graph '{graph_id}'",
184
+ graph_id=graph_id,
185
+ metadata_dict={"created_by": "system"},
186
+ )
187
+ )
188
+ await session.commit()
189
+ finally:
190
+ await session.close()
191
+
192
+ async def _get_base_graph(self, graph_id: str) -> Pregel:
193
+ """Get the base compiled graph without checkpointer/store.
194
+
195
+ Caches the compiled graph structure for reuse. This is safe because
196
+ the base graph is immutable - we create copies with checkpointer/store
197
+ injected per-request.
198
+
199
+ @param graph_id: The graph identifier from aegra.json
200
+ @returns: Compiled Pregel graph (without checkpointer/store)
201
+ @raises ValueError: If graph_id not found or loading fails
202
+ """
203
+ if graph_id not in self._graph_registry:
204
+ raise ValueError(f"Graph not found: {graph_id}")
205
+
206
+ # Return cached base graph if available
207
+ if graph_id in self._base_graph_cache:
208
+ return self._base_graph_cache[graph_id]
209
+
210
+ graph_info = self._graph_registry[graph_id]
211
+
212
+ # Load graph from file
213
+ raw_graph = await self._load_graph_from_file(graph_id, graph_info)
214
+
215
+ # Compile if it's a StateGraph
216
+ if isinstance(raw_graph, StateGraph):
217
+ logger.info(f"🔧 Compiling graph '{graph_id}'")
218
+ compiled_graph = raw_graph.compile()
219
+ else:
220
+ compiled_graph = raw_graph
221
+
222
+ # Cache the base compiled graph (without checkpointer/store)
223
+ self._base_graph_cache[graph_id] = compiled_graph
224
+ return compiled_graph
225
+
226
+ @asynccontextmanager
227
+ async def get_graph(self, graph_id: str) -> AsyncIterator[Pregel]:
228
+ """Get a graph instance for execution with checkpointer/store injected.
229
+
230
+ This is a context manager that yields a fresh graph copy per-request.
231
+ Thread-safe without locks since each request gets its own instance.
232
+
233
+ Usage:
234
+ async with langgraph_service.get_graph("react_agent") as graph:
235
+ async for event in graph.astream(input, config):
236
+ ...
237
+
238
+ @param graph_id: The graph identifier from aegra.json
239
+ @yields: Compiled Pregel graph with Postgres checkpointer/store attached
240
+ @raises ValueError: If graph_id not found or loading fails
241
+ """
242
+ # Get the cached base graph
243
+ base_graph = await self._get_base_graph(graph_id)
244
+
245
+ # Get checkpointer and store for this request
246
+ from aegra_api.core.database import db_manager
247
+
248
+ checkpointer = db_manager.get_checkpointer()
249
+ store = db_manager.get_store()
250
+
251
+ # Try to create a copy with checkpointer/store injected.
252
+ # NOTE: Do this BEFORE yield to avoid dual-yield when exceptions occur
253
+ # in the context body - @asynccontextmanager would call athrow() and
254
+ # catch it in except, causing "generator didn't stop after athrow()".
255
+ try:
256
+ graph_to_use = base_graph.copy(update={"checkpointer": checkpointer, "store": store})
257
+ except Exception:
258
+ # Graph doesn't support copy with these attrs (e.g., immutable property)
259
+ logger.warning(
260
+ f"⚠️ Graph '{graph_id}' does not support checkpointer injection; running without persistence"
261
+ )
262
+ graph_to_use = base_graph
263
+
264
+ yield graph_to_use
265
+
266
+ async def get_graph_for_validation(self, graph_id: str) -> Pregel:
267
+ """Get a graph instance for validation/schema extraction only.
268
+
269
+ Use this when you only need to validate that a graph exists and can be
270
+ loaded, or to extract schemas. Does NOT include checkpointer/store.
271
+
272
+ For actual execution, use the `get_graph()` context manager instead.
273
+
274
+ @param graph_id: The graph identifier from aegra.json
275
+ @returns: Compiled Pregel graph (without checkpointer/store)
276
+ @raises ValueError: If graph_id not found or loading fails
277
+ """
278
+ return await self._get_base_graph(graph_id)
279
+
280
+ async def _load_graph_from_file(self, graph_id: str, graph_info: dict[str, str]):
281
+ """Load graph from filesystem.
282
+
283
+ Paths are resolved relative to the config file's directory.
284
+ """
285
+ raw_path = graph_info["file_path"]
286
+ file_path = Path(raw_path)
287
+
288
+ # Resolve relative paths from config file directory
289
+ if not file_path.is_absolute():
290
+ file_path = (self.config_path.parent / file_path).resolve()
291
+
292
+ if not file_path.exists():
293
+ raise ValueError(f"Graph file not found: {file_path}")
294
+
295
+ # Dynamic import of graph module
296
+ spec = importlib.util.spec_from_file_location(f"graphs.{graph_id}", str(file_path.resolve()))
297
+ if spec is None or spec.loader is None:
298
+ raise ValueError(f"Failed to load graph module: {file_path}")
299
+
300
+ module = importlib.util.module_from_spec(spec)
301
+ spec.loader.exec_module(module)
302
+
303
+ # Get the exported graph
304
+ export_name = graph_info["export_name"]
305
+ if not hasattr(module, export_name):
306
+ raise ValueError(f"Graph export not found: {export_name} in {file_path}")
307
+
308
+ graph = getattr(module, export_name)
309
+
310
+ # https://github.com/langchain-ai/langchain-mcp-adapters?tab=readme-ov-file#using-with-langgraph-api-server
311
+ if callable(graph):
312
+ graph = await graph()
313
+
314
+ # The graph should already be compiled in the module
315
+ # If it needs our checkpointer/store, we'll handle that during execution
316
+ return graph
317
+
318
+ def list_graphs(self) -> dict[str, str]:
319
+ """List all available graphs"""
320
+ return {graph_id: info["file_path"] for graph_id, info in self._graph_registry.items()}
321
+
322
+ def invalidate_cache(self, graph_id: str = None):
323
+ """Invalidate graph cache for hot-reload.
324
+
325
+ @param graph_id: Specific graph to invalidate, or None to clear all
326
+ """
327
+ if graph_id:
328
+ self._base_graph_cache.pop(graph_id, None)
329
+ else:
330
+ self._base_graph_cache.clear()
331
+
332
+ def get_config(self) -> dict[str, Any] | None:
333
+ """Get loaded configuration"""
334
+ return self.config
335
+
336
+ def get_dependencies(self) -> list:
337
+ """Get dependencies from config"""
338
+ if self.config is None:
339
+ return []
340
+ return self.config.get("dependencies", [])
341
+
342
+ def get_http_config(self) -> dict[str, Any] | None:
343
+ """Get HTTP configuration from loaded config file.
344
+
345
+ Returns:
346
+ HTTP configuration dict or None if not configured
347
+ """
348
+ if self.config is None:
349
+ return None
350
+ return self.config.get("http")
351
+
352
+
353
+ # Global service instance
354
+ _langgraph_service = None
355
+
356
+
357
+ def get_langgraph_service() -> LangGraphService:
358
+ """Get global LangGraph service instance"""
359
+ global _langgraph_service
360
+ if _langgraph_service is None:
361
+ _langgraph_service = LangGraphService()
362
+ return _langgraph_service
363
+
364
+
365
+ def inject_user_context(user: Any | None, base_config: dict[str, Any] | None = None) -> dict[str, Any]:
366
+ """Inject user context into LangGraph configuration for user isolation.
367
+
368
+ Passes ALL user fields (including custom auth handler fields like
369
+ subscription_tier, team_id, etc.) to the graph config under
370
+ 'langgraph_auth_user'.
371
+
372
+ Args:
373
+ user: User object with identity and optional extra fields
374
+ base_config: Base configuration to extend
375
+
376
+ Returns:
377
+ Configuration dict with user context injected
378
+ """
379
+ config: dict[str, Any] = (base_config or {}).copy()
380
+ config["configurable"] = config.get("configurable", {})
381
+
382
+ # All user-related data injection (only if user exists)
383
+ if user:
384
+ # Basic user identity for multi-tenant scoping
385
+ config["configurable"].setdefault("user_id", user.identity)
386
+ config["configurable"].setdefault("user_display_name", getattr(user, "display_name", None) or user.identity)
387
+
388
+ # Full auth payload for graph nodes - includes ALL fields from auth handler
389
+ if "langgraph_auth_user" not in config["configurable"]:
390
+ try:
391
+ # user.to_dict() returns all fields including extras from auth handlers
392
+ config["configurable"]["langgraph_auth_user"] = user.to_dict()
393
+ except Exception:
394
+ # Fallback: minimal dict if to_dict unavailable or fails
395
+ config["configurable"]["langgraph_auth_user"] = {"identity": user.identity}
396
+
397
+ return config
398
+
399
+
400
+ def create_thread_config(thread_id: str, user, additional_config: dict = None) -> dict:
401
+ """Create LangGraph configuration for a specific thread with user context"""
402
+ base_config = {"configurable": {"thread_id": thread_id}}
403
+
404
+ if additional_config:
405
+ base_config.update(additional_config)
406
+
407
+ return inject_user_context(user, base_config)
408
+
409
+
410
+ def create_run_config(
411
+ run_id: str,
412
+ thread_id: str,
413
+ user,
414
+ additional_config: dict = None,
415
+ checkpoint: dict | None = None,
416
+ ) -> dict:
417
+ """Create LangGraph configuration for a specific run with full context.
418
+
419
+ The function is *additive*: it never removes or renames anything the client
420
+ supplied. We simply ensure a `configurable` dict exists and then merge a
421
+ few server-side keys so graph nodes can rely on them.
422
+ """
423
+ from copy import deepcopy
424
+
425
+ cfg: dict = deepcopy(additional_config) if additional_config else {}
426
+
427
+ # Ensure a configurable section exists
428
+ cfg.setdefault("configurable", {})
429
+
430
+ # Merge server-provided fields (do NOT overwrite if client already set)
431
+ cfg["configurable"].setdefault("thread_id", thread_id)
432
+ cfg["configurable"].setdefault("run_id", run_id)
433
+
434
+ # Add observability callbacks from various potential sources
435
+ tracing_callbacks = get_tracing_callbacks()
436
+ if tracing_callbacks:
437
+ existing_callbacks = cfg.get("callbacks", [])
438
+ if not isinstance(existing_callbacks, list):
439
+ # If we want to be more robust, we can log a warning here
440
+ existing_callbacks = []
441
+
442
+ # Combine existing callbacks with new tracing callbacks to be non-destructive
443
+ cfg["callbacks"] = existing_callbacks + tracing_callbacks
444
+
445
+ # Add metadata from all observability providers (independent of callbacks)
446
+ cfg.setdefault("metadata", {})
447
+ user_identity = user.identity if user else None
448
+ observability_metadata = get_tracing_metadata(run_id, thread_id, user_identity)
449
+ cfg["metadata"].update(observability_metadata)
450
+
451
+ # Apply checkpoint parameters if provided
452
+ if checkpoint and isinstance(checkpoint, dict):
453
+ cfg["configurable"].update({k: v for k, v in checkpoint.items() if v is not None})
454
+
455
+ # Finally inject user context via existing helper
456
+ return inject_user_context(user, cfg)