langgraph-sdk 0.2.7__tar.gz → 0.2.9__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.7 → langgraph_sdk-0.2.9}/PKG-INFO +1 -1
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.9}/langgraph_sdk/__init__.py +1 -1
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.9}/langgraph_sdk/auth/__init__.py +1 -1
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.9}/langgraph_sdk/client.py +307 -64
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.9}/langgraph_sdk/sse.py +6 -0
- langgraph_sdk-0.2.9/tests/test_client_stream.py +248 -0
- langgraph_sdk-0.2.7/tests/test_client_stream.py +0 -81
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.9}/.gitignore +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.9}/LICENSE +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.9}/Makefile +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.9}/README.md +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.9}/langgraph_sdk/auth/exceptions.py +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.9}/langgraph_sdk/auth/types.py +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.9}/langgraph_sdk/py.typed +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.9}/langgraph_sdk/schema.py +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.9}/pyproject.toml +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.9}/tests/fixtures/response.txt +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.9}/tests/test_api_parity.py +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.9}/tests/test_select_fields_sync.py +0 -0
- {langgraph_sdk-0.2.7 → langgraph_sdk-0.2.9}/uv.lock +0 -0
|
@@ -437,7 +437,7 @@ class HttpClient:
|
|
|
437
437
|
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
|
|
438
438
|
raise e
|
|
439
439
|
|
|
440
|
-
async def
|
|
440
|
+
async def request_reconnect(
|
|
441
441
|
self,
|
|
442
442
|
path: str,
|
|
443
443
|
method: str,
|
|
@@ -446,45 +446,151 @@ class HttpClient:
|
|
|
446
446
|
params: QueryParamTypes | None = None,
|
|
447
447
|
headers: Mapping[str, str] | None = None,
|
|
448
448
|
on_response: Callable[[httpx.Response], None] | None = None,
|
|
449
|
-
|
|
450
|
-
|
|
449
|
+
reconnect_limit: int = 5,
|
|
450
|
+
) -> Any:
|
|
451
|
+
"""Send a request that automatically reconnects to Location header."""
|
|
451
452
|
request_headers, content = await _aencode_json(json)
|
|
452
|
-
request_headers["Accept"] = "text/event-stream"
|
|
453
|
-
request_headers["Cache-Control"] = "no-store"
|
|
454
|
-
# Add runtime headers with precedence
|
|
455
453
|
if headers:
|
|
456
454
|
request_headers.update(headers)
|
|
457
|
-
|
|
458
455
|
async with self.client.stream(
|
|
459
456
|
method, path, headers=request_headers, content=content, params=params
|
|
460
|
-
) as
|
|
457
|
+
) as r:
|
|
461
458
|
if on_response:
|
|
462
|
-
on_response(
|
|
463
|
-
# check status
|
|
459
|
+
on_response(r)
|
|
464
460
|
try:
|
|
465
|
-
|
|
461
|
+
r.raise_for_status()
|
|
466
462
|
except httpx.HTTPStatusError as e:
|
|
467
|
-
body = (await
|
|
463
|
+
body = (await r.aread()).decode()
|
|
468
464
|
if sys.version_info >= (3, 11):
|
|
469
465
|
e.add_note(body)
|
|
470
466
|
else:
|
|
471
467
|
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
|
|
472
468
|
raise e
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
469
|
+
loc = r.headers.get("location")
|
|
470
|
+
if reconnect_limit <= 0 or not loc:
|
|
471
|
+
return await _adecode_json(r)
|
|
472
|
+
try:
|
|
473
|
+
return await _adecode_json(r)
|
|
474
|
+
except httpx.HTTPError:
|
|
475
|
+
warnings.warn(
|
|
476
|
+
f"Request failed, attempting reconnect to Location: {loc}",
|
|
477
|
+
stacklevel=2,
|
|
478
|
+
)
|
|
479
|
+
await r.aclose()
|
|
480
|
+
return await self.request_reconnect(
|
|
481
|
+
loc,
|
|
482
|
+
"GET",
|
|
483
|
+
headers=request_headers,
|
|
484
|
+
# don't pass on_response so it's only called once
|
|
485
|
+
reconnect_limit=reconnect_limit - 1,
|
|
479
486
|
)
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
487
|
+
|
|
488
|
+
async def stream(
|
|
489
|
+
self,
|
|
490
|
+
path: str,
|
|
491
|
+
method: str,
|
|
492
|
+
*,
|
|
493
|
+
json: dict[str, Any] | None = None,
|
|
494
|
+
params: QueryParamTypes | None = None,
|
|
495
|
+
headers: Mapping[str, str] | None = None,
|
|
496
|
+
on_response: Callable[[httpx.Response], None] | None = None,
|
|
497
|
+
) -> AsyncIterator[StreamPart]:
|
|
498
|
+
"""Stream results using SSE."""
|
|
499
|
+
request_headers, content = await _aencode_json(json)
|
|
500
|
+
request_headers["Accept"] = "text/event-stream"
|
|
501
|
+
request_headers["Cache-Control"] = "no-store"
|
|
502
|
+
# Add runtime headers with precedence
|
|
503
|
+
if headers:
|
|
504
|
+
request_headers.update(headers)
|
|
505
|
+
|
|
506
|
+
reconnect_headers = {
|
|
507
|
+
key: value
|
|
508
|
+
for key, value in request_headers.items()
|
|
509
|
+
if key.lower() not in {"content-length", "content-type"}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
last_event_id: str | None = None
|
|
513
|
+
reconnect_path: str | None = None
|
|
514
|
+
reconnect_attempts = 0
|
|
515
|
+
max_reconnect_attempts = 5
|
|
516
|
+
|
|
517
|
+
while True:
|
|
518
|
+
current_headers = dict(
|
|
519
|
+
request_headers if reconnect_path is None else reconnect_headers
|
|
520
|
+
)
|
|
521
|
+
if last_event_id is not None:
|
|
522
|
+
current_headers["Last-Event-ID"] = last_event_id
|
|
523
|
+
|
|
524
|
+
current_method = method if reconnect_path is None else "GET"
|
|
525
|
+
current_content = content if reconnect_path is None else None
|
|
526
|
+
current_params = params if reconnect_path is None else None
|
|
527
|
+
|
|
528
|
+
retry = False
|
|
529
|
+
async with self.client.stream(
|
|
530
|
+
current_method,
|
|
531
|
+
reconnect_path or path,
|
|
532
|
+
headers=current_headers,
|
|
533
|
+
content=current_content,
|
|
534
|
+
params=current_params,
|
|
535
|
+
) as res:
|
|
536
|
+
if reconnect_path is None and on_response:
|
|
537
|
+
on_response(res)
|
|
538
|
+
# check status
|
|
539
|
+
try:
|
|
540
|
+
res.raise_for_status()
|
|
541
|
+
except httpx.HTTPStatusError as e:
|
|
542
|
+
body = (await res.aread()).decode()
|
|
543
|
+
if sys.version_info >= (3, 11):
|
|
544
|
+
e.add_note(body)
|
|
545
|
+
else:
|
|
546
|
+
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
|
|
547
|
+
raise e
|
|
548
|
+
# check content type
|
|
549
|
+
content_type = res.headers.get("content-type", "").partition(";")[0]
|
|
550
|
+
if "text/event-stream" not in content_type:
|
|
551
|
+
raise httpx.TransportError(
|
|
552
|
+
"Expected response header Content-Type to contain 'text/event-stream', "
|
|
553
|
+
f"got {content_type!r}"
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
reconnect_location = res.headers.get("location")
|
|
557
|
+
if reconnect_location:
|
|
558
|
+
reconnect_path = reconnect_location
|
|
559
|
+
|
|
560
|
+
# parse SSE
|
|
561
|
+
decoder = SSEDecoder()
|
|
562
|
+
try:
|
|
563
|
+
async for line in aiter_lines_raw(res):
|
|
564
|
+
sse = decoder.decode(line=line.rstrip(b"\n"))
|
|
565
|
+
if sse is not None:
|
|
566
|
+
if decoder.last_event_id is not None:
|
|
567
|
+
last_event_id = decoder.last_event_id
|
|
568
|
+
if sse.event or sse.data is not None:
|
|
569
|
+
yield sse
|
|
570
|
+
except httpx.HTTPError:
|
|
571
|
+
# httpx.TransportError inherits from HTTPError, so transient
|
|
572
|
+
# disconnects during streaming land here.
|
|
573
|
+
if reconnect_path is None:
|
|
574
|
+
raise
|
|
575
|
+
retry = True
|
|
576
|
+
else:
|
|
577
|
+
if sse := decoder.decode(b""):
|
|
578
|
+
if decoder.last_event_id is not None:
|
|
579
|
+
last_event_id = decoder.last_event_id
|
|
580
|
+
if sse.event or sse.data is not None:
|
|
581
|
+
# decoder.decode(b"") flushes the in-flight event and may
|
|
582
|
+
# return an empty placeholder when there is no pending
|
|
583
|
+
# message. Skip these no-op events so the stream doesn't
|
|
584
|
+
# emit a trailing blank item after reconnects.
|
|
585
|
+
yield sse
|
|
586
|
+
if retry:
|
|
587
|
+
reconnect_attempts += 1
|
|
588
|
+
if reconnect_attempts > max_reconnect_attempts:
|
|
589
|
+
raise httpx.TransportError(
|
|
590
|
+
"Exceeded maximum SSE reconnection attempts"
|
|
591
|
+
)
|
|
592
|
+
continue
|
|
593
|
+
break
|
|
488
594
|
|
|
489
595
|
|
|
490
596
|
async def _aencode_json(json: Any) -> tuple[dict[str, str], bytes | None]:
|
|
@@ -2475,8 +2581,9 @@ class RunsClient:
|
|
|
2475
2581
|
if on_run_created and (metadata := _get_run_metadata_from_response(res)):
|
|
2476
2582
|
on_run_created(metadata)
|
|
2477
2583
|
|
|
2478
|
-
response = await self.http.
|
|
2584
|
+
response = await self.http.request_reconnect(
|
|
2479
2585
|
endpoint,
|
|
2586
|
+
"POST",
|
|
2480
2587
|
json={k: v for k, v in payload.items() if v is not None},
|
|
2481
2588
|
params=params,
|
|
2482
2589
|
headers=headers,
|
|
@@ -2621,12 +2728,20 @@ class RunsClient:
|
|
|
2621
2728
|
}
|
|
2622
2729
|
if params:
|
|
2623
2730
|
query_params.update(params)
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2731
|
+
if wait:
|
|
2732
|
+
return await self.http.request_reconnect(
|
|
2733
|
+
f"/threads/{thread_id}/runs/{run_id}/cancel",
|
|
2734
|
+
"POST",
|
|
2735
|
+
params=query_params,
|
|
2736
|
+
headers=headers,
|
|
2737
|
+
)
|
|
2738
|
+
else:
|
|
2739
|
+
return await self.http.post(
|
|
2740
|
+
f"/threads/{thread_id}/runs/{run_id}/cancel",
|
|
2741
|
+
json=None,
|
|
2742
|
+
params=query_params,
|
|
2743
|
+
headers=headers,
|
|
2744
|
+
)
|
|
2630
2745
|
|
|
2631
2746
|
async def join(
|
|
2632
2747
|
self,
|
|
@@ -2658,8 +2773,11 @@ class RunsClient:
|
|
|
2658
2773
|
```
|
|
2659
2774
|
|
|
2660
2775
|
""" # noqa: E501
|
|
2661
|
-
return await self.http.
|
|
2662
|
-
f"/threads/{thread_id}/runs/{run_id}/join",
|
|
2776
|
+
return await self.http.request_reconnect(
|
|
2777
|
+
f"/threads/{thread_id}/runs/{run_id}/join",
|
|
2778
|
+
"GET",
|
|
2779
|
+
headers=headers,
|
|
2780
|
+
params=params,
|
|
2663
2781
|
)
|
|
2664
2782
|
|
|
2665
2783
|
def join_stream(
|
|
@@ -3631,7 +3749,7 @@ class SyncHttpClient:
|
|
|
3631
3749
|
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
|
|
3632
3750
|
raise e
|
|
3633
3751
|
|
|
3634
|
-
def
|
|
3752
|
+
def request_reconnect(
|
|
3635
3753
|
self,
|
|
3636
3754
|
path: str,
|
|
3637
3755
|
method: str,
|
|
@@ -3640,43 +3758,150 @@ class SyncHttpClient:
|
|
|
3640
3758
|
params: QueryParamTypes | None = None,
|
|
3641
3759
|
headers: Mapping[str, str] | None = None,
|
|
3642
3760
|
on_response: Callable[[httpx.Response], None] | None = None,
|
|
3643
|
-
|
|
3644
|
-
|
|
3761
|
+
reconnect_limit: int = 5,
|
|
3762
|
+
) -> Any:
|
|
3763
|
+
"""Send a request that automatically reconnects to Location header."""
|
|
3645
3764
|
request_headers, content = _encode_json(json)
|
|
3646
|
-
request_headers["Accept"] = "text/event-stream"
|
|
3647
|
-
request_headers["Cache-Control"] = "no-store"
|
|
3648
3765
|
if headers:
|
|
3649
3766
|
request_headers.update(headers)
|
|
3650
3767
|
with self.client.stream(
|
|
3651
3768
|
method, path, headers=request_headers, content=content, params=params
|
|
3652
|
-
) as
|
|
3769
|
+
) as r:
|
|
3653
3770
|
if on_response:
|
|
3654
|
-
on_response(
|
|
3655
|
-
# check status
|
|
3771
|
+
on_response(r)
|
|
3656
3772
|
try:
|
|
3657
|
-
|
|
3773
|
+
r.raise_for_status()
|
|
3658
3774
|
except httpx.HTTPStatusError as e:
|
|
3659
|
-
body =
|
|
3775
|
+
body = r.read().decode()
|
|
3660
3776
|
if sys.version_info >= (3, 11):
|
|
3661
3777
|
e.add_note(body)
|
|
3662
3778
|
else:
|
|
3663
3779
|
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
|
|
3664
3780
|
raise e
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3781
|
+
loc = r.headers.get("location")
|
|
3782
|
+
if reconnect_limit <= 0 or not loc:
|
|
3783
|
+
return _decode_json(r)
|
|
3784
|
+
try:
|
|
3785
|
+
return _decode_json(r)
|
|
3786
|
+
except httpx.HTTPError:
|
|
3787
|
+
warnings.warn(
|
|
3788
|
+
f"Request failed, attempting reconnect to Location: {loc}",
|
|
3789
|
+
stacklevel=2,
|
|
3671
3790
|
)
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3791
|
+
r.close()
|
|
3792
|
+
return self.request_reconnect(
|
|
3793
|
+
loc,
|
|
3794
|
+
"GET",
|
|
3795
|
+
headers=request_headers,
|
|
3796
|
+
# don't pass on_response so it's only called once
|
|
3797
|
+
reconnect_limit=reconnect_limit - 1,
|
|
3798
|
+
)
|
|
3799
|
+
|
|
3800
|
+
def stream(
|
|
3801
|
+
self,
|
|
3802
|
+
path: str,
|
|
3803
|
+
method: str,
|
|
3804
|
+
*,
|
|
3805
|
+
json: dict[str, Any] | None = None,
|
|
3806
|
+
params: QueryParamTypes | None = None,
|
|
3807
|
+
headers: Mapping[str, str] | None = None,
|
|
3808
|
+
on_response: Callable[[httpx.Response], None] | None = None,
|
|
3809
|
+
) -> Iterator[StreamPart]:
|
|
3810
|
+
"""Stream the results of a request using SSE."""
|
|
3811
|
+
if json is not None:
|
|
3812
|
+
request_headers, content = _encode_json(json)
|
|
3813
|
+
else:
|
|
3814
|
+
request_headers, content = {}, None
|
|
3815
|
+
request_headers["Accept"] = "text/event-stream"
|
|
3816
|
+
request_headers["Cache-Control"] = "no-store"
|
|
3817
|
+
if headers:
|
|
3818
|
+
request_headers.update(headers)
|
|
3819
|
+
|
|
3820
|
+
reconnect_headers = {
|
|
3821
|
+
key: value
|
|
3822
|
+
for key, value in request_headers.items()
|
|
3823
|
+
if key.lower() not in {"content-length", "content-type"}
|
|
3824
|
+
}
|
|
3825
|
+
|
|
3826
|
+
last_event_id: str | None = None
|
|
3827
|
+
reconnect_path: str | None = None
|
|
3828
|
+
reconnect_attempts = 0
|
|
3829
|
+
max_reconnect_attempts = 5
|
|
3830
|
+
|
|
3831
|
+
while True:
|
|
3832
|
+
current_headers = dict(
|
|
3833
|
+
request_headers if reconnect_path is None else reconnect_headers
|
|
3834
|
+
)
|
|
3835
|
+
if last_event_id is not None:
|
|
3836
|
+
current_headers["Last-Event-ID"] = last_event_id
|
|
3837
|
+
|
|
3838
|
+
current_method = method if reconnect_path is None else "GET"
|
|
3839
|
+
current_content = content if reconnect_path is None else None
|
|
3840
|
+
current_params = params if reconnect_path is None else None
|
|
3841
|
+
|
|
3842
|
+
retry = False
|
|
3843
|
+
with self.client.stream(
|
|
3844
|
+
current_method,
|
|
3845
|
+
reconnect_path or path,
|
|
3846
|
+
headers=current_headers,
|
|
3847
|
+
content=current_content,
|
|
3848
|
+
params=current_params,
|
|
3849
|
+
) as res:
|
|
3850
|
+
if reconnect_path is None and on_response:
|
|
3851
|
+
on_response(res)
|
|
3852
|
+
# check status
|
|
3853
|
+
try:
|
|
3854
|
+
res.raise_for_status()
|
|
3855
|
+
except httpx.HTTPStatusError as e:
|
|
3856
|
+
body = (res.read()).decode()
|
|
3857
|
+
if sys.version_info >= (3, 11):
|
|
3858
|
+
e.add_note(body)
|
|
3859
|
+
else:
|
|
3860
|
+
logger.error(f"Error from langgraph-api: {body}", exc_info=e)
|
|
3861
|
+
raise e
|
|
3862
|
+
# check content type
|
|
3863
|
+
content_type = res.headers.get("content-type", "").partition(";")[0]
|
|
3864
|
+
if "text/event-stream" not in content_type:
|
|
3865
|
+
raise httpx.TransportError(
|
|
3866
|
+
"Expected response header Content-Type to contain 'text/event-stream', "
|
|
3867
|
+
f"got {content_type!r}"
|
|
3868
|
+
)
|
|
3869
|
+
|
|
3870
|
+
reconnect_location = res.headers.get("location")
|
|
3871
|
+
if reconnect_location:
|
|
3872
|
+
reconnect_path = reconnect_location
|
|
3873
|
+
|
|
3874
|
+
decoder = SSEDecoder()
|
|
3875
|
+
try:
|
|
3876
|
+
for line in iter_lines_raw(res):
|
|
3877
|
+
sse = decoder.decode(line.rstrip(b"\n"))
|
|
3878
|
+
if sse is not None:
|
|
3879
|
+
if decoder.last_event_id is not None:
|
|
3880
|
+
last_event_id = decoder.last_event_id
|
|
3881
|
+
if sse.event or sse.data is not None:
|
|
3882
|
+
yield sse
|
|
3883
|
+
except httpx.HTTPError:
|
|
3884
|
+
# httpx.TransportError inherits from HTTPError, so transient
|
|
3885
|
+
# disconnects during streaming land here.
|
|
3886
|
+
if reconnect_path is None:
|
|
3887
|
+
raise
|
|
3888
|
+
retry = True
|
|
3889
|
+
else:
|
|
3890
|
+
if sse := decoder.decode(b""):
|
|
3891
|
+
if decoder.last_event_id is not None:
|
|
3892
|
+
last_event_id = decoder.last_event_id
|
|
3893
|
+
if sse.event or sse.data is not None:
|
|
3894
|
+
# See async stream implementation for rationale on
|
|
3895
|
+
# skipping empty flush events.
|
|
3896
|
+
yield sse
|
|
3897
|
+
if retry:
|
|
3898
|
+
reconnect_attempts += 1
|
|
3899
|
+
if reconnect_attempts > max_reconnect_attempts:
|
|
3900
|
+
raise httpx.TransportError(
|
|
3901
|
+
"Exceeded maximum SSE reconnection attempts"
|
|
3902
|
+
)
|
|
3903
|
+
continue
|
|
3904
|
+
break
|
|
3680
3905
|
|
|
3681
3906
|
|
|
3682
3907
|
def _encode_json(json: Any) -> tuple[dict[str, str], bytes]:
|
|
@@ -5637,8 +5862,9 @@ class SyncRunsClient:
|
|
|
5637
5862
|
endpoint = (
|
|
5638
5863
|
f"/threads/{thread_id}/runs/wait" if thread_id is not None else "/runs/wait"
|
|
5639
5864
|
)
|
|
5640
|
-
return self.http.
|
|
5865
|
+
return self.http.request_reconnect(
|
|
5641
5866
|
endpoint,
|
|
5867
|
+
"POST",
|
|
5642
5868
|
json={k: v for k, v in payload.items() if v is not None},
|
|
5643
5869
|
params=params,
|
|
5644
5870
|
headers=headers,
|
|
@@ -5761,11 +5987,25 @@ class SyncRunsClient:
|
|
|
5761
5987
|
```
|
|
5762
5988
|
|
|
5763
5989
|
""" # noqa: E501
|
|
5990
|
+
query_params = {
|
|
5991
|
+
"wait": 1 if wait else 0,
|
|
5992
|
+
"action": action,
|
|
5993
|
+
}
|
|
5994
|
+
if params:
|
|
5995
|
+
query_params.update(params)
|
|
5996
|
+
if wait:
|
|
5997
|
+
return self.http.request_reconnect(
|
|
5998
|
+
f"/threads/{thread_id}/runs/{run_id}/cancel",
|
|
5999
|
+
"POST",
|
|
6000
|
+
json=None,
|
|
6001
|
+
params=query_params,
|
|
6002
|
+
headers=headers,
|
|
6003
|
+
)
|
|
5764
6004
|
return self.http.post(
|
|
5765
|
-
f"/threads/{thread_id}/runs/{run_id}/cancel
|
|
6005
|
+
f"/threads/{thread_id}/runs/{run_id}/cancel",
|
|
5766
6006
|
json=None,
|
|
6007
|
+
params=query_params,
|
|
5767
6008
|
headers=headers,
|
|
5768
|
-
params=params,
|
|
5769
6009
|
)
|
|
5770
6010
|
|
|
5771
6011
|
def join(
|
|
@@ -5798,8 +6038,11 @@ class SyncRunsClient:
|
|
|
5798
6038
|
```
|
|
5799
6039
|
|
|
5800
6040
|
""" # noqa: E501
|
|
5801
|
-
return self.http.
|
|
5802
|
-
f"/threads/{thread_id}/runs/{run_id}/join",
|
|
6041
|
+
return self.http.request_reconnect(
|
|
6042
|
+
f"/threads/{thread_id}/runs/{run_id}/join",
|
|
6043
|
+
"GET",
|
|
6044
|
+
headers=headers,
|
|
6045
|
+
params=params,
|
|
5803
6046
|
)
|
|
5804
6047
|
|
|
5805
6048
|
def join_stream(
|
|
@@ -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
|
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator, Sequence
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from langgraph_sdk.client import HttpClient, SyncHttpClient
|
|
10
|
+
from langgraph_sdk.schema import StreamPart
|
|
11
|
+
from langgraph_sdk.sse import BytesLike, BytesLineDecoder, SSEDecoder
|
|
12
|
+
|
|
13
|
+
with open(Path(__file__).parent / "fixtures" / "response.txt", "rb") as f:
|
|
14
|
+
RESPONSE_PAYLOAD = f.read()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AsyncListByteStream(httpx.AsyncByteStream):
|
|
18
|
+
def __init__(self, chunks: Sequence[bytes], exc: Exception | None = None) -> None:
|
|
19
|
+
self._chunks = list(chunks)
|
|
20
|
+
self._exc = exc
|
|
21
|
+
|
|
22
|
+
async def __aiter__(self): # type: ignore[override]
|
|
23
|
+
for chunk in self._chunks:
|
|
24
|
+
yield chunk
|
|
25
|
+
if self._exc is not None:
|
|
26
|
+
raise self._exc
|
|
27
|
+
|
|
28
|
+
async def aclose(self) -> None:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ListByteStream(httpx.ByteStream):
|
|
33
|
+
def __init__(self, chunks: Sequence[bytes], exc: Exception | None = None) -> None:
|
|
34
|
+
self._chunks = list(chunks)
|
|
35
|
+
self._exc = exc
|
|
36
|
+
|
|
37
|
+
def __iter__(self): # type: ignore[override]
|
|
38
|
+
yield from self._chunks
|
|
39
|
+
if self._exc is not None:
|
|
40
|
+
raise self._exc
|
|
41
|
+
|
|
42
|
+
def close(self) -> None:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def iter_lines_raw(payload: list[bytes]) -> Iterator[BytesLike]:
|
|
47
|
+
decoder = BytesLineDecoder()
|
|
48
|
+
for part in payload:
|
|
49
|
+
yield from decoder.decode(part)
|
|
50
|
+
yield from decoder.flush()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_stream_see():
|
|
54
|
+
for groups in (
|
|
55
|
+
[RESPONSE_PAYLOAD],
|
|
56
|
+
RESPONSE_PAYLOAD.splitlines(keepends=True),
|
|
57
|
+
):
|
|
58
|
+
parts: list[StreamPart] = []
|
|
59
|
+
|
|
60
|
+
decoder = SSEDecoder()
|
|
61
|
+
for line in iter_lines_raw(groups):
|
|
62
|
+
sse = decoder.decode(line=line.rstrip(b"\n"))
|
|
63
|
+
if sse is not None:
|
|
64
|
+
parts.append(sse)
|
|
65
|
+
if sse := decoder.decode(b""):
|
|
66
|
+
parts.append(sse)
|
|
67
|
+
|
|
68
|
+
assert decoder.decode(b"") is None
|
|
69
|
+
assert len(parts) == 79
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.asyncio
|
|
73
|
+
async def test_http_client_stream_flushes_trailing_event():
|
|
74
|
+
payload = b'event: foo\ndata: {"bar": 1}\n'
|
|
75
|
+
|
|
76
|
+
async def handler(request: httpx.Request) -> httpx.Response:
|
|
77
|
+
assert request.headers["accept"] == "text/event-stream"
|
|
78
|
+
assert request.headers["cache-control"] == "no-store"
|
|
79
|
+
return httpx.Response(
|
|
80
|
+
200,
|
|
81
|
+
headers={"Content-Type": "text/event-stream"},
|
|
82
|
+
content=payload,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
transport = httpx.MockTransport(handler)
|
|
86
|
+
async with httpx.AsyncClient(
|
|
87
|
+
transport=transport, base_url="https://example.com"
|
|
88
|
+
) as client:
|
|
89
|
+
http_client = HttpClient(client)
|
|
90
|
+
parts = [part async for part in http_client.stream("/stream", "GET")]
|
|
91
|
+
|
|
92
|
+
assert parts == [StreamPart(event="foo", data={"bar": 1})]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_sync_http_client_stream_recovers_after_disconnect():
|
|
96
|
+
reconnect_path = "/reconnect"
|
|
97
|
+
first_chunks = [
|
|
98
|
+
b"id: 1\n",
|
|
99
|
+
b"event: values\n",
|
|
100
|
+
b'data: {"step": 1}\n\n',
|
|
101
|
+
]
|
|
102
|
+
second_chunks = [
|
|
103
|
+
b"id: 2\n",
|
|
104
|
+
b"event: values\n",
|
|
105
|
+
b'data: {"step": 2}\n\n',
|
|
106
|
+
b"event: end\n",
|
|
107
|
+
b"data: null\n\n",
|
|
108
|
+
]
|
|
109
|
+
call_count = 0
|
|
110
|
+
|
|
111
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
112
|
+
nonlocal call_count
|
|
113
|
+
call_count += 1
|
|
114
|
+
if call_count == 1:
|
|
115
|
+
assert request.method == "POST"
|
|
116
|
+
assert request.url.path == "/stream"
|
|
117
|
+
assert request.headers["accept"] == "text/event-stream"
|
|
118
|
+
assert request.headers["cache-control"] == "no-store"
|
|
119
|
+
assert "last-event-id" not in {
|
|
120
|
+
k.lower(): v for k, v in request.headers.items()
|
|
121
|
+
}
|
|
122
|
+
assert request.read()
|
|
123
|
+
return httpx.Response(
|
|
124
|
+
200,
|
|
125
|
+
headers={
|
|
126
|
+
"Content-Type": "text/event-stream",
|
|
127
|
+
"Location": reconnect_path,
|
|
128
|
+
},
|
|
129
|
+
stream=ListByteStream(
|
|
130
|
+
first_chunks,
|
|
131
|
+
httpx.RemoteProtocolError("incomplete chunked read"),
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
if call_count == 2:
|
|
135
|
+
assert request.method == "GET"
|
|
136
|
+
assert request.url.path == reconnect_path
|
|
137
|
+
assert request.headers["Last-Event-ID"] == "1"
|
|
138
|
+
assert request.read() == b""
|
|
139
|
+
return httpx.Response(
|
|
140
|
+
200,
|
|
141
|
+
headers={"Content-Type": "text/event-stream"},
|
|
142
|
+
stream=ListByteStream(second_chunks),
|
|
143
|
+
)
|
|
144
|
+
raise AssertionError("unexpected request")
|
|
145
|
+
|
|
146
|
+
transport = httpx.MockTransport(handler)
|
|
147
|
+
with httpx.Client(transport=transport, base_url="https://example.com") as client:
|
|
148
|
+
http_client = SyncHttpClient(client)
|
|
149
|
+
parts = list(http_client.stream("/stream", "POST", json={"payload": "value"}))
|
|
150
|
+
|
|
151
|
+
assert call_count == 2
|
|
152
|
+
assert parts == [
|
|
153
|
+
StreamPart(event="values", data={"step": 1}),
|
|
154
|
+
StreamPart(event="values", data={"step": 2}),
|
|
155
|
+
StreamPart(event="end", data=None),
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@pytest.mark.asyncio
|
|
160
|
+
async def test_http_client_stream_recovers_after_disconnect():
|
|
161
|
+
reconnect_path = "/reconnect"
|
|
162
|
+
first_chunks = [
|
|
163
|
+
b"id: 1\n",
|
|
164
|
+
b"event: values\n",
|
|
165
|
+
b'data: {"step": 1}\n\n',
|
|
166
|
+
]
|
|
167
|
+
second_chunks = [
|
|
168
|
+
b"id: 2\n",
|
|
169
|
+
b"event: values\n",
|
|
170
|
+
b'data: {"step": 2}\n\n',
|
|
171
|
+
b"event: end\n",
|
|
172
|
+
b"data: null\n\n",
|
|
173
|
+
]
|
|
174
|
+
call_count = 0
|
|
175
|
+
|
|
176
|
+
async def handler(request: httpx.Request) -> httpx.Response:
|
|
177
|
+
nonlocal call_count
|
|
178
|
+
call_count += 1
|
|
179
|
+
if call_count == 1:
|
|
180
|
+
assert request.method == "POST"
|
|
181
|
+
assert request.url.path == "/stream"
|
|
182
|
+
assert request.headers["accept"] == "text/event-stream"
|
|
183
|
+
assert request.headers["cache-control"] == "no-store"
|
|
184
|
+
assert "last-event-id" not in {
|
|
185
|
+
k.lower(): v for k, v in request.headers.items()
|
|
186
|
+
}
|
|
187
|
+
assert await request.aread()
|
|
188
|
+
return httpx.Response(
|
|
189
|
+
200,
|
|
190
|
+
headers={
|
|
191
|
+
"Content-Type": "text/event-stream",
|
|
192
|
+
"Location": reconnect_path,
|
|
193
|
+
},
|
|
194
|
+
stream=AsyncListByteStream(
|
|
195
|
+
first_chunks,
|
|
196
|
+
httpx.RemoteProtocolError("incomplete chunked read"),
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
if call_count == 2:
|
|
200
|
+
assert request.method == "GET"
|
|
201
|
+
assert request.url.path == reconnect_path
|
|
202
|
+
assert request.headers["Last-Event-ID"] == "1"
|
|
203
|
+
assert await request.aread() == b""
|
|
204
|
+
return httpx.Response(
|
|
205
|
+
200,
|
|
206
|
+
headers={"Content-Type": "text/event-stream"},
|
|
207
|
+
stream=AsyncListByteStream(second_chunks),
|
|
208
|
+
)
|
|
209
|
+
raise AssertionError("unexpected request")
|
|
210
|
+
|
|
211
|
+
transport = httpx.MockTransport(handler)
|
|
212
|
+
async with httpx.AsyncClient(
|
|
213
|
+
transport=transport, base_url="https://example.com"
|
|
214
|
+
) as client:
|
|
215
|
+
http_client = HttpClient(client)
|
|
216
|
+
parts = [
|
|
217
|
+
part
|
|
218
|
+
async for part in http_client.stream(
|
|
219
|
+
"/stream", "POST", json={"payload": "value"}
|
|
220
|
+
)
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
assert call_count == 2
|
|
224
|
+
assert parts == [
|
|
225
|
+
StreamPart(event="values", data={"step": 1}),
|
|
226
|
+
StreamPart(event="values", data={"step": 2}),
|
|
227
|
+
StreamPart(event="end", data=None),
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_sync_http_client_stream_flushes_trailing_event():
|
|
232
|
+
payload = b'event: foo\ndata: {"bar": 1}\n'
|
|
233
|
+
|
|
234
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
235
|
+
assert request.headers["accept"] == "text/event-stream"
|
|
236
|
+
assert request.headers["cache-control"] == "no-store"
|
|
237
|
+
return httpx.Response(
|
|
238
|
+
200,
|
|
239
|
+
headers={"Content-Type": "text/event-stream"},
|
|
240
|
+
content=payload,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
transport = httpx.MockTransport(handler)
|
|
244
|
+
with httpx.Client(transport=transport, base_url="https://example.com") as client:
|
|
245
|
+
http_client = SyncHttpClient(client)
|
|
246
|
+
parts = list(http_client.stream("/stream", "GET"))
|
|
247
|
+
|
|
248
|
+
assert parts == [StreamPart(event="foo", data={"bar": 1})]
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
from collections.abc import Iterator
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
|
|
4
|
-
import httpx
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
from langgraph_sdk.client import HttpClient, SyncHttpClient
|
|
8
|
-
from langgraph_sdk.schema import StreamPart
|
|
9
|
-
from langgraph_sdk.sse import BytesLike, BytesLineDecoder, SSEDecoder
|
|
10
|
-
|
|
11
|
-
with open(Path(__file__).parent / "fixtures" / "response.txt", "rb") as f:
|
|
12
|
-
RESPONSE_PAYLOAD = f.read()
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def iter_lines_raw(payload: list[bytes]) -> Iterator[BytesLike]:
|
|
16
|
-
decoder = BytesLineDecoder()
|
|
17
|
-
for part in payload:
|
|
18
|
-
yield from decoder.decode(part)
|
|
19
|
-
yield from decoder.flush()
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def test_stream_see():
|
|
23
|
-
for groups in (
|
|
24
|
-
[RESPONSE_PAYLOAD],
|
|
25
|
-
RESPONSE_PAYLOAD.splitlines(keepends=True),
|
|
26
|
-
):
|
|
27
|
-
parts: list[StreamPart] = []
|
|
28
|
-
|
|
29
|
-
decoder = SSEDecoder()
|
|
30
|
-
for line in iter_lines_raw(groups):
|
|
31
|
-
sse = decoder.decode(line=line.rstrip(b"\n"))
|
|
32
|
-
if sse is not None:
|
|
33
|
-
parts.append(sse)
|
|
34
|
-
if sse := decoder.decode(b""):
|
|
35
|
-
parts.append(sse)
|
|
36
|
-
|
|
37
|
-
assert decoder.decode(b"") is None
|
|
38
|
-
assert len(parts) == 79
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
@pytest.mark.asyncio
|
|
42
|
-
async def test_http_client_stream_flushes_trailing_event():
|
|
43
|
-
payload = b'event: foo\ndata: {"bar": 1}\n'
|
|
44
|
-
|
|
45
|
-
async def handler(request: httpx.Request) -> httpx.Response:
|
|
46
|
-
assert request.headers["accept"] == "text/event-stream"
|
|
47
|
-
assert request.headers["cache-control"] == "no-store"
|
|
48
|
-
return httpx.Response(
|
|
49
|
-
200,
|
|
50
|
-
headers={"Content-Type": "text/event-stream"},
|
|
51
|
-
content=payload,
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
transport = httpx.MockTransport(handler)
|
|
55
|
-
async with httpx.AsyncClient(
|
|
56
|
-
transport=transport, base_url="https://example.com"
|
|
57
|
-
) as client:
|
|
58
|
-
http_client = HttpClient(client)
|
|
59
|
-
parts = [part async for part in http_client.stream("/stream", "GET")]
|
|
60
|
-
|
|
61
|
-
assert parts == [StreamPart(event="foo", data={"bar": 1})]
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def test_sync_http_client_stream_flushes_trailing_event():
|
|
65
|
-
payload = b'event: foo\ndata: {"bar": 1}\n'
|
|
66
|
-
|
|
67
|
-
def handler(request: httpx.Request) -> httpx.Response:
|
|
68
|
-
assert request.headers["accept"] == "text/event-stream"
|
|
69
|
-
assert request.headers["cache-control"] == "no-store"
|
|
70
|
-
return httpx.Response(
|
|
71
|
-
200,
|
|
72
|
-
headers={"Content-Type": "text/event-stream"},
|
|
73
|
-
content=payload,
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
transport = httpx.MockTransport(handler)
|
|
77
|
-
with httpx.Client(transport=transport, base_url="https://example.com") as client:
|
|
78
|
-
http_client = SyncHttpClient(client)
|
|
79
|
-
parts = list(http_client.stream("/stream", "GET"))
|
|
80
|
-
|
|
81
|
-
assert parts == [StreamPart(event="foo", data={"bar": 1})]
|
|
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
|