langgraph-api 0.2.77__tar.gz → 0.2.83__tar.gz
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.
Potentially problematic release.
This version of langgraph-api might be problematic. Click here for more details.
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/Makefile +1 -1
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/PKG-INFO +1 -1
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/benchmark/README.md +12 -2
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/benchmark/burst.js +13 -6
- langgraph_api-0.2.83/langgraph_api/__init__.py +1 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/api/__init__.py +13 -1
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/api/meta.py +14 -12
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/api/openapi.py +16 -3
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/cli.py +1 -1
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/config.py +3 -1
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/http.py +7 -1
- langgraph_api-0.2.83/langgraph_api/http_metrics.py +166 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/remote.py +24 -2
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/metadata.py +73 -21
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/middleware/http_logger.py +16 -5
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/serde.py +13 -7
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/webhook.py +9 -2
- langgraph_api-0.2.77/langgraph_api/__init__.py +0 -1
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/.gitignore +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/LICENSE +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/README.md +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/benchmark/.gitignore +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/benchmark/Makefile +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/benchmark/weather.js +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/constraints.txt +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/forbidden.txt +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/healthcheck.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/api/assistants.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/api/mcp.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/api/runs.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/api/store.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/api/threads.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/api/ui.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/asgi_transport.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/asyncio.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/auth/__init__.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/auth/custom.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/auth/langsmith/__init__.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/auth/langsmith/backend.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/auth/langsmith/client.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/auth/middleware.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/auth/noop.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/auth/studio_user.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/command.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/cron_scheduler.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/errors.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/graph.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/.gitignore +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/.prettierrc +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/__init__.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/base.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/build.mts +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/client.http.mts +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/client.mts +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/errors.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/global.d.ts +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/package.json +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/schema.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/src/graph.mts +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/src/load.hooks.mjs +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/src/preload.mjs +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/src/utils/files.mts +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/src/utils/importMap.mts +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/src/utils/pythonSchemas.mts +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/src/utils/serde.mts +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/sse.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/tsconfig.json +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/ui.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/js/yarn.lock +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/logging.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/middleware/__init__.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/middleware/private_network.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/middleware/request_id.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/models/__init__.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/models/run.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/patch.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/queue_entrypoint.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/route.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/schema.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/server.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/sse.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/state.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/store.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/stream.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/thread_ttl.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/tunneling/cloudflare.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/utils/__init__.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/utils/config.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/utils/future.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/utils.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/validation.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_api/worker.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_license/__init__.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_license/validation.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_runtime/__init__.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_runtime/checkpoint.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_runtime/database.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_runtime/lifespan.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_runtime/metrics.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_runtime/ops.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_runtime/queue.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_runtime/retry.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/langgraph_runtime/store.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/logging.json +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/openapi.json +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/pyproject.toml +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/scripts/create_license.py +0 -0
- {langgraph_api-0.2.77 → langgraph_api-0.2.83}/uv.lock +0 -0
|
@@ -6,7 +6,14 @@ K6 is a modern load testing tool that allows you to test the performance and rel
|
|
|
6
6
|
|
|
7
7
|
### Available Tests
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
We use a local benchmark agent that has a MODE that can be any of the following:
|
|
10
|
+
- `single` - Run a single node
|
|
11
|
+
- `parallel` - Run EXPAND nodes in parallel
|
|
12
|
+
- `sequential` - Run EXPAND nodes in sequence
|
|
13
|
+
|
|
14
|
+
By default, MODE is `single` and EXPAND is 50.
|
|
15
|
+
|
|
16
|
+
1. Burst - Kick off a burst of /run/wait requests. Default BURST_SIZE is 100.
|
|
10
17
|
|
|
11
18
|
## Running Tests Locally
|
|
12
19
|
|
|
@@ -19,12 +26,15 @@ K6 is a modern load testing tool that allows you to test the performance and rel
|
|
|
19
26
|
### Basic Usage
|
|
20
27
|
|
|
21
28
|
```bash
|
|
22
|
-
# Run burst test with default burst size
|
|
29
|
+
# Run burst test with default burst size
|
|
23
30
|
make benchmark-burst
|
|
24
31
|
|
|
25
32
|
# Run burst test with custom burst size
|
|
26
33
|
BURST_SIZE=500 make benchmark-burst
|
|
27
34
|
|
|
35
|
+
# Run burst test with a different mode and expand size
|
|
36
|
+
MODE='parallel' EXPAND=100 make benchmark-burst
|
|
37
|
+
|
|
28
38
|
# Run burst test against a deployment
|
|
29
39
|
BASE_URL=https://jdr-debug-31ac2c83eef557309f21c1e98d822025.us.langgraph.app make benchmark-burst
|
|
30
40
|
|
|
@@ -15,7 +15,9 @@ const burstSuccessRate = new Rate('burst_success_rate');
|
|
|
15
15
|
|
|
16
16
|
// URL of your LangGraph server
|
|
17
17
|
const BASE_URL = __ENV.BASE_URL || 'http://localhost:9123';
|
|
18
|
-
const BURST_SIZE = parseInt(__ENV.BURST_SIZE || '
|
|
18
|
+
const BURST_SIZE = parseInt(__ENV.BURST_SIZE || '100');
|
|
19
|
+
const MODE = __ENV.MODE || 'single';
|
|
20
|
+
const EXPAND = parseInt(__ENV.EXPAND || '50');
|
|
19
21
|
|
|
20
22
|
// Burst testing configuration
|
|
21
23
|
export let options = {
|
|
@@ -28,7 +30,7 @@ export let options = {
|
|
|
28
30
|
},
|
|
29
31
|
},
|
|
30
32
|
thresholds: {
|
|
31
|
-
'run_duration': ['p(95)<
|
|
33
|
+
'run_duration': ['p(95)<2000'],
|
|
32
34
|
'burst_success_rate': ['rate>0.99'],
|
|
33
35
|
},
|
|
34
36
|
};
|
|
@@ -50,8 +52,10 @@ export default function() {
|
|
|
50
52
|
// Create a payload with the LangGraph agent configuration
|
|
51
53
|
const payload = JSON.stringify({
|
|
52
54
|
assistant_id: "benchmark",
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
input: {mode: MODE, expand: EXPAND},
|
|
56
|
+
config: {
|
|
57
|
+
recursion_limit: EXPAND + 2,
|
|
58
|
+
}
|
|
55
59
|
});
|
|
56
60
|
|
|
57
61
|
// Make a single request to the wait endpoint
|
|
@@ -60,15 +64,18 @@ export default function() {
|
|
|
60
64
|
timeout: '35s'
|
|
61
65
|
});
|
|
62
66
|
|
|
67
|
+
// Don't include verification in the duration of the request
|
|
68
|
+
const duration = new Date().getTime() - startTime;
|
|
69
|
+
|
|
63
70
|
// Check the response
|
|
71
|
+
const expected_length = MODE === 'single' ? 1 : EXPAND + 1;
|
|
64
72
|
const success = check(response, {
|
|
65
73
|
'Run completed successfully': (r) => r.status === 200,
|
|
66
|
-
'Response contains
|
|
74
|
+
'Response contains expected number of messages': (r) => JSON.parse(r.body).messages.length === expected_length,
|
|
67
75
|
});
|
|
68
76
|
|
|
69
77
|
if (success) {
|
|
70
78
|
// Record success metrics
|
|
71
|
-
const duration = new Date().getTime() - startTime;
|
|
72
79
|
runDuration.add(duration);
|
|
73
80
|
successfulRuns.add(1);
|
|
74
81
|
burstSuccessRate.add(1); // 1 = success
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.83"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import functools
|
|
2
3
|
import importlib
|
|
3
4
|
import importlib.util
|
|
4
5
|
import os
|
|
@@ -27,7 +28,11 @@ from langgraph_runtime.database import connect, healthcheck
|
|
|
27
28
|
logger = structlog.stdlib.get_logger(__name__)
|
|
28
29
|
|
|
29
30
|
|
|
30
|
-
async def ok(request: Request):
|
|
31
|
+
async def ok(request: Request, *, disabled: bool = False):
|
|
32
|
+
if disabled:
|
|
33
|
+
# We still expose an /ok endpoint even if disable_meta is set so that
|
|
34
|
+
# the operator knows the server started up.
|
|
35
|
+
return JSONResponse({"ok": True})
|
|
31
36
|
check_db = int(request.query_params.get("check_db", "0")) # must be "0" or "1"
|
|
32
37
|
if check_db:
|
|
33
38
|
await healthcheck()
|
|
@@ -126,6 +131,13 @@ if HTTP_CONFIG:
|
|
|
126
131
|
user_router = load_custom_app(router_import)
|
|
127
132
|
if not HTTP_CONFIG.get("disable_meta"):
|
|
128
133
|
routes.extend(meta_routes)
|
|
134
|
+
else:
|
|
135
|
+
# Otherwise the deployment will never be considered healthy
|
|
136
|
+
routes.append(
|
|
137
|
+
Route(
|
|
138
|
+
"/ok", functools.partial(ok, disabled=True), methods=["GET"], name="ok"
|
|
139
|
+
)
|
|
140
|
+
)
|
|
129
141
|
if protected_routes:
|
|
130
142
|
routes.append(
|
|
131
143
|
Mount(
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import os
|
|
2
|
-
|
|
3
1
|
from starlette.responses import JSONResponse, PlainTextResponse
|
|
4
2
|
|
|
5
3
|
from langgraph_api import __version__, config, metadata
|
|
4
|
+
from langgraph_api.http_metrics import HTTP_METRICS_COLLECTOR
|
|
6
5
|
from langgraph_api.route import ApiRequest
|
|
7
6
|
from langgraph_license.validation import plus_features_enabled
|
|
8
7
|
from langgraph_runtime.database import connect, pool_stats
|
|
@@ -26,6 +25,7 @@ async def meta_info(request: ApiRequest):
|
|
|
26
25
|
"host": {
|
|
27
26
|
"kind": metadata.HOST,
|
|
28
27
|
"project_id": metadata.PROJECT_ID,
|
|
28
|
+
"host_revision_id": metadata.HOST_REVISION_ID,
|
|
29
29
|
"revision_id": metadata.REVISION,
|
|
30
30
|
"tenant_id": metadata.TENANT_ID,
|
|
31
31
|
},
|
|
@@ -46,31 +46,31 @@ async def meta_metrics(request: ApiRequest):
|
|
|
46
46
|
workers_active = worker_metrics["active"]
|
|
47
47
|
workers_available = worker_metrics["available"]
|
|
48
48
|
|
|
49
|
+
http_metrics = HTTP_METRICS_COLLECTOR.get_metrics(
|
|
50
|
+
metadata.PROJECT_ID, metadata.HOST_REVISION_ID, metrics_format
|
|
51
|
+
)
|
|
52
|
+
|
|
49
53
|
if metrics_format == "json":
|
|
50
54
|
async with connect() as conn:
|
|
51
55
|
resp = {
|
|
52
56
|
**pool_stats(),
|
|
53
57
|
"queue": await Runs.stats(conn),
|
|
58
|
+
**http_metrics,
|
|
54
59
|
}
|
|
55
60
|
if config.N_JOBS_PER_WORKER > 0:
|
|
56
61
|
resp["workers"] = worker_metrics
|
|
57
62
|
return JSONResponse(resp)
|
|
58
63
|
elif metrics_format == "prometheus":
|
|
59
|
-
# LANGSMITH_HOST_PROJECT_ID and LANGSMITH_HOST_REVISION_ID are injected
|
|
60
|
-
# into the deployed image by host-backend.
|
|
61
|
-
project_id = os.getenv("LANGSMITH_HOST_PROJECT_ID")
|
|
62
|
-
revision_id = os.getenv("LANGSMITH_HOST_REVISION_ID")
|
|
63
|
-
|
|
64
64
|
async with connect() as conn:
|
|
65
65
|
queue_stats = await Runs.stats(conn)
|
|
66
66
|
|
|
67
67
|
metrics = [
|
|
68
68
|
"# HELP lg_api_num_pending_runs The number of runs currently pending.",
|
|
69
69
|
"# TYPE lg_api_num_pending_runs gauge",
|
|
70
|
-
f'lg_api_num_pending_runs{{project_id="{
|
|
70
|
+
f'lg_api_num_pending_runs{{project_id="{metadata.PROJECT_ID}", revision_id="{metadata.HOST_REVISION_ID}"}} {queue_stats["n_pending"]}',
|
|
71
71
|
"# HELP lg_api_num_running_runs The number of runs currently running.",
|
|
72
72
|
"# TYPE lg_api_num_running_runs gauge",
|
|
73
|
-
f'lg_api_num_running_runs{{project_id="{
|
|
73
|
+
f'lg_api_num_running_runs{{project_id="{metadata.PROJECT_ID}", revision_id="{metadata.HOST_REVISION_ID}"}} {queue_stats["n_running"]}',
|
|
74
74
|
]
|
|
75
75
|
|
|
76
76
|
if config.N_JOBS_PER_WORKER > 0:
|
|
@@ -78,15 +78,17 @@ async def meta_metrics(request: ApiRequest):
|
|
|
78
78
|
[
|
|
79
79
|
"# HELP lg_api_workers_max The maximum number of workers available.",
|
|
80
80
|
"# TYPE lg_api_workers_max gauge",
|
|
81
|
-
f'lg_api_workers_max{{project_id="{
|
|
81
|
+
f'lg_api_workers_max{{project_id="{metadata.PROJECT_ID}", revision_id="{metadata.HOST_REVISION_ID}"}} {workers_max}',
|
|
82
82
|
"# HELP lg_api_workers_active The number of currently active workers.",
|
|
83
83
|
"# TYPE lg_api_workers_active gauge",
|
|
84
|
-
f'lg_api_workers_active{{project_id="{
|
|
84
|
+
f'lg_api_workers_active{{project_id="{metadata.PROJECT_ID}", revision_id="{metadata.HOST_REVISION_ID}"}} {workers_active}',
|
|
85
85
|
"# HELP lg_api_workers_available The number of available (idle) workers.",
|
|
86
86
|
"# TYPE lg_api_workers_available gauge",
|
|
87
|
-
f'lg_api_workers_available{{project_id="{
|
|
87
|
+
f'lg_api_workers_available{{project_id="{metadata.PROJECT_ID}", revision_id="{metadata.HOST_REVISION_ID}"}} {workers_available}',
|
|
88
88
|
]
|
|
89
89
|
)
|
|
90
90
|
|
|
91
|
+
metrics.extend(http_metrics)
|
|
92
|
+
|
|
91
93
|
metrics_response = "\n".join(metrics)
|
|
92
94
|
return PlainTextResponse(metrics_response)
|
|
@@ -80,6 +80,19 @@ def get_openapi_spec() -> str:
|
|
|
80
80
|
"API documentation will not show authentication requirements. "
|
|
81
81
|
"Add 'openapi' section to auth section of your `langgraph.json` file to specify security schemes."
|
|
82
82
|
)
|
|
83
|
+
|
|
84
|
+
# Remove webhook parameters if webhooks are disabled
|
|
85
|
+
if HTTP_CONFIG and HTTP_CONFIG.get("disable_webhooks"):
|
|
86
|
+
webhook_schemas = ["CronCreate", "RunCreateStateful", "RunCreateStateless"]
|
|
87
|
+
for schema_name in webhook_schemas:
|
|
88
|
+
if schema_name in openapi["components"]["schemas"]:
|
|
89
|
+
schema = openapi["components"]["schemas"][schema_name]
|
|
90
|
+
if "properties" in schema and "webhook" in schema["properties"]:
|
|
91
|
+
del schema["properties"]["webhook"]
|
|
92
|
+
logger.info(
|
|
93
|
+
f"Removed webhook parameter from {schema_name} schema due to disable_webhooks setting"
|
|
94
|
+
)
|
|
95
|
+
|
|
83
96
|
final = openapi
|
|
84
97
|
if CUSTOM_OPENAPI_SPEC:
|
|
85
98
|
final = merge_openapi_specs(openapi, CUSTOM_OPENAPI_SPEC)
|
|
@@ -100,11 +113,11 @@ def merge_openapi_specs(spec_a: dict, spec_b: dict) -> dict:
|
|
|
100
113
|
Merge two OpenAPI specifications with spec_b taking precedence on conflicts.
|
|
101
114
|
|
|
102
115
|
This function handles merging of the following keys:
|
|
103
|
-
- "openapi": Uses spec_b
|
|
116
|
+
- "openapi": Uses spec_b's version.
|
|
104
117
|
- "info": Merges dictionaries with spec_b taking precedence.
|
|
105
118
|
- "servers": Merges lists with deduplication (by URL and description).
|
|
106
119
|
- "paths": For shared paths, merges HTTP methods:
|
|
107
|
-
- If a method exists in both, spec_b
|
|
120
|
+
- If a method exists in both, spec_b's definition wins.
|
|
108
121
|
- Otherwise, methods from both are preserved.
|
|
109
122
|
Additionally, merges path-level "parameters" by (name, in).
|
|
110
123
|
- "components": Merges per component type (schemas, responses, etc.).
|
|
@@ -217,7 +230,7 @@ def _merge_paths(paths_a: dict, paths_b: dict) -> dict:
|
|
|
217
230
|
|
|
218
231
|
For each path:
|
|
219
232
|
- If the path exists in both specs, merge HTTP methods:
|
|
220
|
-
- If a method exists in both, use spec_b
|
|
233
|
+
- If a method exists in both, use spec_b's definition.
|
|
221
234
|
- Otherwise, preserve both.
|
|
222
235
|
- Additionally, merge path-level "parameters" if present.
|
|
223
236
|
|
|
@@ -346,7 +346,7 @@ def run_server(
|
|
|
346
346
|
- 📚 API Docs: \033[36m{local_url}/docs\033[0m
|
|
347
347
|
|
|
348
348
|
This in-memory server is designed for development and testing.
|
|
349
|
-
For production use, please use LangGraph
|
|
349
|
+
For production use, please use LangGraph Platform.
|
|
350
350
|
|
|
351
351
|
"""
|
|
352
352
|
logger.info(welcome)
|
|
@@ -37,6 +37,8 @@ class HttpConfig(TypedDict, total=False):
|
|
|
37
37
|
"""Disable /store routes"""
|
|
38
38
|
disable_meta: bool
|
|
39
39
|
"""Disable /ok, /info, /metrics, and /docs routes"""
|
|
40
|
+
disable_webhooks: bool
|
|
41
|
+
"""Disable webhooks calls on run completion in all routes"""
|
|
40
42
|
cors: CorsConfig | None
|
|
41
43
|
"""CORS configuration"""
|
|
42
44
|
disable_ui: bool
|
|
@@ -153,7 +155,7 @@ POSTGRES_POOL_MAX_SIZE = env("LANGGRAPH_POSTGRES_POOL_MAX_SIZE", cast=int, defau
|
|
|
153
155
|
RESUMABLE_STREAM_TTL_SECONDS = env(
|
|
154
156
|
"RESUMABLE_STREAM_TTL_SECONDS",
|
|
155
157
|
cast=int,
|
|
156
|
-
default=
|
|
158
|
+
default=120, # 2 minutes
|
|
157
159
|
)
|
|
158
160
|
|
|
159
161
|
|
|
@@ -114,6 +114,11 @@ def get_loopback_client() -> JsonHttpClient:
|
|
|
114
114
|
|
|
115
115
|
|
|
116
116
|
def is_retriable_error(exception: Exception) -> bool:
|
|
117
|
+
# httpx error hierarchy: https://www.python-httpx.org/exceptions/
|
|
118
|
+
# Retry all timeout related errors
|
|
119
|
+
if isinstance(exception, httpx.TimeoutException | httpx.NetworkError):
|
|
120
|
+
return True
|
|
121
|
+
# Seems to just apply to HttpStatusError but doesn't hurt to check all
|
|
117
122
|
if isinstance(exception, httpx.HTTPError):
|
|
118
123
|
return (
|
|
119
124
|
getattr(exception, "response", None) is not None
|
|
@@ -143,6 +148,7 @@ async def http_request(
|
|
|
143
148
|
connect_timeout: float | None = 5,
|
|
144
149
|
request_timeout: float | None = 30,
|
|
145
150
|
raise_error: bool = True,
|
|
151
|
+
client: JsonHttpClient | None = None,
|
|
146
152
|
) -> httpx.Response:
|
|
147
153
|
"""Make an HTTP request with retries.
|
|
148
154
|
|
|
@@ -163,7 +169,7 @@ async def http_request(
|
|
|
163
169
|
if not path.startswith(("http://", "https://", "/")):
|
|
164
170
|
raise ValueError("path must start with / or http")
|
|
165
171
|
|
|
166
|
-
client = get_http_client()
|
|
172
|
+
client = client or get_http_client()
|
|
167
173
|
|
|
168
174
|
content = None
|
|
169
175
|
if body is not None:
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
FILTERED_ROUTES = {"/ok", "/info", "/metrics", "/docs", "/openapi.json"}
|
|
5
|
+
|
|
6
|
+
MAX_REQUEST_COUNT_ENTRIES = 5000
|
|
7
|
+
MAX_HISTOGRAM_ENTRIES = 1000
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_route(route: Any) -> str | None:
|
|
11
|
+
try:
|
|
12
|
+
# default lg api routes use the custom APIRoute where scope["route"] is set to a string
|
|
13
|
+
if isinstance(route, str):
|
|
14
|
+
return route
|
|
15
|
+
else:
|
|
16
|
+
# custom FastAPI routes provided by user_router attach an object to scope["route"]
|
|
17
|
+
route_path = getattr(route, "path", None)
|
|
18
|
+
return route_path
|
|
19
|
+
except Exception:
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def should_filter_route(route_path: str) -> bool:
|
|
24
|
+
# use endswith to honor MOUNT_PREFIX
|
|
25
|
+
return any(route_path.endswith(suffix) for suffix in FILTERED_ROUTES)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HTTPMetricsCollector:
|
|
29
|
+
def __init__(self):
|
|
30
|
+
# Counter: Key: (method, route, status), Value: count
|
|
31
|
+
self._request_counts: dict[tuple[str, str, int], int] = defaultdict(int)
|
|
32
|
+
|
|
33
|
+
self._histogram_buckets = [
|
|
34
|
+
0.01,
|
|
35
|
+
0.1,
|
|
36
|
+
0.5,
|
|
37
|
+
1,
|
|
38
|
+
5,
|
|
39
|
+
15,
|
|
40
|
+
30,
|
|
41
|
+
60,
|
|
42
|
+
120,
|
|
43
|
+
300,
|
|
44
|
+
600,
|
|
45
|
+
1800,
|
|
46
|
+
3600,
|
|
47
|
+
float("inf"),
|
|
48
|
+
]
|
|
49
|
+
self._histogram_bucket_labels = [
|
|
50
|
+
"+Inf" if value == float("inf") else str(value)
|
|
51
|
+
for value in self._histogram_buckets
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
self._histogram_data: dict[tuple[str, str], dict] = defaultdict(
|
|
55
|
+
lambda: {
|
|
56
|
+
"bucket_counts": [0] * len(self._histogram_buckets),
|
|
57
|
+
"sum": 0.0,
|
|
58
|
+
"count": 0,
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def record_request(
|
|
63
|
+
self, method: str, route: Any, status: int, latency_ms: float
|
|
64
|
+
) -> None:
|
|
65
|
+
route_path = get_route(route)
|
|
66
|
+
if route_path is None:
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
if should_filter_route(route_path):
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
request_count_key = (method, route_path, status)
|
|
73
|
+
histogram_key = (method, route_path)
|
|
74
|
+
|
|
75
|
+
if (
|
|
76
|
+
request_count_key not in self._request_counts
|
|
77
|
+
and len(self._request_counts) >= MAX_REQUEST_COUNT_ENTRIES
|
|
78
|
+
):
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
if (
|
|
82
|
+
histogram_key not in self._histogram_data
|
|
83
|
+
and len(self._histogram_data) >= MAX_HISTOGRAM_ENTRIES
|
|
84
|
+
):
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
self._request_counts[request_count_key] += 1
|
|
88
|
+
|
|
89
|
+
latency_seconds = latency_ms / 1000.0
|
|
90
|
+
hist_data = self._histogram_data[histogram_key]
|
|
91
|
+
|
|
92
|
+
for i, bucket_value in enumerate(self._histogram_buckets):
|
|
93
|
+
if latency_seconds <= bucket_value:
|
|
94
|
+
hist_data["bucket_counts"][i] += 1
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
hist_data["sum"] += latency_seconds
|
|
98
|
+
hist_data["count"] += 1
|
|
99
|
+
|
|
100
|
+
def get_metrics(
|
|
101
|
+
self, project_id: str, revision_id: str, format: str = "prometheus"
|
|
102
|
+
) -> dict | list[str]:
|
|
103
|
+
if format == "json":
|
|
104
|
+
return {
|
|
105
|
+
"api": {
|
|
106
|
+
"http_requests_total": [
|
|
107
|
+
{
|
|
108
|
+
"method": method,
|
|
109
|
+
"path": path,
|
|
110
|
+
"status": status,
|
|
111
|
+
"count": count,
|
|
112
|
+
}
|
|
113
|
+
for (
|
|
114
|
+
method,
|
|
115
|
+
path,
|
|
116
|
+
status,
|
|
117
|
+
), count in self._request_counts.items()
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
metrics = []
|
|
123
|
+
|
|
124
|
+
# Counter metrics
|
|
125
|
+
if self._request_counts:
|
|
126
|
+
metrics.extend(
|
|
127
|
+
[
|
|
128
|
+
"# HELP lg_api_http_requests_total Total number of HTTP requests.",
|
|
129
|
+
"# TYPE lg_api_http_requests_total counter",
|
|
130
|
+
]
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
for (method, path, status), count in self._request_counts.items():
|
|
134
|
+
metrics.append(
|
|
135
|
+
f'lg_api_http_requests_total{{project_id="{project_id}", revision_id="{revision_id}", method="{method}", path="{path}", status="{status}"}} {count}'
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Histogram metrics
|
|
139
|
+
if self._histogram_data:
|
|
140
|
+
metrics.extend(
|
|
141
|
+
[
|
|
142
|
+
"# HELP lg_api_http_requests_latency_seconds HTTP request latency in seconds.",
|
|
143
|
+
"# TYPE lg_api_http_requests_latency_seconds histogram",
|
|
144
|
+
]
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
for (method, path), hist_data in self._histogram_data.items():
|
|
148
|
+
acc = 0
|
|
149
|
+
for i, bucket_count in enumerate(hist_data["bucket_counts"]):
|
|
150
|
+
acc += bucket_count
|
|
151
|
+
bucket_label = self._histogram_bucket_labels[i]
|
|
152
|
+
metrics.append(
|
|
153
|
+
f'lg_api_http_requests_latency_seconds_bucket{{project_id="{project_id}", revision_id="{revision_id}", method="{method}", path="{path}", le="{bucket_label}"}} {acc}'
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
metrics.extend(
|
|
157
|
+
[
|
|
158
|
+
f'lg_api_http_requests_latency_seconds_sum{{project_id="{project_id}", revision_id="{revision_id}", method="{method}", path="{path}"}} {hist_data["sum"]:.6f}',
|
|
159
|
+
f'lg_api_http_requests_latency_seconds_count{{project_id="{project_id}", revision_id="{revision_id}", method="{method}", path="{path}"}} {hist_data["count"]}',
|
|
160
|
+
]
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return metrics
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
HTTP_METRICS_COLLECTOR = HTTPMetricsCollector()
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import json
|
|
2
3
|
import logging
|
|
3
4
|
import os
|
|
5
|
+
import re
|
|
4
6
|
import shutil
|
|
5
7
|
import ssl
|
|
6
8
|
from collections import deque
|
|
@@ -452,6 +454,26 @@ async def run_js_http_process(paths_str: str, http_config: dict, watch: bool = F
|
|
|
452
454
|
attempt += 1
|
|
453
455
|
|
|
454
456
|
|
|
457
|
+
_BAD_SURROGATE_RE = re.compile(r"\\u[dD][89a-fA-F][0-9a-fA-F]{2}")
|
|
458
|
+
_BAD_ESCAPE_RE = re.compile(r"\\(?![\"\\/bfnrtu])")
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _safe_json_loads(data: bytes):
|
|
462
|
+
"""Attempt *orjson.loads* first; if it fails, repair common escape issues.
|
|
463
|
+
|
|
464
|
+
For a time, we had a bug in our surrogate cleanup in serde.py, which
|
|
465
|
+
allowed sequences containing a stray backslash to be stored which would
|
|
466
|
+
then fail upon loading. This function attempts to repair those sequences.
|
|
467
|
+
"""
|
|
468
|
+
try:
|
|
469
|
+
return orjson.loads(data)
|
|
470
|
+
except orjson.JSONDecodeError:
|
|
471
|
+
txt = data.decode("utf-8", "replace")
|
|
472
|
+
txt = _BAD_ESCAPE_RE.sub(r"\\\\", txt)
|
|
473
|
+
txt = _BAD_SURROGATE_RE.sub("", txt)
|
|
474
|
+
return json.loads(txt)
|
|
475
|
+
|
|
476
|
+
|
|
455
477
|
class PassthroughSerialiser(SerializerProtocol):
|
|
456
478
|
def dumps(self, obj: Any) -> bytes:
|
|
457
479
|
return json_dumpb(obj)
|
|
@@ -460,13 +482,13 @@ class PassthroughSerialiser(SerializerProtocol):
|
|
|
460
482
|
return "json", json_dumpb(obj)
|
|
461
483
|
|
|
462
484
|
def loads(self, data: bytes) -> Any:
|
|
463
|
-
return
|
|
485
|
+
return _safe_json_loads(data)
|
|
464
486
|
|
|
465
487
|
def loads_typed(self, data: tuple[str, bytes]) -> Any:
|
|
466
488
|
type, payload = data
|
|
467
489
|
if type != "json":
|
|
468
490
|
raise ValueError(f"Unsupported type {type}")
|
|
469
|
-
return
|
|
491
|
+
return _safe_json_loads(payload)
|
|
470
492
|
|
|
471
493
|
|
|
472
494
|
def _get_passthrough_checkpointer():
|
|
@@ -26,6 +26,7 @@ INTERVAL = 300
|
|
|
26
26
|
REVISION = os.getenv("LANGSMITH_LANGGRAPH_API_REVISION")
|
|
27
27
|
VARIANT = os.getenv("LANGSMITH_LANGGRAPH_API_VARIANT")
|
|
28
28
|
PROJECT_ID = os.getenv("LANGSMITH_HOST_PROJECT_ID")
|
|
29
|
+
HOST_REVISION_ID = os.getenv("LANGSMITH_HOST_REVISION_ID")
|
|
29
30
|
TENANT_ID = os.getenv("LANGSMITH_TENANT_ID")
|
|
30
31
|
if PROJECT_ID:
|
|
31
32
|
try:
|
|
@@ -54,13 +55,15 @@ RUN_COUNTER = 0
|
|
|
54
55
|
NODE_COUNTER = 0
|
|
55
56
|
FROM_TIMESTAMP = datetime.now(UTC).isoformat()
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
58
|
+
# Beacon endpoint for license key submissions
|
|
59
|
+
BEACON_ENDPOINT = "https://api.smith.langchain.com/v1/metadata/submit"
|
|
60
|
+
|
|
61
|
+
# LangChain auth endpoint for API key submissions
|
|
62
|
+
LANGCHAIN_METADATA_ENDPOINT = None
|
|
63
|
+
if LANGSMITH_AUTH_ENDPOINT:
|
|
64
|
+
LANGCHAIN_METADATA_ENDPOINT = (
|
|
65
|
+
LANGSMITH_AUTH_ENDPOINT.rstrip("/") + "/v1/metadata/submit"
|
|
66
|
+
)
|
|
64
67
|
|
|
65
68
|
|
|
66
69
|
def incr_runs(*, incr: int = 1) -> None:
|
|
@@ -81,8 +84,10 @@ async def metadata_loop() -> None:
|
|
|
81
84
|
if not LANGGRAPH_CLOUD_LICENSE_KEY and not LANGSMITH_API_KEY:
|
|
82
85
|
return
|
|
83
86
|
|
|
84
|
-
if
|
|
85
|
-
|
|
87
|
+
if (
|
|
88
|
+
LANGGRAPH_CLOUD_LICENSE_KEY
|
|
89
|
+
and not LANGGRAPH_CLOUD_LICENSE_KEY.startswith("lcl_")
|
|
90
|
+
and not LANGSMITH_API_KEY
|
|
86
91
|
):
|
|
87
92
|
logger.info("Running in air-gapped mode, skipping metadata loop")
|
|
88
93
|
return
|
|
@@ -101,9 +106,7 @@ async def metadata_loop() -> None:
|
|
|
101
106
|
NODE_COUNTER = 0
|
|
102
107
|
FROM_TIMESTAMP = to_timestamp
|
|
103
108
|
|
|
104
|
-
|
|
105
|
-
"license_key": LANGGRAPH_CLOUD_LICENSE_KEY,
|
|
106
|
-
"api_key": LANGSMITH_API_KEY,
|
|
109
|
+
base_payload = {
|
|
107
110
|
"from_timestamp": from_timestamp,
|
|
108
111
|
"to_timestamp": to_timestamp,
|
|
109
112
|
"tags": {
|
|
@@ -129,17 +132,66 @@ async def metadata_loop() -> None:
|
|
|
129
132
|
},
|
|
130
133
|
"logs": [],
|
|
131
134
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
135
|
+
|
|
136
|
+
# Track successful submissions
|
|
137
|
+
submissions_attempted = []
|
|
138
|
+
submissions_failed = []
|
|
139
|
+
|
|
140
|
+
# 1. Send to beacon endpoint if license key starts with lcl_
|
|
141
|
+
if LANGGRAPH_CLOUD_LICENSE_KEY and LANGGRAPH_CLOUD_LICENSE_KEY.startswith(
|
|
142
|
+
"lcl_"
|
|
143
|
+
):
|
|
144
|
+
beacon_payload = {
|
|
145
|
+
**base_payload,
|
|
146
|
+
"license_key": LANGGRAPH_CLOUD_LICENSE_KEY,
|
|
147
|
+
}
|
|
148
|
+
submissions_attempted.append("beacon")
|
|
149
|
+
try:
|
|
150
|
+
await http_request(
|
|
151
|
+
"POST",
|
|
152
|
+
BEACON_ENDPOINT,
|
|
153
|
+
body=orjson.dumps(beacon_payload),
|
|
154
|
+
headers={"Content-Type": "application/json"},
|
|
155
|
+
)
|
|
156
|
+
await logger.ainfo("Successfully submitted metadata to beacon endpoint")
|
|
157
|
+
except Exception as e:
|
|
158
|
+
submissions_failed.append("beacon")
|
|
159
|
+
await logger.awarning(
|
|
160
|
+
"Beacon metadata submission failed.", error=str(e)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# 2. Send to langchain auth endpoint if API key is set
|
|
164
|
+
if LANGSMITH_API_KEY and LANGCHAIN_METADATA_ENDPOINT:
|
|
165
|
+
langchain_payload = {
|
|
166
|
+
**base_payload,
|
|
167
|
+
"api_key": LANGSMITH_API_KEY,
|
|
168
|
+
}
|
|
169
|
+
submissions_attempted.append("langchain")
|
|
170
|
+
try:
|
|
171
|
+
await http_request(
|
|
172
|
+
"POST",
|
|
173
|
+
LANGCHAIN_METADATA_ENDPOINT,
|
|
174
|
+
body=orjson.dumps(langchain_payload),
|
|
175
|
+
headers={"Content-Type": "application/json"},
|
|
176
|
+
)
|
|
177
|
+
logger.info("Successfully submitted metadata to LangSmith instance")
|
|
178
|
+
except Exception as e:
|
|
179
|
+
submissions_failed.append("langchain")
|
|
180
|
+
await logger.awarning(
|
|
181
|
+
"LangChain metadata submission failed.", error=str(e)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if submissions_attempted and len(submissions_failed) == len(
|
|
185
|
+
submissions_attempted
|
|
186
|
+
):
|
|
140
187
|
# retry on next iteration
|
|
141
188
|
incr_runs(incr=runs)
|
|
142
189
|
incr_nodes("", incr=nodes)
|
|
143
190
|
FROM_TIMESTAMP = from_timestamp
|
|
144
|
-
await logger.
|
|
191
|
+
await logger.awarning(
|
|
192
|
+
"All metadata submissions failed, will retry",
|
|
193
|
+
attempted=submissions_attempted,
|
|
194
|
+
failed=submissions_failed,
|
|
195
|
+
)
|
|
196
|
+
|
|
145
197
|
await asyncio.sleep(INTERVAL)
|
|
@@ -5,6 +5,8 @@ import structlog
|
|
|
5
5
|
from starlette.requests import ClientDisconnect
|
|
6
6
|
from starlette.types import Message, Receive, Scope, Send
|
|
7
7
|
|
|
8
|
+
from langgraph_api.http_metrics import HTTP_METRICS_COLLECTOR
|
|
9
|
+
|
|
8
10
|
asgi = structlog.stdlib.get_logger("asgi")
|
|
9
11
|
|
|
10
12
|
PATHS_IGNORE = {"/ok", "/metrics"}
|
|
@@ -64,13 +66,22 @@ class AccessLoggerMiddleware:
|
|
|
64
66
|
finally:
|
|
65
67
|
info["end_time"] = loop.time()
|
|
66
68
|
latency = int((info["end_time"] - info["start_time"]) * 1_000)
|
|
69
|
+
|
|
70
|
+
status = info["response"].get("status")
|
|
71
|
+
method = scope.get("method")
|
|
72
|
+
path = scope.get("path")
|
|
73
|
+
route = scope.get("route")
|
|
74
|
+
|
|
75
|
+
if method and route and status:
|
|
76
|
+
HTTP_METRICS_COLLECTOR.record_request(method, route, status, latency)
|
|
77
|
+
|
|
67
78
|
self.logger.info(
|
|
68
|
-
f"{
|
|
69
|
-
method=
|
|
70
|
-
path=
|
|
71
|
-
status=
|
|
79
|
+
f"{method} {path} {status} {latency}ms",
|
|
80
|
+
method=method,
|
|
81
|
+
path=path,
|
|
82
|
+
status=status,
|
|
72
83
|
latency_ms=latency,
|
|
73
|
-
route=
|
|
84
|
+
route=route,
|
|
74
85
|
path_params=scope.get("path_params"),
|
|
75
86
|
query_string=scope.get("query_string").decode(),
|
|
76
87
|
proto=scope.get("http_version"),
|
|
@@ -123,16 +123,18 @@ def _sanitise(o: Any) -> Any:
|
|
|
123
123
|
|
|
124
124
|
def json_dumpb(obj) -> bytes:
|
|
125
125
|
try:
|
|
126
|
-
|
|
127
|
-
rb"\u0000", b""
|
|
128
|
-
) # null unicode char not allowed in json
|
|
126
|
+
dumped = orjson.dumps(obj, default=default, option=_option)
|
|
129
127
|
except TypeError as e:
|
|
130
128
|
if "surrogates not allowed" not in str(e):
|
|
131
129
|
raise
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
130
|
+
dumped = orjson.dumps(_sanitise(obj), default=default, option=_option)
|
|
131
|
+
return (
|
|
132
|
+
# Unfortunately simply doing ``.replace(rb"\\u0000", b"")`` on
|
|
133
|
+
# the dumped bytes can leave an **orphaned back-slash** (e.g. ``\\q``)
|
|
134
|
+
# which makes the resulting JSON invalid. The fix is to delete the *double*
|
|
135
|
+
# back-slash form **first**, then (optionally) the single-escapes.
|
|
136
|
+
dumped.replace(rb"\\u0000", b"").replace(rb"\u0000", b"")
|
|
137
|
+
)
|
|
136
138
|
|
|
137
139
|
|
|
138
140
|
def json_loads(content: bytes | Fragment | dict) -> Any:
|
|
@@ -154,6 +156,10 @@ class Serializer(JsonPlusSerializer):
|
|
|
154
156
|
except TypeError:
|
|
155
157
|
return "pickle", cloudpickle.dumps(obj)
|
|
156
158
|
|
|
159
|
+
def dumps(self, obj: Any) -> bytes:
|
|
160
|
+
# See comment above (in json_dumpb)
|
|
161
|
+
return super().dumps(obj).replace(rb"\\u0000", b"").replace(rb"\u0000", b"")
|
|
162
|
+
|
|
157
163
|
def loads_typed(self, data: tuple[str, bytes]) -> Any:
|
|
158
164
|
if data[0] == "pickle":
|
|
159
165
|
try:
|
|
@@ -2,13 +2,20 @@ from datetime import UTC, datetime
|
|
|
2
2
|
|
|
3
3
|
import structlog
|
|
4
4
|
|
|
5
|
-
from langgraph_api.
|
|
5
|
+
from langgraph_api.config import HTTP_CONFIG
|
|
6
|
+
from langgraph_api.http import get_http_client, get_loopback_client, http_request
|
|
6
7
|
from langgraph_api.worker import WorkerResult
|
|
7
8
|
|
|
8
9
|
logger = structlog.stdlib.get_logger(__name__)
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
async def call_webhook(result: "WorkerResult") -> None:
|
|
13
|
+
if HTTP_CONFIG and HTTP_CONFIG.get("disable_webhooks"):
|
|
14
|
+
logger.info(
|
|
15
|
+
"Webhooks disabled, skipping webhook call", webhook=result["webhook"]
|
|
16
|
+
)
|
|
17
|
+
return
|
|
18
|
+
|
|
12
19
|
checkpoint = result["checkpoint"]
|
|
13
20
|
payload = {
|
|
14
21
|
**result["run"],
|
|
@@ -28,7 +35,7 @@ async def call_webhook(result: "WorkerResult") -> None:
|
|
|
28
35
|
webhook_client = get_loopback_client()
|
|
29
36
|
else:
|
|
30
37
|
webhook_client = get_http_client()
|
|
31
|
-
await
|
|
38
|
+
await http_request("POST", webhook, json=payload, client=webhook_client)
|
|
32
39
|
await logger.ainfo(
|
|
33
40
|
"Background worker called webhook",
|
|
34
41
|
webhook=result["webhook"],
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.77"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|