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.
- aegra_api/__init__.py +3 -0
- aegra_api/api/__init__.py +1 -0
- aegra_api/api/assistants.py +235 -0
- aegra_api/api/runs.py +1110 -0
- aegra_api/api/store.py +200 -0
- aegra_api/api/threads.py +761 -0
- aegra_api/config.py +204 -0
- aegra_api/constants.py +5 -0
- aegra_api/core/__init__.py +0 -0
- aegra_api/core/app_loader.py +91 -0
- aegra_api/core/auth_ctx.py +65 -0
- aegra_api/core/auth_deps.py +186 -0
- aegra_api/core/auth_handlers.py +248 -0
- aegra_api/core/auth_middleware.py +331 -0
- aegra_api/core/database.py +123 -0
- aegra_api/core/health.py +131 -0
- aegra_api/core/orm.py +165 -0
- aegra_api/core/route_merger.py +69 -0
- aegra_api/core/serializers/__init__.py +7 -0
- aegra_api/core/serializers/base.py +22 -0
- aegra_api/core/serializers/general.py +54 -0
- aegra_api/core/serializers/langgraph.py +102 -0
- aegra_api/core/sse.py +178 -0
- aegra_api/main.py +303 -0
- aegra_api/middleware/__init__.py +4 -0
- aegra_api/middleware/double_encoded_json.py +74 -0
- aegra_api/middleware/logger_middleware.py +95 -0
- aegra_api/models/__init__.py +76 -0
- aegra_api/models/assistants.py +81 -0
- aegra_api/models/auth.py +62 -0
- aegra_api/models/enums.py +29 -0
- aegra_api/models/errors.py +29 -0
- aegra_api/models/runs.py +124 -0
- aegra_api/models/store.py +67 -0
- aegra_api/models/threads.py +152 -0
- aegra_api/observability/__init__.py +1 -0
- aegra_api/observability/base.py +88 -0
- aegra_api/observability/otel.py +133 -0
- aegra_api/observability/setup.py +27 -0
- aegra_api/observability/targets/__init__.py +11 -0
- aegra_api/observability/targets/base.py +18 -0
- aegra_api/observability/targets/langfuse.py +33 -0
- aegra_api/observability/targets/otlp.py +38 -0
- aegra_api/observability/targets/phoenix.py +24 -0
- aegra_api/services/__init__.py +0 -0
- aegra_api/services/assistant_service.py +569 -0
- aegra_api/services/base_broker.py +59 -0
- aegra_api/services/broker.py +141 -0
- aegra_api/services/event_converter.py +157 -0
- aegra_api/services/event_store.py +196 -0
- aegra_api/services/graph_streaming.py +433 -0
- aegra_api/services/langgraph_service.py +456 -0
- aegra_api/services/streaming_service.py +362 -0
- aegra_api/services/thread_state_service.py +128 -0
- aegra_api/settings.py +124 -0
- aegra_api/utils/__init__.py +3 -0
- aegra_api/utils/assistants.py +23 -0
- aegra_api/utils/run_utils.py +60 -0
- aegra_api/utils/setup_logging.py +122 -0
- aegra_api/utils/sse_utils.py +26 -0
- aegra_api/utils/status_compat.py +57 -0
- aegra_api-0.1.0.dist-info/METADATA +244 -0
- aegra_api-0.1.0.dist-info/RECORD +64 -0
- 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)
|