langgraph-api 0.5.4__py3-none-any.whl → 0.7.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- langgraph_api/__init__.py +1 -1
- langgraph_api/api/__init__.py +93 -27
- langgraph_api/api/a2a.py +36 -32
- langgraph_api/api/assistants.py +114 -26
- langgraph_api/api/mcp.py +3 -3
- langgraph_api/api/meta.py +15 -2
- langgraph_api/api/openapi.py +27 -17
- langgraph_api/api/profile.py +108 -0
- langgraph_api/api/runs.py +114 -57
- langgraph_api/api/store.py +19 -2
- langgraph_api/api/threads.py +133 -10
- langgraph_api/asgi_transport.py +14 -9
- langgraph_api/auth/custom.py +23 -13
- langgraph_api/cli.py +86 -41
- langgraph_api/command.py +2 -2
- langgraph_api/config/__init__.py +532 -0
- langgraph_api/config/_parse.py +58 -0
- langgraph_api/config/schemas.py +431 -0
- langgraph_api/cron_scheduler.py +17 -1
- langgraph_api/encryption/__init__.py +15 -0
- langgraph_api/encryption/aes_json.py +158 -0
- langgraph_api/encryption/context.py +35 -0
- langgraph_api/encryption/custom.py +280 -0
- langgraph_api/encryption/middleware.py +632 -0
- langgraph_api/encryption/shared.py +63 -0
- langgraph_api/errors.py +12 -1
- langgraph_api/executor_entrypoint.py +11 -6
- langgraph_api/feature_flags.py +19 -0
- langgraph_api/graph.py +163 -64
- langgraph_api/{grpc_ops → grpc}/client.py +142 -12
- langgraph_api/{grpc_ops → grpc}/config_conversion.py +16 -10
- langgraph_api/grpc/generated/__init__.py +29 -0
- langgraph_api/grpc/generated/checkpointer_pb2.py +63 -0
- langgraph_api/grpc/generated/checkpointer_pb2.pyi +99 -0
- langgraph_api/grpc/generated/checkpointer_pb2_grpc.py +329 -0
- langgraph_api/grpc/generated/core_api_pb2.py +216 -0
- langgraph_api/{grpc_ops → grpc}/generated/core_api_pb2.pyi +292 -372
- langgraph_api/{grpc_ops → grpc}/generated/core_api_pb2_grpc.py +252 -31
- langgraph_api/grpc/generated/engine_common_pb2.py +219 -0
- langgraph_api/{grpc_ops → grpc}/generated/engine_common_pb2.pyi +178 -104
- langgraph_api/grpc/generated/enum_cancel_run_action_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_cancel_run_action_pb2.pyi +12 -0
- langgraph_api/grpc/generated/enum_cancel_run_action_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_control_signal_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_control_signal_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_control_signal_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_durability_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_durability_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_durability_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_multitask_strategy_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_multitask_strategy_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_multitask_strategy_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_run_status_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_run_status_pb2.pyi +22 -0
- langgraph_api/grpc/generated/enum_run_status_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_stream_mode_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_stream_mode_pb2.pyi +28 -0
- langgraph_api/grpc/generated/enum_stream_mode_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_thread_status_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_thread_status_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_thread_status_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.py +37 -0
- langgraph_api/grpc/generated/enum_thread_stream_mode_pb2.pyi +16 -0
- langgraph_api/grpc/generated/enum_thread_stream_mode_pb2_grpc.py +24 -0
- langgraph_api/grpc/generated/errors_pb2.py +39 -0
- langgraph_api/grpc/generated/errors_pb2.pyi +21 -0
- langgraph_api/grpc/generated/errors_pb2_grpc.py +24 -0
- langgraph_api/grpc/ops/__init__.py +370 -0
- langgraph_api/grpc/ops/assistants.py +424 -0
- langgraph_api/grpc/ops/runs.py +792 -0
- langgraph_api/grpc/ops/threads.py +1013 -0
- langgraph_api/http.py +16 -5
- langgraph_api/js/client.mts +1 -4
- langgraph_api/js/package.json +28 -27
- langgraph_api/js/remote.py +39 -17
- langgraph_api/js/sse.py +2 -2
- langgraph_api/js/ui.py +1 -1
- langgraph_api/js/yarn.lock +1139 -869
- langgraph_api/metadata.py +29 -3
- langgraph_api/middleware/http_logger.py +1 -1
- langgraph_api/middleware/private_network.py +7 -7
- langgraph_api/models/run.py +44 -26
- langgraph_api/otel_context.py +205 -0
- langgraph_api/patch.py +2 -2
- langgraph_api/queue_entrypoint.py +34 -35
- langgraph_api/route.py +33 -1
- langgraph_api/schema.py +84 -9
- langgraph_api/self_hosted_logs.py +2 -2
- langgraph_api/self_hosted_metrics.py +73 -3
- langgraph_api/serde.py +16 -4
- langgraph_api/server.py +33 -31
- langgraph_api/state.py +3 -2
- langgraph_api/store.py +25 -16
- langgraph_api/stream.py +20 -16
- langgraph_api/thread_ttl.py +28 -13
- langgraph_api/timing/__init__.py +25 -0
- langgraph_api/timing/profiler.py +200 -0
- langgraph_api/timing/timer.py +318 -0
- langgraph_api/utils/__init__.py +53 -8
- langgraph_api/utils/config.py +2 -1
- langgraph_api/utils/future.py +10 -6
- langgraph_api/utils/uuids.py +29 -62
- langgraph_api/validation.py +6 -0
- langgraph_api/webhook.py +120 -6
- langgraph_api/worker.py +54 -24
- {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/METADATA +8 -6
- langgraph_api-0.7.3.dist-info/RECORD +168 -0
- {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/WHEEL +1 -1
- langgraph_runtime/__init__.py +1 -0
- langgraph_runtime/routes.py +11 -0
- logging.json +1 -3
- openapi.json +635 -537
- langgraph_api/config.py +0 -523
- langgraph_api/grpc_ops/generated/__init__.py +0 -5
- langgraph_api/grpc_ops/generated/core_api_pb2.py +0 -275
- langgraph_api/grpc_ops/generated/engine_common_pb2.py +0 -194
- langgraph_api/grpc_ops/ops.py +0 -1045
- langgraph_api-0.5.4.dist-info/RECORD +0 -121
- /langgraph_api/{grpc_ops → grpc}/__init__.py +0 -0
- /langgraph_api/{grpc_ops → grpc}/generated/engine_common_pb2_grpc.py +0 -0
- {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/entry_points.txt +0 -0
- {langgraph_api-0.5.4.dist-info → langgraph_api-0.7.3.dist-info}/licenses/LICENSE +0 -0
langgraph_api/schema.py
CHANGED
|
@@ -16,7 +16,15 @@ RunStatus = Literal["pending", "running", "error", "success", "timeout", "interr
|
|
|
16
16
|
ThreadStatus = Literal["idle", "busy", "interrupted", "error"]
|
|
17
17
|
|
|
18
18
|
StreamMode = Literal[
|
|
19
|
-
"values",
|
|
19
|
+
"values",
|
|
20
|
+
"messages",
|
|
21
|
+
"updates",
|
|
22
|
+
"events",
|
|
23
|
+
"debug",
|
|
24
|
+
"tasks",
|
|
25
|
+
"checkpoints",
|
|
26
|
+
"custom",
|
|
27
|
+
"messages-tuple",
|
|
20
28
|
]
|
|
21
29
|
|
|
22
30
|
ThreadStreamMode = Literal["lifecycle", "run_modes", "state_update"]
|
|
@@ -50,10 +58,16 @@ class Config(TypedDict, total=False):
|
|
|
50
58
|
"""
|
|
51
59
|
Runtime values for attributes previously made configurable on this Runnable,
|
|
52
60
|
or sub-Runnables, through .configurable_fields() or .configurable_alternatives().
|
|
53
|
-
Check .output_schema() for a description of the attributes that have been made
|
|
61
|
+
Check .output_schema() for a description of the attributes that have been made
|
|
54
62
|
configurable.
|
|
55
63
|
"""
|
|
56
64
|
|
|
65
|
+
__encryption_context__: dict[str, Any]
|
|
66
|
+
"""
|
|
67
|
+
Internal: Encryption context for encryption/decryption operations.
|
|
68
|
+
Not exposed to users.
|
|
69
|
+
"""
|
|
70
|
+
|
|
57
71
|
|
|
58
72
|
class Checkpoint(TypedDict):
|
|
59
73
|
thread_id: str
|
|
@@ -112,6 +126,17 @@ class DeprecatedInterrupt(TypedDict, total=False):
|
|
|
112
126
|
"""When the interrupt occurred, always "during"."""
|
|
113
127
|
|
|
114
128
|
|
|
129
|
+
class ThreadTTLInfo(TypedDict, total=False):
|
|
130
|
+
"""TTL information for a thread. Only present when ?include=ttl is passed."""
|
|
131
|
+
|
|
132
|
+
strategy: Literal["delete", "keep_latest"]
|
|
133
|
+
"""The TTL strategy."""
|
|
134
|
+
ttl_minutes: float
|
|
135
|
+
"""The TTL in minutes."""
|
|
136
|
+
expires_at: datetime
|
|
137
|
+
"""When the thread will expire."""
|
|
138
|
+
|
|
139
|
+
|
|
115
140
|
class Thread(TypedDict):
|
|
116
141
|
thread_id: UUID
|
|
117
142
|
"""The ID of the thread."""
|
|
@@ -123,14 +148,14 @@ class Thread(TypedDict):
|
|
|
123
148
|
"""The thread metadata."""
|
|
124
149
|
config: Fragment
|
|
125
150
|
"""The thread config."""
|
|
126
|
-
context: Fragment
|
|
127
|
-
"""The thread context."""
|
|
128
151
|
status: ThreadStatus
|
|
129
152
|
"""The status of the thread. One of 'idle', 'busy', 'interrupted', "error"."""
|
|
130
153
|
values: Fragment
|
|
131
154
|
"""The current state of the thread."""
|
|
132
155
|
interrupts: dict[str, list[Interrupt]]
|
|
133
156
|
"""The current interrupts of the thread, a map of task_id to list of interrupts."""
|
|
157
|
+
ttl: NotRequired[ThreadTTLInfo]
|
|
158
|
+
"""TTL information if set for this thread. Only present when ?include=ttl is passed."""
|
|
134
159
|
|
|
135
160
|
|
|
136
161
|
class ThreadTask(TypedDict):
|
|
@@ -148,7 +173,7 @@ class ThreadState(TypedDict):
|
|
|
148
173
|
next: Sequence[str]
|
|
149
174
|
"""The name of the node to execute in each task for this step."""
|
|
150
175
|
checkpoint: Checkpoint
|
|
151
|
-
"""The checkpoint keys. This object can be passed to the /threads and /runs
|
|
176
|
+
"""The checkpoint keys. This object can be passed to the /threads and /runs
|
|
152
177
|
endpoints to resume execution or update state."""
|
|
153
178
|
metadata: Fragment
|
|
154
179
|
"""Metadata for this state"""
|
|
@@ -220,6 +245,8 @@ class Cron(TypedDict):
|
|
|
220
245
|
"""The ID of the assistant."""
|
|
221
246
|
thread_id: UUID | None
|
|
222
247
|
"""The ID of the thread."""
|
|
248
|
+
on_run_completed: NotRequired[Literal["delete", "keep"] | None]
|
|
249
|
+
"""What to do with the thread after the run completes."""
|
|
223
250
|
end_time: datetime | None
|
|
224
251
|
"""The end date to stop running the cron."""
|
|
225
252
|
schedule: str
|
|
@@ -249,8 +276,9 @@ class ThreadUpdateResponse(TypedDict):
|
|
|
249
276
|
class QueueStats(TypedDict):
|
|
250
277
|
n_pending: int
|
|
251
278
|
n_running: int
|
|
252
|
-
|
|
253
|
-
|
|
279
|
+
pending_runs_wait_time_max_secs: float | None
|
|
280
|
+
pending_runs_wait_time_med_secs: float | None
|
|
281
|
+
pending_unblocked_runs_wait_time_max_secs: float | None
|
|
254
282
|
|
|
255
283
|
|
|
256
284
|
# Canonical field sets for select= validation and type aliases for ops
|
|
@@ -277,7 +305,6 @@ ThreadSelectField = Literal[
|
|
|
277
305
|
"updated_at",
|
|
278
306
|
"metadata",
|
|
279
307
|
"config",
|
|
280
|
-
"context",
|
|
281
308
|
"status",
|
|
282
309
|
"values",
|
|
283
310
|
"interrupts",
|
|
@@ -303,6 +330,7 @@ CronSelectField = Literal[
|
|
|
303
330
|
"cron_id",
|
|
304
331
|
"assistant_id",
|
|
305
332
|
"thread_id",
|
|
333
|
+
"on_run_completed",
|
|
306
334
|
"end_time",
|
|
307
335
|
"schedule",
|
|
308
336
|
"created_at",
|
|
@@ -311,6 +339,53 @@ CronSelectField = Literal[
|
|
|
311
339
|
"payload",
|
|
312
340
|
"next_run_date",
|
|
313
341
|
"metadata",
|
|
314
|
-
"now",
|
|
315
342
|
]
|
|
316
343
|
CRON_FIELDS: set[str] = set(CronSelectField.__args__) # type: ignore[attr-defined]
|
|
344
|
+
|
|
345
|
+
# Encryption field constants
|
|
346
|
+
# These define which fields are encrypted for each model type.
|
|
347
|
+
#
|
|
348
|
+
# Note: Checkpoint encryption (checkpoint, metadata columns in checkpoints table, plus
|
|
349
|
+
# blob data in checkpoint_blobs and checkpoint_writes) is handled directly by the
|
|
350
|
+
# Checkpointer class in storage_postgres/langgraph_runtime_postgres/checkpoint.py.
|
|
351
|
+
# The checkpointer uses encrypt_json_if_needed/decrypt_json_if_needed directly rather
|
|
352
|
+
# than the field list pattern used by the API middleware. This is because checkpoints
|
|
353
|
+
# are only accessed via the checkpointer's internal methods (aget_tuple, aput, etc.),
|
|
354
|
+
# not through generic API CRUD operations.
|
|
355
|
+
|
|
356
|
+
THREAD_ENCRYPTION_FIELDS = ["metadata", "config", "values", "interrupts", "error"]
|
|
357
|
+
|
|
358
|
+
# kwargs is a nested blob - its subfields are decrypted automatically by the middleware
|
|
359
|
+
RUN_ENCRYPTION_FIELDS = ["metadata", "kwargs"]
|
|
360
|
+
|
|
361
|
+
ASSISTANT_ENCRYPTION_FIELDS = ["metadata", "config", "context"]
|
|
362
|
+
|
|
363
|
+
# payload is a nested blob - its subfields are decrypted automatically by the middleware
|
|
364
|
+
CRON_ENCRYPTION_FIELDS = ["metadata", "payload"]
|
|
365
|
+
|
|
366
|
+
# Store encryption - only the value field contains user data
|
|
367
|
+
STORE_ENCRYPTION_FIELDS = ["value"]
|
|
368
|
+
|
|
369
|
+
# The middleware automatically decrypts these subfields when decrypting the parent field.
|
|
370
|
+
# This is recursive: if a subfield is also in NESTED_ENCRYPTED_SUBFIELDS, its subfields
|
|
371
|
+
# are decrypted too (e.g., run.kwargs.config.configurable).
|
|
372
|
+
NESTED_ENCRYPTED_SUBFIELDS: dict[tuple[str, str], list[str]] = {
|
|
373
|
+
("run", "kwargs"): ["input", "config", "context", "command"],
|
|
374
|
+
("run", "config"): ["configurable", "metadata"],
|
|
375
|
+
("cron", "payload"): ["metadata", "context", "input", "config"],
|
|
376
|
+
("cron", "config"): ["configurable", "metadata"],
|
|
377
|
+
("assistant", "config"): ["configurable"],
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
# Convenience alias for cron payload subfields.
|
|
381
|
+
#
|
|
382
|
+
# This is a reflection of an unfortunate asymmetry in cron's data model.
|
|
383
|
+
#
|
|
384
|
+
# The cron API requests have payload fields (metadata, input, config, context) at the
|
|
385
|
+
# top level, but at rest they're nested inside the `payload` JSONB column (with
|
|
386
|
+
# metadata also duplicated as a top-level column). This alias is used to encrypt
|
|
387
|
+
# those fields in the flat request before storage.
|
|
388
|
+
CRON_PAYLOAD_ENCRYPTION_SUBFIELDS = NESTED_ENCRYPTED_SUBFIELDS[("cron", "payload")]
|
|
389
|
+
|
|
390
|
+
# Convenience alias for run kwargs subfields, used by the worker for decryption.
|
|
391
|
+
RUN_KWARGS_ENCRYPTION_SUBFIELDS = NESTED_ENCRYPTED_SUBFIELDS[("run", "kwargs")]
|
|
@@ -20,7 +20,7 @@ _customer_attributes = {}
|
|
|
20
20
|
|
|
21
21
|
# see https://github.com/open-telemetry/opentelemetry-python/issues/3649 for why we need this
|
|
22
22
|
class AttrFilteredLoggingHandler(LoggingHandler):
|
|
23
|
-
DROP_ATTRIBUTES =
|
|
23
|
+
DROP_ATTRIBUTES = ("_logger",)
|
|
24
24
|
|
|
25
25
|
@staticmethod
|
|
26
26
|
def _get_attributes(record: logging.LogRecord) -> Attributes:
|
|
@@ -32,7 +32,7 @@ class AttrFilteredLoggingHandler(LoggingHandler):
|
|
|
32
32
|
}
|
|
33
33
|
if _customer_attributes:
|
|
34
34
|
attributes.update(_customer_attributes)
|
|
35
|
-
return cast(Attributes, attributes)
|
|
35
|
+
return cast("Attributes", attributes)
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
def initialize_self_hosted_logs() -> None:
|
|
@@ -12,11 +12,15 @@ from opentelemetry.sdk.resources import SERVICE_NAME, Resource
|
|
|
12
12
|
|
|
13
13
|
from langgraph_api import asyncio as lg_asyncio
|
|
14
14
|
from langgraph_api import config, metadata
|
|
15
|
+
from langgraph_api.feature_flags import FF_USE_CORE_API
|
|
16
|
+
from langgraph_api.grpc.ops import Runs as GrpcRuns
|
|
15
17
|
from langgraph_api.http_metrics_utils import HTTP_LATENCY_BUCKETS
|
|
16
18
|
from langgraph_runtime.database import connect, pool_stats
|
|
17
19
|
from langgraph_runtime.metrics import get_metrics
|
|
18
20
|
from langgraph_runtime.ops import Runs
|
|
19
21
|
|
|
22
|
+
CrudRuns = GrpcRuns if FF_USE_CORE_API else Runs
|
|
23
|
+
|
|
20
24
|
logger = structlog.stdlib.get_logger(__name__)
|
|
21
25
|
|
|
22
26
|
_meter_provider = None
|
|
@@ -109,6 +113,27 @@ def initialize_self_hosted_metrics():
|
|
|
109
113
|
callbacks=[_get_running_runs_callback],
|
|
110
114
|
)
|
|
111
115
|
|
|
116
|
+
meter.create_observable_gauge(
|
|
117
|
+
name="lg_api_pending_runs_wait_time_max",
|
|
118
|
+
description="The maximum time a run has been pending, in seconds",
|
|
119
|
+
unit="s",
|
|
120
|
+
callbacks=[_get_pending_runs_wait_time_max_callback],
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
meter.create_observable_gauge(
|
|
124
|
+
name="lg_api_pending_runs_wait_time_med",
|
|
125
|
+
description="The median pending wait time across runs, in seconds",
|
|
126
|
+
unit="s",
|
|
127
|
+
callbacks=[_get_pending_runs_wait_time_med_callback],
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
meter.create_observable_gauge(
|
|
131
|
+
name="lg_api_pending_unblocked_runs_wait_time_max",
|
|
132
|
+
description="The maximum time a run has been pending excluding runs blocked by another run on the same thread, in seconds",
|
|
133
|
+
unit="s",
|
|
134
|
+
callbacks=[_get_pending_unblocked_runs_wait_time_max_callback],
|
|
135
|
+
)
|
|
136
|
+
|
|
112
137
|
if config.N_JOBS_PER_WORKER > 0:
|
|
113
138
|
meter.create_observable_gauge(
|
|
114
139
|
name="lg_api_workers_max",
|
|
@@ -229,17 +254,29 @@ def _get_queue_stats():
|
|
|
229
254
|
async def _fetch_queue_stats():
|
|
230
255
|
try:
|
|
231
256
|
async with connect() as conn:
|
|
232
|
-
return await
|
|
257
|
+
return await CrudRuns.stats(conn)
|
|
233
258
|
except Exception as e:
|
|
234
259
|
logger.warning("Failed to get queue stats from database", exc_info=e)
|
|
235
|
-
return {
|
|
260
|
+
return {
|
|
261
|
+
"n_pending": 0,
|
|
262
|
+
"n_running": 0,
|
|
263
|
+
"pending_runs_wait_time_max_secs": 0,
|
|
264
|
+
"pending_runs_wait_time_med_secs": 0,
|
|
265
|
+
"pending_unblocked_runs_wait_time_max_secs": 0,
|
|
266
|
+
}
|
|
236
267
|
|
|
237
268
|
try:
|
|
238
269
|
future = lg_asyncio.run_coroutine_threadsafe(_fetch_queue_stats())
|
|
239
270
|
return future.result(timeout=5)
|
|
240
271
|
except Exception as e:
|
|
241
272
|
logger.warning("Failed to get queue stats", exc_info=e)
|
|
242
|
-
return {
|
|
273
|
+
return {
|
|
274
|
+
"n_pending": 0,
|
|
275
|
+
"n_running": 0,
|
|
276
|
+
"pending_runs_wait_time_max_secs": 0,
|
|
277
|
+
"pending_runs_wait_time_med_secs": 0,
|
|
278
|
+
"pending_unblocked_runs_wait_time_max_secs": 0,
|
|
279
|
+
}
|
|
243
280
|
|
|
244
281
|
|
|
245
282
|
def _get_pool_stats():
|
|
@@ -280,6 +317,39 @@ def _get_running_runs_callback(options: CallbackOptions):
|
|
|
280
317
|
return [Observation(0, attributes=_customer_attributes)]
|
|
281
318
|
|
|
282
319
|
|
|
320
|
+
def _get_pending_runs_wait_time_max_callback(options: CallbackOptions):
|
|
321
|
+
try:
|
|
322
|
+
stats = _get_queue_stats()
|
|
323
|
+
value = stats.get("pending_runs_wait_time_max_secs")
|
|
324
|
+
value = 0 if value is None else value
|
|
325
|
+
return [Observation(value, attributes=_customer_attributes)]
|
|
326
|
+
except Exception as e:
|
|
327
|
+
logger.warning("Failed to get max pending wait time", exc_info=e)
|
|
328
|
+
return [Observation(0, attributes=_customer_attributes)]
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _get_pending_runs_wait_time_med_callback(options: CallbackOptions):
|
|
332
|
+
try:
|
|
333
|
+
stats = _get_queue_stats()
|
|
334
|
+
value = stats.get("pending_runs_wait_time_med_secs")
|
|
335
|
+
value = 0 if value is None else value
|
|
336
|
+
return [Observation(value, attributes=_customer_attributes)]
|
|
337
|
+
except Exception as e:
|
|
338
|
+
logger.warning("Failed to get median pending wait time", exc_info=e)
|
|
339
|
+
return [Observation(0, attributes=_customer_attributes)]
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _get_pending_unblocked_runs_wait_time_max_callback(options: CallbackOptions):
|
|
343
|
+
try:
|
|
344
|
+
stats = _get_queue_stats()
|
|
345
|
+
value = stats.get("pending_unblocked_runs_wait_time_max_secs")
|
|
346
|
+
value = 0 if value is None else value
|
|
347
|
+
return [Observation(value, attributes=_customer_attributes)]
|
|
348
|
+
except Exception as e:
|
|
349
|
+
logger.warning("Failed to get max unblocked pending wait time", exc_info=e)
|
|
350
|
+
return [Observation(0, attributes=_customer_attributes)]
|
|
351
|
+
|
|
352
|
+
|
|
283
353
|
def _get_workers_max_callback(options: CallbackOptions):
|
|
284
354
|
try:
|
|
285
355
|
metrics_data = get_metrics()
|
langgraph_api/serde.py
CHANGED
|
@@ -54,7 +54,7 @@ def decimal_encoder(dec_value: Decimal) -> int | float:
|
|
|
54
54
|
# maps to float('nan') / float('inf') / float('-inf')
|
|
55
55
|
not dec_value.is_finite()
|
|
56
56
|
# or regular float
|
|
57
|
-
or cast(int, dec_value.as_tuple().exponent) < 0
|
|
57
|
+
or cast("int", dec_value.as_tuple().exponent) < 0
|
|
58
58
|
):
|
|
59
59
|
return float(dec_value)
|
|
60
60
|
return int(dec_value)
|
|
@@ -79,15 +79,15 @@ def default(obj):
|
|
|
79
79
|
return obj._asdict()
|
|
80
80
|
elif isinstance(obj, BaseException):
|
|
81
81
|
return {"error": type(obj).__name__, "message": str(obj)}
|
|
82
|
-
elif isinstance(obj, (set, frozenset, deque)):
|
|
82
|
+
elif isinstance(obj, (set, frozenset, deque)):
|
|
83
83
|
return list(obj)
|
|
84
|
-
elif isinstance(obj, (timezone, ZoneInfo)):
|
|
84
|
+
elif isinstance(obj, (timezone, ZoneInfo)):
|
|
85
85
|
return obj.tzname(None)
|
|
86
86
|
elif isinstance(obj, timedelta):
|
|
87
87
|
return obj.total_seconds()
|
|
88
88
|
elif isinstance(obj, Decimal):
|
|
89
89
|
return decimal_encoder(obj)
|
|
90
|
-
elif isinstance(
|
|
90
|
+
elif isinstance(
|
|
91
91
|
obj,
|
|
92
92
|
(
|
|
93
93
|
uuid.UUID,
|
|
@@ -160,6 +160,18 @@ def json_loads(content: bytes | Fragment | dict) -> Any:
|
|
|
160
160
|
return orjson.loads(content)
|
|
161
161
|
|
|
162
162
|
|
|
163
|
+
def json_dumpb_optional(obj: Any | None) -> bytes | None:
|
|
164
|
+
if obj is None:
|
|
165
|
+
return
|
|
166
|
+
return json_dumpb(obj)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def json_loads_optional(content: bytes | None) -> Any | None:
|
|
170
|
+
if content is None:
|
|
171
|
+
return
|
|
172
|
+
return json_loads(content)
|
|
173
|
+
|
|
174
|
+
|
|
163
175
|
# Do not use. orjson holds the GIL the entire time it's running anyway.
|
|
164
176
|
async def ajson_loads(content: bytes | Fragment) -> Any:
|
|
165
177
|
return await asyncio.to_thread(json_loads, content)
|
langgraph_api/server.py
CHANGED
|
@@ -1,29 +1,28 @@
|
|
|
1
1
|
# MONKEY PATCH: Patch Starlette to fix an error in the library
|
|
2
|
-
# ruff: noqa: E402
|
|
3
|
-
import langgraph_api.patch # noqa: F401,I001
|
|
4
|
-
import sys
|
|
5
|
-
import os
|
|
6
|
-
|
|
7
2
|
# WARNING: Keep the import above before other code runs as it
|
|
8
3
|
# patches an error in the Starlette library.
|
|
4
|
+
import langgraph_api.patch # noqa: F401,I001
|
|
5
|
+
import langgraph_api.timing as timing
|
|
9
6
|
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
10
9
|
import typing
|
|
11
10
|
|
|
12
11
|
if not (
|
|
13
12
|
(disable_truststore := os.getenv("DISABLE_TRUSTSTORE"))
|
|
14
13
|
and disable_truststore.lower() == "true"
|
|
15
14
|
):
|
|
16
|
-
import truststore
|
|
15
|
+
import truststore
|
|
17
16
|
|
|
18
|
-
truststore.inject_into_ssl()
|
|
17
|
+
truststore.inject_into_ssl()
|
|
19
18
|
|
|
20
|
-
from contextlib import asynccontextmanager
|
|
21
19
|
|
|
22
20
|
import jsonschema_rs
|
|
23
21
|
import structlog
|
|
24
22
|
from langgraph.errors import EmptyInputError, InvalidUpdateError
|
|
25
23
|
from langgraph_sdk.client import configure_loopback_transports
|
|
26
24
|
from starlette.applications import Starlette
|
|
25
|
+
from starlette.exceptions import HTTPException
|
|
27
26
|
from starlette.middleware import Middleware
|
|
28
27
|
from starlette.middleware.cors import CORSMiddleware
|
|
29
28
|
from starlette.routing import BaseRoute, Mount
|
|
@@ -39,6 +38,7 @@ from langgraph_api.api import (
|
|
|
39
38
|
)
|
|
40
39
|
from langgraph_api.api.openapi import set_custom_spec
|
|
41
40
|
from langgraph_api.errors import (
|
|
41
|
+
http_exception_handler,
|
|
42
42
|
overloaded_error_handler,
|
|
43
43
|
validation_error_handler,
|
|
44
44
|
value_error_handler,
|
|
@@ -96,6 +96,7 @@ global_middleware.extend(
|
|
|
96
96
|
]
|
|
97
97
|
)
|
|
98
98
|
exception_handlers = {
|
|
99
|
+
HTTPException: http_exception_handler,
|
|
99
100
|
ValueError: value_error_handler,
|
|
100
101
|
InvalidUpdateError: value_error_handler,
|
|
101
102
|
EmptyInputError: value_error_handler,
|
|
@@ -223,16 +224,7 @@ if user_router:
|
|
|
223
224
|
f"Cannot merge lifespans with on_startup or on_shutdown: {app.router.on_startup} {app.router.on_shutdown}"
|
|
224
225
|
)
|
|
225
226
|
|
|
226
|
-
|
|
227
|
-
async def combined_lifespan(app):
|
|
228
|
-
async with lifespan(app):
|
|
229
|
-
if user_lifespan:
|
|
230
|
-
async with user_lifespan(app):
|
|
231
|
-
yield
|
|
232
|
-
else:
|
|
233
|
-
yield
|
|
234
|
-
|
|
235
|
-
app.router.lifespan_context = combined_lifespan
|
|
227
|
+
app.router.lifespan_context = timing.combine_lifespans(lifespan, user_lifespan)
|
|
236
228
|
|
|
237
229
|
# Merge exception handlers (base + user)
|
|
238
230
|
for k, v in exception_handlers.items():
|
|
@@ -240,24 +232,30 @@ if user_router:
|
|
|
240
232
|
app.exception_handlers[k] = v
|
|
241
233
|
else:
|
|
242
234
|
logger.debug(f"Overriding exception handler for {k}")
|
|
243
|
-
# If the user creates a loopback client with `get_client() (no url)
|
|
244
|
-
# this will update the http transport to connect to the right app
|
|
245
|
-
configure_loopback_transports(app)
|
|
246
235
|
else:
|
|
247
236
|
# It's a regular starlette app
|
|
248
237
|
app = Starlette(
|
|
249
|
-
routes=
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
238
|
+
routes=[
|
|
239
|
+
*apply_middleware(
|
|
240
|
+
unshadowable_meta_routes + shadowable_meta_routes,
|
|
241
|
+
route_level_custom_middleware,
|
|
242
|
+
),
|
|
243
|
+
protected_mount,
|
|
244
|
+
],
|
|
245
|
+
lifespan=timing.combine_lifespans(lifespan),
|
|
255
246
|
middleware=global_middleware,
|
|
256
247
|
exception_handlers=exception_handlers,
|
|
257
248
|
)
|
|
258
249
|
|
|
250
|
+
# If the user creates a loopback client with `get_client() (no url)
|
|
251
|
+
# this will update the http transport to connect to the right app
|
|
252
|
+
configure_loopback_transports(app)
|
|
259
253
|
|
|
260
254
|
if config.MOUNT_PREFIX:
|
|
255
|
+
from starlette.routing import Route
|
|
256
|
+
|
|
257
|
+
from langgraph_api.api import meta_metrics, ok
|
|
258
|
+
|
|
261
259
|
prefix = config.MOUNT_PREFIX
|
|
262
260
|
if not prefix.startswith("/") or prefix.endswith("/"):
|
|
263
261
|
raise ValueError(
|
|
@@ -265,8 +263,6 @@ if config.MOUNT_PREFIX:
|
|
|
265
263
|
f"Valid examples: '/my-api', '/v1', '/api/v1'.\nInvalid examples: 'api/', '/api/'"
|
|
266
264
|
)
|
|
267
265
|
logger.info(f"Mounting routes at prefix: {prefix}")
|
|
268
|
-
plen = len(prefix)
|
|
269
|
-
rplen = len(prefix.encode("utf-8"))
|
|
270
266
|
|
|
271
267
|
class ASGIBypassMiddleware:
|
|
272
268
|
def __init__(self, app: typing.Any, **kwargs):
|
|
@@ -284,9 +280,15 @@ if config.MOUNT_PREFIX:
|
|
|
284
280
|
|
|
285
281
|
return await self.app(scope, receive, send)
|
|
286
282
|
|
|
283
|
+
# Add health checks at root still to avoid having to override health checks.
|
|
287
284
|
app = Starlette(
|
|
288
|
-
routes=[
|
|
285
|
+
routes=[
|
|
286
|
+
Route("/", ok, methods=["GET"]),
|
|
287
|
+
Route("/ok", ok, methods=["GET"]),
|
|
288
|
+
Route("/metrics", meta_metrics, methods=["GET"]),
|
|
289
|
+
Mount(prefix, app=app),
|
|
290
|
+
],
|
|
289
291
|
lifespan=app.router.lifespan_context,
|
|
290
|
-
middleware=[Middleware(ASGIBypassMiddleware)]
|
|
292
|
+
middleware=[Middleware(ASGIBypassMiddleware)],
|
|
291
293
|
exception_handlers=app.exception_handlers,
|
|
292
294
|
)
|
langgraph_api/state.py
CHANGED
|
@@ -6,12 +6,13 @@ from langgraph.types import Interrupt, StateSnapshot
|
|
|
6
6
|
|
|
7
7
|
from langgraph_api.feature_flags import USE_NEW_INTERRUPTS
|
|
8
8
|
from langgraph_api.js.base import RemoteInterrupt
|
|
9
|
-
from langgraph_api.schema import Checkpoint, DeprecatedInterrupt, ThreadState
|
|
10
|
-
from langgraph_api.schema import Interrupt as InterruptSchema
|
|
11
9
|
|
|
12
10
|
if typing.TYPE_CHECKING:
|
|
13
11
|
from langchain_core.runnables.config import RunnableConfig
|
|
14
12
|
|
|
13
|
+
from langgraph_api.schema import Checkpoint, DeprecatedInterrupt, ThreadState
|
|
14
|
+
from langgraph_api.schema import Interrupt as InterruptSchema
|
|
15
|
+
|
|
15
16
|
|
|
16
17
|
def runnable_config_to_checkpoint(
|
|
17
18
|
config: RunnableConfig | None,
|
langgraph_api/store.py
CHANGED
|
@@ -12,7 +12,8 @@ from langgraph.graph import StateGraph
|
|
|
12
12
|
from langgraph.pregel import Pregel
|
|
13
13
|
from langgraph.store.base import BaseStore
|
|
14
14
|
|
|
15
|
-
from langgraph_api import config
|
|
15
|
+
from langgraph_api import config, timing
|
|
16
|
+
from langgraph_api.timing import profiled_import
|
|
16
17
|
from langgraph_api.utils.config import run_in_executor
|
|
17
18
|
|
|
18
19
|
logger = structlog.stdlib.get_logger(__name__)
|
|
@@ -83,22 +84,30 @@ async def collect_store_from_env() -> None:
|
|
|
83
84
|
CUSTOM_STORE = value
|
|
84
85
|
|
|
85
86
|
|
|
87
|
+
@timing.timer(
|
|
88
|
+
message="Loading store {store_path}",
|
|
89
|
+
metadata_fn=lambda store_path: {"store_path": store_path},
|
|
90
|
+
warn_threshold_secs=5,
|
|
91
|
+
warn_message="Loading store '{store_path}' took longer than expected",
|
|
92
|
+
error_threshold_secs=10,
|
|
93
|
+
)
|
|
86
94
|
def _load_store(store_path: str) -> Any:
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
95
|
+
with profiled_import(store_path):
|
|
96
|
+
if "/" in store_path or ".py:" in store_path:
|
|
97
|
+
modname = "".join(choice("abcdefghijklmnopqrstuvwxyz") for _ in range(24))
|
|
98
|
+
path_name, function = store_path.rsplit(":", 1)
|
|
99
|
+
module_name = path_name.rstrip(":")
|
|
100
|
+
# Load from file path
|
|
101
|
+
modspec = importlib.util.spec_from_file_location(modname, module_name)
|
|
102
|
+
if modspec is None:
|
|
103
|
+
raise ValueError(f"Could not find store file: {path_name}")
|
|
104
|
+
module = importlib.util.module_from_spec(modspec)
|
|
105
|
+
sys.modules[module_name] = module
|
|
106
|
+
modspec.loader.exec_module(module) # type: ignore[possibly-unbound-attribute]
|
|
107
|
+
|
|
108
|
+
else:
|
|
109
|
+
path_name, function = store_path.rsplit(".", 1)
|
|
110
|
+
module = importlib.import_module(path_name)
|
|
102
111
|
|
|
103
112
|
try:
|
|
104
113
|
store: BaseStore | Callable[[config.StoreConfig], BaseStore] = module.__dict__[
|