langgraph-sdk 0.2.6__tar.gz → 0.2.8__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.
- {langgraph_sdk-0.2.6 → langgraph_sdk-0.2.8}/PKG-INFO +1 -1
- {langgraph_sdk-0.2.6 → langgraph_sdk-0.2.8}/langgraph_sdk/__init__.py +1 -1
- {langgraph_sdk-0.2.6 → langgraph_sdk-0.2.8}/langgraph_sdk/client.py +218 -71
- {langgraph_sdk-0.2.6 → langgraph_sdk-0.2.8}/langgraph_sdk/sse.py +6 -0
- langgraph_sdk-0.2.8/tests/fixtures/response.txt +239 -0
- langgraph_sdk-0.2.8/tests/test_client_stream.py +248 -0
- langgraph_sdk-0.2.8/uv.lock +525 -0
- langgraph_sdk-0.2.6/uv.lock +0 -525
- {langgraph_sdk-0.2.6 → langgraph_sdk-0.2.8}/.gitignore +0 -0
- {langgraph_sdk-0.2.6 → langgraph_sdk-0.2.8}/LICENSE +0 -0
- {langgraph_sdk-0.2.6 → langgraph_sdk-0.2.8}/Makefile +0 -0
- {langgraph_sdk-0.2.6 → langgraph_sdk-0.2.8}/README.md +0 -0
- {langgraph_sdk-0.2.6 → langgraph_sdk-0.2.8}/langgraph_sdk/auth/__init__.py +0 -0
- {langgraph_sdk-0.2.6 → langgraph_sdk-0.2.8}/langgraph_sdk/auth/exceptions.py +0 -0
- {langgraph_sdk-0.2.6 → langgraph_sdk-0.2.8}/langgraph_sdk/auth/types.py +0 -0
- {langgraph_sdk-0.2.6 → langgraph_sdk-0.2.8}/langgraph_sdk/py.typed +0 -0
- {langgraph_sdk-0.2.6 → langgraph_sdk-0.2.8}/langgraph_sdk/schema.py +0 -0
- {langgraph_sdk-0.2.6 → langgraph_sdk-0.2.8}/pyproject.toml +0 -0
- {langgraph_sdk-0.2.6 → langgraph_sdk-0.2.8}/tests/test_api_parity.py +0 -0
- {langgraph_sdk-0.2.6 → langgraph_sdk-0.2.8}/tests/test_select_fields_sync.py +0 -0
|
@@ -157,27 +157,38 @@ def get_client(
|
|
|
157
157
|
headers: Mapping[str, str] | None = None,
|
|
158
158
|
timeout: TimeoutTypes | None = None,
|
|
159
159
|
) -> LangGraphClient:
|
|
160
|
-
"""
|
|
160
|
+
"""Create and configure a LangGraphClient.
|
|
161
|
+
|
|
162
|
+
The client provides programmatic access to a LangGraph Platform deployment. It supports
|
|
163
|
+
both remote servers and local in-process connections (when running inside a LangGraph server).
|
|
161
164
|
|
|
162
165
|
Args:
|
|
163
|
-
url:
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
166
|
+
url:
|
|
167
|
+
Base URL of the LangGraph API.
|
|
168
|
+
– If `None`, the client first attempts an in-process connection via ASGI transport.
|
|
169
|
+
If that fails, it falls back to `http://localhost:8123`.
|
|
170
|
+
api_key:
|
|
171
|
+
API key for authentication. If omitted, the client reads from environment
|
|
172
|
+
variables in the following order:
|
|
173
|
+
1. Function argument
|
|
174
|
+
2. `LANGGRAPH_API_KEY`
|
|
175
|
+
3. `LANGSMITH_API_KEY`
|
|
176
|
+
4. `LANGCHAIN_API_KEY`
|
|
177
|
+
headers:
|
|
178
|
+
Additional HTTP headers to include in requests. Merged with authentication headers.
|
|
179
|
+
timeout:
|
|
180
|
+
HTTP timeout configuration. May be:
|
|
181
|
+
– `httpx.Timeout` instance
|
|
182
|
+
– float (total seconds)
|
|
183
|
+
– tuple `(connect, read, write, pool)` in seconds
|
|
184
|
+
Defaults: connect=5, read=300, write=300, pool=5.
|
|
175
185
|
|
|
176
186
|
Returns:
|
|
177
|
-
LangGraphClient:
|
|
178
|
-
|
|
187
|
+
LangGraphClient:
|
|
188
|
+
A top-level client exposing sub-clients for assistants, threads,
|
|
189
|
+
runs, and cron operations.
|
|
179
190
|
|
|
180
|
-
???+ example "
|
|
191
|
+
???+ example "Connect to a remote server:"
|
|
181
192
|
|
|
182
193
|
```python
|
|
183
194
|
from langgraph_sdk import get_client
|
|
@@ -188,6 +199,21 @@ def get_client(
|
|
|
188
199
|
# example usage: client.<model>.<method_name>()
|
|
189
200
|
assistants = await client.assistants.get(assistant_id="some_uuid")
|
|
190
201
|
```
|
|
202
|
+
|
|
203
|
+
???+ example "Connect in-process to a running LangGraph server:"
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
from langgraph_sdk import get_client
|
|
207
|
+
|
|
208
|
+
client = get_client(url=None)
|
|
209
|
+
|
|
210
|
+
async def my_node(...):
|
|
211
|
+
subagent_result = await client.runs.wait(
|
|
212
|
+
thread_id=None,
|
|
213
|
+
assistant_id="agent",
|
|
214
|
+
input={"messages": [{"role": "user", "content": "Foo"}]},
|
|
215
|
+
)
|
|
216
|
+
```
|
|
191
217
|
"""
|
|
192
218
|
|
|
193
219
|
transport: httpx.AsyncBaseTransport | None = None
|
|
@@ -429,34 +455,94 @@ class HttpClient:
|
|
|
429
455
|
if headers:
|
|
430
456
|
request_headers.update(headers)
|
|
431
457
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
if
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
458
|
+
reconnect_headers = {
|
|
459
|
+
key: value
|
|
460
|
+
for key, value in request_headers.items()
|
|
461
|
+
if key.lower() not in {"content-length", "content-type"}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
last_event_id: str | None = None
|
|
465
|
+
reconnect_path: str | None = None
|
|
466
|
+
reconnect_attempts = 0
|
|
467
|
+
max_reconnect_attempts = 5
|
|
468
|
+
|
|
469
|
+
while True:
|
|
470
|
+
current_headers = dict(
|
|
471
|
+
request_headers if reconnect_path is None else reconnect_headers
|
|
472
|
+
)
|
|
473
|
+
if last_event_id is not None:
|
|
474
|
+
current_headers["Last-Event-ID"] = last_event_id
|
|
475
|
+
|
|
476
|
+
current_method = method if reconnect_path is None else "GET"
|
|
477
|
+
current_content = content if reconnect_path is None else None
|
|
478
|
+
current_params = params if reconnect_path is None else None
|
|
479
|
+
|
|
480
|
+
retry = False
|
|
481
|
+
async with self.client.stream(
|
|
482
|
+
current_method,
|
|
483
|
+
reconnect_path or path,
|
|
484
|
+
headers=current_headers,
|
|
485
|
+
content=current_content,
|
|
486
|
+
params=current_params,
|
|
487
|
+
) as res:
|
|
488
|
+
if reconnect_path is None and on_response:
|
|
489
|
+
on_response(res)
|
|
490
|
+
# check status
|
|
491
|
+
try:
|
|
492
|
+
res.raise_for_status()
|
|
493
|
+
except httpx.HTTPStatusError as e:
|
|
494
|
+
body = (await res.aread()).decode()
|
|
495
|
+
if sys.version_info >= (3, 11):
|
|
496
|
+
e.add_note(body)
|
|
497
|
+
else:
|
|
498
|
+
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
|
|
499
|
+
raise e
|
|
500
|
+
# check content type
|
|
501
|
+
content_type = res.headers.get("content-type", "").partition(";")[0]
|
|
502
|
+
if "text/event-stream" not in content_type:
|
|
503
|
+
raise httpx.TransportError(
|
|
504
|
+
"Expected response header Content-Type to contain 'text/event-stream', "
|
|
505
|
+
f"got {content_type!r}"
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
reconnect_location = res.headers.get("location")
|
|
509
|
+
if reconnect_location:
|
|
510
|
+
reconnect_path = reconnect_location
|
|
511
|
+
|
|
512
|
+
# parse SSE
|
|
513
|
+
decoder = SSEDecoder()
|
|
514
|
+
try:
|
|
515
|
+
async for line in aiter_lines_raw(res):
|
|
516
|
+
sse = decoder.decode(line=line.rstrip(b"\n"))
|
|
517
|
+
if sse is not None:
|
|
518
|
+
if decoder.last_event_id is not None:
|
|
519
|
+
last_event_id = decoder.last_event_id
|
|
520
|
+
if sse.event or sse.data is not None:
|
|
521
|
+
yield sse
|
|
522
|
+
except httpx.HTTPError:
|
|
523
|
+
# httpx.TransportError inherits from HTTPError, so transient
|
|
524
|
+
# disconnects during streaming land here.
|
|
525
|
+
if reconnect_path is None:
|
|
526
|
+
raise
|
|
527
|
+
retry = True
|
|
444
528
|
else:
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
529
|
+
if sse := decoder.decode(b""):
|
|
530
|
+
if decoder.last_event_id is not None:
|
|
531
|
+
last_event_id = decoder.last_event_id
|
|
532
|
+
if sse.event or sse.data is not None:
|
|
533
|
+
# decoder.decode(b"") flushes the in-flight event and may
|
|
534
|
+
# return an empty placeholder when there is no pending
|
|
535
|
+
# message. Skip these no-op events so the stream doesn't
|
|
536
|
+
# emit a trailing blank item after reconnects.
|
|
537
|
+
yield sse
|
|
538
|
+
if retry:
|
|
539
|
+
reconnect_attempts += 1
|
|
540
|
+
if reconnect_attempts > max_reconnect_attempts:
|
|
541
|
+
raise httpx.TransportError(
|
|
542
|
+
"Exceeded maximum SSE reconnection attempts"
|
|
543
|
+
)
|
|
544
|
+
continue
|
|
545
|
+
break
|
|
460
546
|
|
|
461
547
|
|
|
462
548
|
async def _aencode_json(json: Any) -> tuple[dict[str, str], bytes | None]:
|
|
@@ -3614,39 +3700,100 @@ class SyncHttpClient:
|
|
|
3614
3700
|
on_response: Callable[[httpx.Response], None] | None = None,
|
|
3615
3701
|
) -> Iterator[StreamPart]:
|
|
3616
3702
|
"""Stream the results of a request using SSE."""
|
|
3617
|
-
|
|
3703
|
+
if json is not None:
|
|
3704
|
+
request_headers, content = _encode_json(json)
|
|
3705
|
+
else:
|
|
3706
|
+
request_headers, content = {}, None
|
|
3618
3707
|
request_headers["Accept"] = "text/event-stream"
|
|
3619
3708
|
request_headers["Cache-Control"] = "no-store"
|
|
3620
3709
|
if headers:
|
|
3621
3710
|
request_headers.update(headers)
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3711
|
+
|
|
3712
|
+
reconnect_headers = {
|
|
3713
|
+
key: value
|
|
3714
|
+
for key, value in request_headers.items()
|
|
3715
|
+
if key.lower() not in {"content-length", "content-type"}
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
last_event_id: str | None = None
|
|
3719
|
+
reconnect_path: str | None = None
|
|
3720
|
+
reconnect_attempts = 0
|
|
3721
|
+
max_reconnect_attempts = 5
|
|
3722
|
+
|
|
3723
|
+
while True:
|
|
3724
|
+
current_headers = dict(
|
|
3725
|
+
request_headers if reconnect_path is None else reconnect_headers
|
|
3726
|
+
)
|
|
3727
|
+
if last_event_id is not None:
|
|
3728
|
+
current_headers["Last-Event-ID"] = last_event_id
|
|
3729
|
+
|
|
3730
|
+
current_method = method if reconnect_path is None else "GET"
|
|
3731
|
+
current_content = content if reconnect_path is None else None
|
|
3732
|
+
current_params = params if reconnect_path is None else None
|
|
3733
|
+
|
|
3734
|
+
retry = False
|
|
3735
|
+
with self.client.stream(
|
|
3736
|
+
current_method,
|
|
3737
|
+
reconnect_path or path,
|
|
3738
|
+
headers=current_headers,
|
|
3739
|
+
content=current_content,
|
|
3740
|
+
params=current_params,
|
|
3741
|
+
) as res:
|
|
3742
|
+
if reconnect_path is None and on_response:
|
|
3743
|
+
on_response(res)
|
|
3744
|
+
# check status
|
|
3745
|
+
try:
|
|
3746
|
+
res.raise_for_status()
|
|
3747
|
+
except httpx.HTTPStatusError as e:
|
|
3748
|
+
body = (res.read()).decode()
|
|
3749
|
+
if sys.version_info >= (3, 11):
|
|
3750
|
+
e.add_note(body)
|
|
3751
|
+
else:
|
|
3752
|
+
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
|
|
3753
|
+
raise e
|
|
3754
|
+
# check content type
|
|
3755
|
+
content_type = res.headers.get("content-type", "").partition(";")[0]
|
|
3756
|
+
if "text/event-stream" not in content_type:
|
|
3757
|
+
raise httpx.TransportError(
|
|
3758
|
+
"Expected response header Content-Type to contain 'text/event-stream', "
|
|
3759
|
+
f"got {content_type!r}"
|
|
3760
|
+
)
|
|
3761
|
+
|
|
3762
|
+
reconnect_location = res.headers.get("location")
|
|
3763
|
+
if reconnect_location:
|
|
3764
|
+
reconnect_path = reconnect_location
|
|
3765
|
+
|
|
3766
|
+
decoder = SSEDecoder()
|
|
3767
|
+
try:
|
|
3768
|
+
for line in iter_lines_raw(res):
|
|
3769
|
+
sse = decoder.decode(line.rstrip(b"\n"))
|
|
3770
|
+
if sse is not None:
|
|
3771
|
+
if decoder.last_event_id is not None:
|
|
3772
|
+
last_event_id = decoder.last_event_id
|
|
3773
|
+
if sse.event or sse.data is not None:
|
|
3774
|
+
yield sse
|
|
3775
|
+
except httpx.HTTPError:
|
|
3776
|
+
# httpx.TransportError inherits from HTTPError, so transient
|
|
3777
|
+
# disconnects during streaming land here.
|
|
3778
|
+
if reconnect_path is None:
|
|
3779
|
+
raise
|
|
3780
|
+
retry = True
|
|
3634
3781
|
else:
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3782
|
+
if sse := decoder.decode(b""):
|
|
3783
|
+
if decoder.last_event_id is not None:
|
|
3784
|
+
last_event_id = decoder.last_event_id
|
|
3785
|
+
if sse.event or sse.data is not None:
|
|
3786
|
+
# See async stream implementation for rationale on
|
|
3787
|
+
# skipping empty flush events.
|
|
3788
|
+
yield sse
|
|
3789
|
+
if retry:
|
|
3790
|
+
reconnect_attempts += 1
|
|
3791
|
+
if reconnect_attempts > max_reconnect_attempts:
|
|
3792
|
+
raise httpx.TransportError(
|
|
3793
|
+
"Exceeded maximum SSE reconnection attempts"
|
|
3794
|
+
)
|
|
3795
|
+
continue
|
|
3796
|
+
break
|
|
3650
3797
|
|
|
3651
3798
|
|
|
3652
3799
|
def _encode_json(json: Any) -> tuple[dict[str, str], bytes]:
|
|
@@ -81,6 +81,12 @@ class SSEDecoder:
|
|
|
81
81
|
self._last_event_id = ""
|
|
82
82
|
self._retry: int | None = None
|
|
83
83
|
|
|
84
|
+
@property
|
|
85
|
+
def last_event_id(self) -> str | None:
|
|
86
|
+
"""Return the last event identifier that was seen."""
|
|
87
|
+
|
|
88
|
+
return self._last_event_id or None
|
|
89
|
+
|
|
84
90
|
def decode(self, line: bytes) -> StreamPart | None:
|
|
85
91
|
# See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation # noqa: E501
|
|
86
92
|
|