azure-functions-langgraph 0.5.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.
- azure_functions_langgraph/__init__.py +130 -0
- azure_functions_langgraph/_handlers.py +267 -0
- azure_functions_langgraph/_validation.py +169 -0
- azure_functions_langgraph/app.py +386 -0
- azure_functions_langgraph/checkpointers/__init__.py +23 -0
- azure_functions_langgraph/checkpointers/azure_blob.py +633 -0
- azure_functions_langgraph/contracts.py +117 -0
- azure_functions_langgraph/openapi.py +117 -0
- azure_functions_langgraph/platform/__init__.py +20 -0
- azure_functions_langgraph/platform/_assistants.py +112 -0
- azure_functions_langgraph/platform/_common.py +210 -0
- azure_functions_langgraph/platform/_runs.py +601 -0
- azure_functions_langgraph/platform/_sse.py +79 -0
- azure_functions_langgraph/platform/_threads.py +520 -0
- azure_functions_langgraph/platform/contracts.py +308 -0
- azure_functions_langgraph/platform/routes.py +74 -0
- azure_functions_langgraph/platform/stores.py +282 -0
- azure_functions_langgraph/protocols.py +72 -0
- azure_functions_langgraph/py.typed +1 -0
- azure_functions_langgraph/stores/__init__.py +19 -0
- azure_functions_langgraph/stores/azure_table.py +328 -0
- azure_functions_langgraph-0.5.0.dist-info/METADATA +419 -0
- azure_functions_langgraph-0.5.0.dist-info/RECORD +25 -0
- azure_functions_langgraph-0.5.0.dist-info/WHEEL +4 -0
- azure_functions_langgraph-0.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Azure Functions LangGraph — Deploy LangGraph agents as Azure Functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
__version__ = "0.5.0"
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from azure_functions_langgraph.app import LangGraphApp
|
|
11
|
+
from azure_functions_langgraph.contracts import (
|
|
12
|
+
AppMetadata,
|
|
13
|
+
ErrorResponse,
|
|
14
|
+
GraphInfo,
|
|
15
|
+
HealthResponse,
|
|
16
|
+
InvokeRequest,
|
|
17
|
+
InvokeResponse,
|
|
18
|
+
RegisteredGraphMetadata,
|
|
19
|
+
RouteMetadata,
|
|
20
|
+
StateResponse,
|
|
21
|
+
StreamRequest,
|
|
22
|
+
)
|
|
23
|
+
from azure_functions_langgraph.protocols import (
|
|
24
|
+
CloneableGraph,
|
|
25
|
+
InvocableGraph,
|
|
26
|
+
LangGraphLike,
|
|
27
|
+
StatefulGraph,
|
|
28
|
+
StreamableGraph,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def __getattr__(name: str) -> object:
|
|
33
|
+
if name == "LangGraphApp":
|
|
34
|
+
try:
|
|
35
|
+
from azure_functions_langgraph.app import LangGraphApp
|
|
36
|
+
|
|
37
|
+
return LangGraphApp
|
|
38
|
+
except ImportError as exc:
|
|
39
|
+
raise ImportError(
|
|
40
|
+
"LangGraphApp requires 'azure-functions' and 'langgraph'. "
|
|
41
|
+
"Install them with: pip install azure-functions-langgraph"
|
|
42
|
+
) from exc
|
|
43
|
+
# Contracts
|
|
44
|
+
if name == "InvokeRequest":
|
|
45
|
+
from azure_functions_langgraph.contracts import InvokeRequest
|
|
46
|
+
|
|
47
|
+
return InvokeRequest
|
|
48
|
+
if name == "InvokeResponse":
|
|
49
|
+
from azure_functions_langgraph.contracts import InvokeResponse
|
|
50
|
+
|
|
51
|
+
return InvokeResponse
|
|
52
|
+
if name == "StreamRequest":
|
|
53
|
+
from azure_functions_langgraph.contracts import StreamRequest
|
|
54
|
+
|
|
55
|
+
return StreamRequest
|
|
56
|
+
if name == "HealthResponse":
|
|
57
|
+
from azure_functions_langgraph.contracts import HealthResponse
|
|
58
|
+
|
|
59
|
+
return HealthResponse
|
|
60
|
+
if name == "GraphInfo":
|
|
61
|
+
from azure_functions_langgraph.contracts import GraphInfo
|
|
62
|
+
|
|
63
|
+
return GraphInfo
|
|
64
|
+
if name == "ErrorResponse":
|
|
65
|
+
from azure_functions_langgraph.contracts import ErrorResponse
|
|
66
|
+
|
|
67
|
+
return ErrorResponse
|
|
68
|
+
if name == "StateResponse":
|
|
69
|
+
from azure_functions_langgraph.contracts import StateResponse
|
|
70
|
+
|
|
71
|
+
return StateResponse
|
|
72
|
+
# Metadata dataclasses
|
|
73
|
+
if name == "AppMetadata":
|
|
74
|
+
from azure_functions_langgraph.contracts import AppMetadata
|
|
75
|
+
|
|
76
|
+
return AppMetadata
|
|
77
|
+
if name == "RegisteredGraphMetadata":
|
|
78
|
+
from azure_functions_langgraph.contracts import RegisteredGraphMetadata
|
|
79
|
+
|
|
80
|
+
return RegisteredGraphMetadata
|
|
81
|
+
if name == "RouteMetadata":
|
|
82
|
+
from azure_functions_langgraph.contracts import RouteMetadata
|
|
83
|
+
|
|
84
|
+
return RouteMetadata
|
|
85
|
+
# Protocols
|
|
86
|
+
if name == "InvocableGraph":
|
|
87
|
+
from azure_functions_langgraph.protocols import InvocableGraph
|
|
88
|
+
|
|
89
|
+
return InvocableGraph
|
|
90
|
+
if name == "StreamableGraph":
|
|
91
|
+
from azure_functions_langgraph.protocols import StreamableGraph
|
|
92
|
+
|
|
93
|
+
return StreamableGraph
|
|
94
|
+
if name == "LangGraphLike":
|
|
95
|
+
from azure_functions_langgraph.protocols import LangGraphLike
|
|
96
|
+
|
|
97
|
+
return LangGraphLike
|
|
98
|
+
if name == "StatefulGraph":
|
|
99
|
+
from azure_functions_langgraph.protocols import StatefulGraph
|
|
100
|
+
|
|
101
|
+
return StatefulGraph
|
|
102
|
+
if name == "CloneableGraph":
|
|
103
|
+
from azure_functions_langgraph.protocols import CloneableGraph
|
|
104
|
+
|
|
105
|
+
return CloneableGraph
|
|
106
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
__all__ = [
|
|
110
|
+
"LangGraphApp",
|
|
111
|
+
"__version__",
|
|
112
|
+
# Contracts
|
|
113
|
+
"InvokeRequest",
|
|
114
|
+
"InvokeResponse",
|
|
115
|
+
"StreamRequest",
|
|
116
|
+
"HealthResponse",
|
|
117
|
+
"GraphInfo",
|
|
118
|
+
"ErrorResponse",
|
|
119
|
+
"StateResponse",
|
|
120
|
+
# Metadata
|
|
121
|
+
"AppMetadata",
|
|
122
|
+
"RegisteredGraphMetadata",
|
|
123
|
+
"RouteMetadata",
|
|
124
|
+
# Protocols
|
|
125
|
+
"InvocableGraph",
|
|
126
|
+
"StreamableGraph",
|
|
127
|
+
"LangGraphLike",
|
|
128
|
+
"StatefulGraph",
|
|
129
|
+
"CloneableGraph",
|
|
130
|
+
]
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Internal request handlers for native graph endpoints.
|
|
2
|
+
|
|
3
|
+
These are standalone functions extracted from ``LangGraphApp`` to keep
|
|
4
|
+
``app.py`` focused on registration and route wiring. Each function
|
|
5
|
+
receives only the explicit dependencies it needs — no reference to
|
|
6
|
+
``LangGraphApp`` itself.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import azure.functions as func
|
|
16
|
+
|
|
17
|
+
from azure_functions_langgraph._validation import (
|
|
18
|
+
validate_body_size,
|
|
19
|
+
validate_input_structure,
|
|
20
|
+
validate_thread_id,
|
|
21
|
+
)
|
|
22
|
+
from azure_functions_langgraph.contracts import (
|
|
23
|
+
ErrorResponse,
|
|
24
|
+
InvokeRequest,
|
|
25
|
+
InvokeResponse,
|
|
26
|
+
StateResponse,
|
|
27
|
+
StreamRequest,
|
|
28
|
+
)
|
|
29
|
+
from azure_functions_langgraph.protocols import StatefulGraph, StreamableGraph
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ------------------------------------------------------------------
|
|
35
|
+
# Shared helper
|
|
36
|
+
# ------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _error_response(status_code: int, detail: str) -> func.HttpResponse:
|
|
40
|
+
body = ErrorResponse(error="error", detail=detail)
|
|
41
|
+
return func.HttpResponse(
|
|
42
|
+
body=body.model_dump_json(),
|
|
43
|
+
mimetype="application/json",
|
|
44
|
+
status_code=status_code,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
# Invoke handler
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def handle_invoke(
|
|
54
|
+
req: func.HttpRequest,
|
|
55
|
+
reg: Any,
|
|
56
|
+
*,
|
|
57
|
+
max_request_body_bytes: int,
|
|
58
|
+
max_input_depth: int,
|
|
59
|
+
max_input_nodes: int,
|
|
60
|
+
) -> func.HttpResponse:
|
|
61
|
+
"""Handle a synchronous invoke request."""
|
|
62
|
+
# Body size check — reject before parsing
|
|
63
|
+
raw_body = req.get_body()
|
|
64
|
+
size_err = validate_body_size(raw_body, max_request_body_bytes)
|
|
65
|
+
if size_err:
|
|
66
|
+
return _error_response(400, size_err)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
body = req.get_json()
|
|
70
|
+
except ValueError:
|
|
71
|
+
return _error_response(400, "Invalid JSON body")
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
request = InvokeRequest.model_validate(body)
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
return _error_response(422, f"Validation error: {exc}")
|
|
77
|
+
|
|
78
|
+
# Structural validation on user-supplied fields
|
|
79
|
+
structure_err = validate_input_structure(
|
|
80
|
+
request.input, max_depth=max_input_depth, max_nodes=max_input_nodes,
|
|
81
|
+
)
|
|
82
|
+
if structure_err:
|
|
83
|
+
return _error_response(400, structure_err)
|
|
84
|
+
if request.config:
|
|
85
|
+
config_err = validate_input_structure(
|
|
86
|
+
request.config, max_depth=max_input_depth, max_nodes=max_input_nodes,
|
|
87
|
+
)
|
|
88
|
+
if config_err:
|
|
89
|
+
return _error_response(400, config_err)
|
|
90
|
+
|
|
91
|
+
config = request.config or {}
|
|
92
|
+
try:
|
|
93
|
+
result = reg.graph.invoke(request.input, config=config)
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
logger.exception("Graph %s invoke failed", reg.name)
|
|
96
|
+
_ = exc
|
|
97
|
+
return _error_response(500, "Graph execution failed")
|
|
98
|
+
|
|
99
|
+
output = result if isinstance(result, dict) else {"result": result}
|
|
100
|
+
response = InvokeResponse(output=output)
|
|
101
|
+
return func.HttpResponse(
|
|
102
|
+
body=response.model_dump_json(),
|
|
103
|
+
mimetype="application/json",
|
|
104
|
+
status_code=200,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
# Stream handler
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def handle_stream(
|
|
114
|
+
req: func.HttpRequest,
|
|
115
|
+
reg: Any,
|
|
116
|
+
*,
|
|
117
|
+
max_stream_response_bytes: int,
|
|
118
|
+
max_request_body_bytes: int,
|
|
119
|
+
max_input_depth: int,
|
|
120
|
+
max_input_nodes: int,
|
|
121
|
+
) -> func.HttpResponse:
|
|
122
|
+
"""Handle a streaming request.
|
|
123
|
+
|
|
124
|
+
Returns a **buffered** SSE-formatted response. All stream chunks are
|
|
125
|
+
collected first, then returned in a single HTTP response. This is a
|
|
126
|
+
known v0.1 limitation — true chunked streaming will follow once Azure
|
|
127
|
+
Functions Python HTTP streaming is fully stable.
|
|
128
|
+
"""
|
|
129
|
+
if not reg.stream_enabled:
|
|
130
|
+
return _error_response(501, f"Graph {reg.name!r} is configured as invoke-only")
|
|
131
|
+
|
|
132
|
+
if not isinstance(reg.graph, StreamableGraph):
|
|
133
|
+
return _error_response(501, f"Graph {reg.name!r} does not support streaming")
|
|
134
|
+
|
|
135
|
+
# Body size check — reject before parsing
|
|
136
|
+
raw_body = req.get_body()
|
|
137
|
+
size_err = validate_body_size(raw_body, max_request_body_bytes)
|
|
138
|
+
if size_err:
|
|
139
|
+
return _error_response(400, size_err)
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
body = req.get_json()
|
|
143
|
+
except ValueError:
|
|
144
|
+
return _error_response(400, "Invalid JSON body")
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
request = StreamRequest.model_validate(body)
|
|
148
|
+
except Exception as exc:
|
|
149
|
+
return _error_response(422, f"Validation error: {exc}")
|
|
150
|
+
|
|
151
|
+
# Structural validation on user-supplied fields
|
|
152
|
+
structure_err = validate_input_structure(
|
|
153
|
+
request.input, max_depth=max_input_depth, max_nodes=max_input_nodes,
|
|
154
|
+
)
|
|
155
|
+
if structure_err:
|
|
156
|
+
return _error_response(400, structure_err)
|
|
157
|
+
if request.config:
|
|
158
|
+
config_err = validate_input_structure(
|
|
159
|
+
request.config, max_depth=max_input_depth, max_nodes=max_input_nodes,
|
|
160
|
+
)
|
|
161
|
+
if config_err:
|
|
162
|
+
return _error_response(400, config_err)
|
|
163
|
+
|
|
164
|
+
config = request.config or {}
|
|
165
|
+
chunks: list[str] = []
|
|
166
|
+
buffered_bytes = 0
|
|
167
|
+
|
|
168
|
+
def _append_chunk(chunk: str) -> bool:
|
|
169
|
+
nonlocal buffered_bytes
|
|
170
|
+
chunk_bytes = len(chunk.encode())
|
|
171
|
+
if buffered_bytes + chunk_bytes > max_stream_response_bytes:
|
|
172
|
+
error_payload = json.dumps(
|
|
173
|
+
{
|
|
174
|
+
"error": (
|
|
175
|
+
"stream response exceeded max buffered size "
|
|
176
|
+
f"({max_stream_response_bytes} bytes)"
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
chunks.append(f"event: error\ndata: {error_payload}\n\n")
|
|
181
|
+
return False
|
|
182
|
+
chunks.append(chunk)
|
|
183
|
+
buffered_bytes += chunk_bytes
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
for event in reg.graph.stream(
|
|
188
|
+
request.input,
|
|
189
|
+
config=config,
|
|
190
|
+
stream_mode=request.stream_mode,
|
|
191
|
+
):
|
|
192
|
+
serialized = json.dumps(
|
|
193
|
+
event if isinstance(event, dict) else {"data": str(event)},
|
|
194
|
+
default=str,
|
|
195
|
+
)
|
|
196
|
+
if not _append_chunk(f"event: data\ndata: {serialized}\n\n"):
|
|
197
|
+
break
|
|
198
|
+
except Exception as exc:
|
|
199
|
+
logger.exception("Graph %s stream failed", reg.name)
|
|
200
|
+
_ = exc
|
|
201
|
+
error_payload = json.dumps({"error": "stream processing failed"})
|
|
202
|
+
_append_chunk(f"event: error\ndata: {error_payload}\n\n")
|
|
203
|
+
|
|
204
|
+
_append_chunk("event: end\ndata: {}\n\n")
|
|
205
|
+
|
|
206
|
+
return func.HttpResponse(
|
|
207
|
+
body="".join(chunks),
|
|
208
|
+
mimetype="text/event-stream",
|
|
209
|
+
status_code=200,
|
|
210
|
+
headers={
|
|
211
|
+
"Cache-Control": "no-cache",
|
|
212
|
+
"X-Accel-Buffering": "no",
|
|
213
|
+
},
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
# State handler
|
|
219
|
+
# ------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def handle_state(
|
|
223
|
+
req: func.HttpRequest,
|
|
224
|
+
reg: Any,
|
|
225
|
+
) -> func.HttpResponse:
|
|
226
|
+
"""Handle a GET request for thread state."""
|
|
227
|
+
if not isinstance(reg.graph, StatefulGraph):
|
|
228
|
+
return _error_response(
|
|
229
|
+
409, f"Graph {reg.name!r} does not support state retrieval"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
thread_id = req.route_params.get("thread_id")
|
|
233
|
+
if not thread_id:
|
|
234
|
+
return _error_response(400, "Missing thread_id in URL path")
|
|
235
|
+
tid_err = validate_thread_id(thread_id)
|
|
236
|
+
if tid_err:
|
|
237
|
+
return _error_response(400, tid_err)
|
|
238
|
+
|
|
239
|
+
config: dict[str, Any] = {"configurable": {"thread_id": thread_id}}
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
snapshot = reg.graph.get_state(config)
|
|
243
|
+
except (KeyError, ValueError):
|
|
244
|
+
logger.warning(
|
|
245
|
+
"Graph %s: thread %s not found", reg.name, thread_id
|
|
246
|
+
)
|
|
247
|
+
return _error_response(404, f"Thread {thread_id!r} not found")
|
|
248
|
+
except Exception:
|
|
249
|
+
logger.exception(
|
|
250
|
+
"Graph %s get_state failed for thread %s", reg.name, thread_id
|
|
251
|
+
)
|
|
252
|
+
return _error_response(
|
|
253
|
+
500, "Internal error while retrieving thread state"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
values = snapshot.values if isinstance(snapshot.values, dict) else {}
|
|
257
|
+
next_nodes: list[str] = list(snapshot.next) if hasattr(snapshot, "next") else []
|
|
258
|
+
metadata = (
|
|
259
|
+
dict(snapshot.metadata) if hasattr(snapshot, "metadata") and snapshot.metadata else None
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
response = StateResponse(values=values, next=next_nodes, metadata=metadata)
|
|
263
|
+
return func.HttpResponse(
|
|
264
|
+
body=json.dumps(response.model_dump(), default=str),
|
|
265
|
+
mimetype="application/json",
|
|
266
|
+
status_code=200,
|
|
267
|
+
)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Transport-agnostic input validation utilities.
|
|
2
|
+
|
|
3
|
+
All validators are pure functions that return an error message string on
|
|
4
|
+
failure or ``None`` on success. Route layers (native and platform) are
|
|
5
|
+
responsible for converting error messages into their own HTTP response
|
|
6
|
+
format (``ErrorResponse`` vs ``_platform_error``).
|
|
7
|
+
|
|
8
|
+
.. versionadded:: 0.3.0
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Graph-name / assistant-id validation
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
#: Pattern for valid graph names: starts with letter, then letters/digits/
|
|
21
|
+
#: underscores/hyphens, 1–64 characters total.
|
|
22
|
+
_GRAPH_NAME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]{0,63}$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_graph_name(name: str) -> Optional[str]:
|
|
26
|
+
"""Validate a graph registration name or assistant_id.
|
|
27
|
+
|
|
28
|
+
Returns an error message if invalid, ``None`` if valid.
|
|
29
|
+
"""
|
|
30
|
+
if not name:
|
|
31
|
+
return "Graph name must not be empty"
|
|
32
|
+
if not _GRAPH_NAME_RE.match(name):
|
|
33
|
+
return (
|
|
34
|
+
f"Invalid graph name {name!r}. "
|
|
35
|
+
"Must start with a letter, contain only letters, digits, "
|
|
36
|
+
"underscores or hyphens, and be 1–64 characters."
|
|
37
|
+
)
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Thread-id validation
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
#: Maximum allowed thread_id length.
|
|
46
|
+
_MAX_THREAD_ID_LENGTH = 256
|
|
47
|
+
|
|
48
|
+
#: Pattern for printable ASCII (space through tilde) — excludes control chars.
|
|
49
|
+
_PRINTABLE_RE = re.compile(r"^[\x20-\x7e]+$")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def validate_thread_id(thread_id: str) -> Optional[str]:
|
|
53
|
+
"""Validate a thread_id string (permissive — non-empty printable, max length).
|
|
54
|
+
|
|
55
|
+
Returns an error message if invalid, ``None`` if valid.
|
|
56
|
+
"""
|
|
57
|
+
if not thread_id:
|
|
58
|
+
return "Thread ID must not be empty"
|
|
59
|
+
if len(thread_id) > _MAX_THREAD_ID_LENGTH:
|
|
60
|
+
return (
|
|
61
|
+
f"Thread ID exceeds maximum length of {_MAX_THREAD_ID_LENGTH} characters"
|
|
62
|
+
)
|
|
63
|
+
if not _PRINTABLE_RE.match(thread_id):
|
|
64
|
+
return "Thread ID must contain only printable ASCII characters"
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Request body size validation
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def validate_body_size(body: bytes, max_bytes: int) -> Optional[str]:
|
|
74
|
+
"""Check that *body* does not exceed *max_bytes*.
|
|
75
|
+
|
|
76
|
+
Returns an error message if too large, ``None`` if within limit.
|
|
77
|
+
Must be called **before** JSON parsing to reject oversized payloads early.
|
|
78
|
+
"""
|
|
79
|
+
if len(body) > max_bytes:
|
|
80
|
+
return (
|
|
81
|
+
f"Request body too large: {len(body)} bytes "
|
|
82
|
+
f"(max {max_bytes} bytes)"
|
|
83
|
+
)
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Input / config depth and node-count validation
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _count_depth_and_nodes(
|
|
93
|
+
data: Any,
|
|
94
|
+
current_depth: int,
|
|
95
|
+
max_depth: int,
|
|
96
|
+
node_count: int,
|
|
97
|
+
max_nodes: int,
|
|
98
|
+
) -> tuple[int, Optional[str]]:
|
|
99
|
+
"""Recursively count depth and nodes.
|
|
100
|
+
|
|
101
|
+
Returns ``(updated_node_count, error_message_or_none)``.
|
|
102
|
+
"""
|
|
103
|
+
if current_depth > max_depth:
|
|
104
|
+
return node_count, (
|
|
105
|
+
f"Input exceeds maximum nesting depth of {max_depth}"
|
|
106
|
+
)
|
|
107
|
+
if node_count > max_nodes:
|
|
108
|
+
return node_count, (
|
|
109
|
+
f"Input exceeds maximum node count of {max_nodes}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if isinstance(data, dict):
|
|
113
|
+
for value in data.values():
|
|
114
|
+
node_count += 1
|
|
115
|
+
if node_count > max_nodes:
|
|
116
|
+
return node_count, (
|
|
117
|
+
f"Input exceeds maximum node count of {max_nodes}"
|
|
118
|
+
)
|
|
119
|
+
node_count, err = _count_depth_and_nodes(
|
|
120
|
+
value, current_depth + 1, max_depth, node_count, max_nodes
|
|
121
|
+
)
|
|
122
|
+
if err:
|
|
123
|
+
return node_count, err
|
|
124
|
+
elif isinstance(data, list):
|
|
125
|
+
for item in data:
|
|
126
|
+
node_count += 1
|
|
127
|
+
if node_count > max_nodes:
|
|
128
|
+
return node_count, (
|
|
129
|
+
f"Input exceeds maximum node count of {max_nodes}"
|
|
130
|
+
)
|
|
131
|
+
node_count, err = _count_depth_and_nodes(
|
|
132
|
+
item, current_depth + 1, max_depth, node_count, max_nodes
|
|
133
|
+
)
|
|
134
|
+
if err:
|
|
135
|
+
return node_count, err
|
|
136
|
+
|
|
137
|
+
return node_count, None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def validate_input_structure(
|
|
141
|
+
data: Any,
|
|
142
|
+
*,
|
|
143
|
+
max_depth: int = 32,
|
|
144
|
+
max_nodes: int = 10_000,
|
|
145
|
+
) -> Optional[str]:
|
|
146
|
+
"""Validate nesting depth and total node count of user-supplied data.
|
|
147
|
+
|
|
148
|
+
Intended for the ``input`` and ``config``/``configurable`` fields —
|
|
149
|
+
the only fields that can contain arbitrarily deep user data.
|
|
150
|
+
|
|
151
|
+
Returns an error message if invalid, ``None`` if valid.
|
|
152
|
+
"""
|
|
153
|
+
if not isinstance(data, (dict, list)):
|
|
154
|
+
# Scalars are always fine
|
|
155
|
+
return None
|
|
156
|
+
_, err = _count_depth_and_nodes(data, 1, max_depth, 0, max_nodes)
|
|
157
|
+
return err
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
# Public surface
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
__all__ = [
|
|
165
|
+
"validate_graph_name",
|
|
166
|
+
"validate_thread_id",
|
|
167
|
+
"validate_body_size",
|
|
168
|
+
"validate_input_structure",
|
|
169
|
+
]
|