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.
@@ -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
+ ]