mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-13 05:07:51 +02:00
fix(sdk-py): percent-encode caller-supplied identifiers in URL paths (#7893)
## Summary Wraps every caller-supplied identifier (thread_id, assistant_id, run_id, cron_id, checkpoint_id, namespace) interpolated into request URL paths with `urllib.parse.quote(safe="")` via a new `_quote_path_param` helper. All-dot strings are encoded as `%2E` triplets so they aren't collapsed by httpx's client-side path normalization. Helper raises `TypeError` for None/bytes inputs. ## Test plan - [x] `make format` / `make lint` / `make test` from `libs/sdk-py`
This commit is contained in:
@@ -8,6 +8,7 @@ from typing import Any, Literal, cast, overload
|
||||
import httpx
|
||||
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param
|
||||
from langgraph_sdk.schema import (
|
||||
Assistant,
|
||||
AssistantSelectField,
|
||||
@@ -84,7 +85,9 @@ class AssistantsClient:
|
||||
```
|
||||
"""
|
||||
return await self.http.get(
|
||||
f"/assistants/{assistant_id}", headers=headers, params=params
|
||||
f"/assistants/{_quote_path_param(assistant_id)}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
async def get_graph(
|
||||
@@ -142,7 +145,9 @@ class AssistantsClient:
|
||||
query_params.update(params)
|
||||
|
||||
return await self.http.get(
|
||||
f"/assistants/{assistant_id}/graph", params=query_params, headers=headers
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/graph",
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def get_schemas(
|
||||
@@ -263,7 +268,9 @@ class AssistantsClient:
|
||||
|
||||
"""
|
||||
return await self.http.get(
|
||||
f"/assistants/{assistant_id}/schemas", headers=headers, params=params
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/schemas",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
async def get_subgraphs(
|
||||
@@ -293,13 +300,13 @@ class AssistantsClient:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
if namespace is not None:
|
||||
return await self.http.get(
|
||||
f"/assistants/{assistant_id}/subgraphs/{namespace}",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/subgraphs/{_quote_path_param(namespace)}",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
else:
|
||||
return await self.http.get(
|
||||
f"/assistants/{assistant_id}/subgraphs",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/subgraphs",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
@@ -436,7 +443,7 @@ class AssistantsClient:
|
||||
if description:
|
||||
payload["description"] = description
|
||||
return await self.http.patch(
|
||||
f"/assistants/{assistant_id}",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -479,7 +486,7 @@ class AssistantsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
await self.http.delete(
|
||||
f"/assistants/{assistant_id}",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}",
|
||||
headers=headers,
|
||||
params=query_params or None,
|
||||
)
|
||||
@@ -686,7 +693,7 @@ class AssistantsClient:
|
||||
if metadata:
|
||||
payload["metadata"] = metadata
|
||||
return await self.http.post(
|
||||
f"/assistants/{assistant_id}/versions",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/versions",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -726,7 +733,7 @@ class AssistantsClient:
|
||||
payload: dict[str, Any] = {"version": version}
|
||||
|
||||
return await self.http.post(
|
||||
f"/assistants/{assistant_id}/latest",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/latest",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
|
||||
@@ -8,7 +8,7 @@ from datetime import datetime, tzinfo
|
||||
from typing import Any
|
||||
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk._shared.utilities import _resolve_timezone
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param, _resolve_timezone
|
||||
from langgraph_sdk.schema import (
|
||||
All,
|
||||
Config,
|
||||
@@ -166,7 +166,7 @@ class CronClient:
|
||||
payload["multitask_strategy"] = multitask_strategy
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
return await self.http.post(
|
||||
f"/threads/{thread_id}/runs/crons",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/crons",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -315,7 +315,9 @@ class CronClient:
|
||||
```
|
||||
|
||||
"""
|
||||
await self.http.delete(f"/runs/crons/{cron_id}", headers=headers, params=params)
|
||||
await self.http.delete(
|
||||
f"/runs/crons/{_quote_path_param(cron_id)}", headers=headers, params=params
|
||||
)
|
||||
|
||||
async def update(
|
||||
self,
|
||||
@@ -402,7 +404,7 @@ class CronClient:
|
||||
}
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
return await self.http.patch(
|
||||
f"/runs/crons/{cron_id}",
|
||||
f"/runs/crons/{_quote_path_param(cron_id)}",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
|
||||
@@ -12,6 +12,7 @@ import httpx
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk._shared.utilities import (
|
||||
_get_run_metadata_from_response,
|
||||
_quote_path_param,
|
||||
_sse_to_v2_dict,
|
||||
)
|
||||
from langgraph_sdk.schema import (
|
||||
@@ -337,7 +338,7 @@ class RunsClient:
|
||||
"langsmith_tracer": langsmith_tracing,
|
||||
}
|
||||
endpoint = (
|
||||
f"/threads/{thread_id}/runs/stream"
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/stream"
|
||||
if thread_id is not None
|
||||
else "/runs/stream"
|
||||
)
|
||||
@@ -596,7 +597,7 @@ class RunsClient:
|
||||
on_run_created(metadata)
|
||||
|
||||
return await self.http.post(
|
||||
f"/threads/{thread_id}/runs" if thread_id else "/runs",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs" if thread_id else "/runs",
|
||||
json=payload,
|
||||
params=params,
|
||||
headers=headers,
|
||||
@@ -821,7 +822,9 @@ class RunsClient:
|
||||
"langsmith_tracer": langsmith_tracing,
|
||||
}
|
||||
endpoint = (
|
||||
f"/threads/{thread_id}/runs/wait" if thread_id is not None else "/runs/wait"
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/wait"
|
||||
if thread_id is not None
|
||||
else "/runs/wait"
|
||||
)
|
||||
|
||||
def on_response(res: httpx.Response):
|
||||
@@ -895,7 +898,9 @@ class RunsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return await self.http.get(
|
||||
f"/threads/{thread_id}/runs", params=query_params, headers=headers
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs",
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def get(
|
||||
@@ -930,7 +935,9 @@ class RunsClient:
|
||||
"""
|
||||
|
||||
return await self.http.get(
|
||||
f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
async def cancel(
|
||||
@@ -978,14 +985,14 @@ class RunsClient:
|
||||
query_params.update(params)
|
||||
if wait:
|
||||
return await self.http.request_reconnect(
|
||||
f"/threads/{thread_id}/runs/{run_id}/cancel",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel",
|
||||
"POST",
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
)
|
||||
else:
|
||||
return await self.http.post(
|
||||
f"/threads/{thread_id}/runs/{run_id}/cancel",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel",
|
||||
json=None,
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
@@ -1081,7 +1088,7 @@ class RunsClient:
|
||||
|
||||
"""
|
||||
return await self.http.request_reconnect(
|
||||
f"/threads/{thread_id}/runs/{run_id}/join",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/join",
|
||||
"GET",
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -1136,7 +1143,7 @@ class RunsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return self.http.stream(
|
||||
f"/threads/{thread_id}/runs/{run_id}/stream",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/stream",
|
||||
"GET",
|
||||
params=query_params,
|
||||
headers={
|
||||
@@ -1177,5 +1184,7 @@ class RunsClient:
|
||||
|
||||
"""
|
||||
await self.http.delete(
|
||||
f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
from typing import Any, Literal, overload
|
||||
|
||||
from langgraph_sdk._async.http import HttpClient
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param
|
||||
from langgraph_sdk.schema import (
|
||||
Checkpoint,
|
||||
Json,
|
||||
@@ -90,7 +91,7 @@ class ThreadsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return await self.http.get(
|
||||
f"/threads/{thread_id}",
|
||||
f"/threads/{_quote_path_param(thread_id)}",
|
||||
headers=headers,
|
||||
params=query_params or None,
|
||||
)
|
||||
@@ -254,7 +255,7 @@ class ThreadsClient:
|
||||
if return_minimal:
|
||||
request_headers["Prefer"] = "return=minimal"
|
||||
return await self.http.patch(
|
||||
f"/threads/{thread_id}",
|
||||
f"/threads/{_quote_path_param(thread_id)}",
|
||||
json=payload,
|
||||
headers=request_headers or None,
|
||||
params=params,
|
||||
@@ -287,7 +288,9 @@ class ThreadsClient:
|
||||
```
|
||||
|
||||
"""
|
||||
await self.http.delete(f"/threads/{thread_id}", headers=headers, params=params)
|
||||
await self.http.delete(
|
||||
f"/threads/{_quote_path_param(thread_id)}", headers=headers, params=params
|
||||
)
|
||||
|
||||
async def search(
|
||||
self,
|
||||
@@ -430,7 +433,10 @@ class ThreadsClient:
|
||||
|
||||
"""
|
||||
return await self.http.post(
|
||||
f"/threads/{thread_id}/copy", json=None, headers=headers, params=params
|
||||
f"/threads/{_quote_path_param(thread_id)}/copy",
|
||||
json=None,
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
async def prune(
|
||||
@@ -586,7 +592,7 @@ class ThreadsClient:
|
||||
"""
|
||||
if checkpoint:
|
||||
return await self.http.post(
|
||||
f"/threads/{thread_id}/state/checkpoint",
|
||||
f"/threads/{_quote_path_param(thread_id)}/state/checkpoint",
|
||||
json={"checkpoint": checkpoint, "subgraphs": subgraphs},
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -596,7 +602,7 @@ class ThreadsClient:
|
||||
if params:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return await self.http.get(
|
||||
f"/threads/{thread_id}/state/{checkpoint_id}",
|
||||
f"/threads/{_quote_path_param(thread_id)}/state/{_quote_path_param(checkpoint_id)}",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
@@ -605,7 +611,7 @@ class ThreadsClient:
|
||||
if params:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return await self.http.get(
|
||||
f"/threads/{thread_id}/state",
|
||||
f"/threads/{_quote_path_param(thread_id)}/state",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
@@ -670,7 +676,10 @@ class ThreadsClient:
|
||||
if as_node:
|
||||
payload["as_node"] = as_node
|
||||
return await self.http.post(
|
||||
f"/threads/{thread_id}/state", json=payload, headers=headers, params=params
|
||||
f"/threads/{_quote_path_param(thread_id)}/state",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
async def get_history(
|
||||
@@ -719,7 +728,7 @@ class ThreadsClient:
|
||||
if checkpoint:
|
||||
payload["checkpoint"] = checkpoint
|
||||
return await self.http.post(
|
||||
f"/threads/{thread_id}/history",
|
||||
f"/threads/{_quote_path_param(thread_id)}/history",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -763,7 +772,7 @@ class ThreadsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return self.http.stream(
|
||||
f"/threads/{thread_id}/stream",
|
||||
f"/threads/{_quote_path_param(thread_id)}/stream",
|
||||
"GET",
|
||||
headers={
|
||||
**({"Last-Event-ID": last_event_id} if last_event_id else {}),
|
||||
|
||||
@@ -8,7 +8,7 @@ import re
|
||||
from collections.abc import Mapping
|
||||
from datetime import tzinfo
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -198,6 +198,39 @@ def _provided_vals(d: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {k: v for k, v in d.items() if v is not None}
|
||||
|
||||
|
||||
def _quote_path_param(value: Any) -> str:
|
||||
"""Encode a value for safe interpolation into a request path segment.
|
||||
|
||||
Path segments are encoded with ``safe=""`` so that ``/`` and other reserved
|
||||
characters are escaped. Standalone dot-segments (``.`` and ``..``) are also
|
||||
encoded because some URL-handling stacks (including ``httpx``) collapse
|
||||
them client-side as relative-path traversal before transmission. The value
|
||||
is coerced to ``str`` so callers can pass ``uuid.UUID`` and similar types
|
||||
directly without changing call sites.
|
||||
|
||||
A properly formed identifier (for example, a standard UUID, which contains
|
||||
no dots or reserved characters) round-trips through this function
|
||||
unchanged.
|
||||
|
||||
Raises:
|
||||
TypeError: If `value` is `None` or a `bytes`/`bytearray` instance.
|
||||
Coercing those would produce misleading paths (e.g. `/threads/None`),
|
||||
so surface the caller bug instead.
|
||||
"""
|
||||
if value is None:
|
||||
raise TypeError("path parameter must not be None")
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
raise TypeError("path parameter must not be bytes; pass a str or uuid.UUID")
|
||||
quoted = quote(str(value), safe="")
|
||||
# Bare "." or ".." (or any all-dot string) acts as a relative-path segment
|
||||
# that some HTTP stacks (including ``httpx``) collapse client-side before
|
||||
# transmission. Encode the dots so the segment becomes opaque to that
|
||||
# logic. Mixed values like "agent.v1" are unaffected.
|
||||
if quoted and all(c == "." for c in quoted):
|
||||
quoted = "%2E" * len(quoted)
|
||||
return quoted
|
||||
|
||||
|
||||
_registered_transports: list[httpx.ASGITransport] = []
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any, Literal, cast, overload
|
||||
|
||||
import httpx
|
||||
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
from langgraph_sdk.schema import (
|
||||
Assistant,
|
||||
@@ -83,7 +84,9 @@ class SyncAssistantsClient:
|
||||
|
||||
"""
|
||||
return self.http.get(
|
||||
f"/assistants/{assistant_id}", headers=headers, params=params
|
||||
f"/assistants/{_quote_path_param(assistant_id)}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
def get_graph(
|
||||
@@ -136,7 +139,9 @@ class SyncAssistantsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return self.http.get(
|
||||
f"/assistants/{assistant_id}/graph", params=query_params, headers=headers
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/graph",
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
def get_schemas(
|
||||
@@ -269,7 +274,9 @@ class SyncAssistantsClient:
|
||||
|
||||
"""
|
||||
return self.http.get(
|
||||
f"/assistants/{assistant_id}/schemas", headers=headers, params=params
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/schemas",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
def get_subgraphs(
|
||||
@@ -297,13 +304,13 @@ class SyncAssistantsClient:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
if namespace is not None:
|
||||
return self.http.get(
|
||||
f"/assistants/{assistant_id}/subgraphs/{namespace}",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/subgraphs/{_quote_path_param(namespace)}",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
else:
|
||||
return self.http.get(
|
||||
f"/assistants/{assistant_id}/subgraphs",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/subgraphs",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
@@ -438,7 +445,7 @@ class SyncAssistantsClient:
|
||||
if description:
|
||||
payload["description"] = description
|
||||
return self.http.patch(
|
||||
f"/assistants/{assistant_id}",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -481,7 +488,7 @@ class SyncAssistantsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
self.http.delete(
|
||||
f"/assistants/{assistant_id}",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}",
|
||||
headers=headers,
|
||||
params=query_params or None,
|
||||
)
|
||||
@@ -685,7 +692,7 @@ class SyncAssistantsClient:
|
||||
if metadata:
|
||||
payload["metadata"] = metadata
|
||||
return self.http.post(
|
||||
f"/assistants/{assistant_id}/versions",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/versions",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -724,7 +731,7 @@ class SyncAssistantsClient:
|
||||
payload: dict[str, Any] = {"version": version}
|
||||
|
||||
return self.http.post(
|
||||
f"/assistants/{assistant_id}/latest",
|
||||
f"/assistants/{_quote_path_param(assistant_id)}/latest",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
|
||||
@@ -7,7 +7,7 @@ from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, tzinfo
|
||||
from typing import Any
|
||||
|
||||
from langgraph_sdk._shared.utilities import _resolve_timezone
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param, _resolve_timezone
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
from langgraph_sdk.schema import (
|
||||
All,
|
||||
@@ -156,7 +156,7 @@ class SyncCronClient:
|
||||
}
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
return self.http.post(
|
||||
f"/threads/{thread_id}/runs/crons",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/crons",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -304,7 +304,9 @@ class SyncCronClient:
|
||||
```
|
||||
|
||||
"""
|
||||
self.http.delete(f"/runs/crons/{cron_id}", headers=headers, params=params)
|
||||
self.http.delete(
|
||||
f"/runs/crons/{_quote_path_param(cron_id)}", headers=headers, params=params
|
||||
)
|
||||
|
||||
def update(
|
||||
self,
|
||||
@@ -391,7 +393,7 @@ class SyncCronClient:
|
||||
}
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
return self.http.patch(
|
||||
f"/runs/crons/{cron_id}",
|
||||
f"/runs/crons/{_quote_path_param(cron_id)}",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
|
||||
@@ -11,6 +11,7 @@ import httpx
|
||||
|
||||
from langgraph_sdk._shared.utilities import (
|
||||
_get_run_metadata_from_response,
|
||||
_quote_path_param,
|
||||
_sse_to_v2_dict,
|
||||
)
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
@@ -332,7 +333,7 @@ class SyncRunsClient:
|
||||
"langsmith_tracer": langsmith_tracing,
|
||||
}
|
||||
endpoint = (
|
||||
f"/threads/{thread_id}/runs/stream"
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/stream"
|
||||
if thread_id is not None
|
||||
else "/runs/stream"
|
||||
)
|
||||
@@ -591,7 +592,7 @@ class SyncRunsClient:
|
||||
on_run_created(metadata)
|
||||
|
||||
return self.http.post(
|
||||
f"/threads/{thread_id}/runs" if thread_id else "/runs",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs" if thread_id else "/runs",
|
||||
json=payload,
|
||||
params=params,
|
||||
headers=headers,
|
||||
@@ -825,7 +826,9 @@ class SyncRunsClient:
|
||||
on_run_created(metadata)
|
||||
|
||||
endpoint = (
|
||||
f"/threads/{thread_id}/runs/wait" if thread_id is not None else "/runs/wait"
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/wait"
|
||||
if thread_id is not None
|
||||
else "/runs/wait"
|
||||
)
|
||||
return self.http.request_reconnect(
|
||||
endpoint,
|
||||
@@ -879,7 +882,9 @@ class SyncRunsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return self.http.get(
|
||||
f"/threads/{thread_id}/runs", params=query_params, headers=headers
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs",
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
def get(
|
||||
@@ -912,7 +917,9 @@ class SyncRunsClient:
|
||||
"""
|
||||
|
||||
return self.http.get(
|
||||
f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
def cancel(
|
||||
@@ -960,14 +967,14 @@ class SyncRunsClient:
|
||||
query_params.update(params)
|
||||
if wait:
|
||||
return self.http.request_reconnect(
|
||||
f"/threads/{thread_id}/runs/{run_id}/cancel",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel",
|
||||
"POST",
|
||||
json=None,
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
)
|
||||
return self.http.post(
|
||||
f"/threads/{thread_id}/runs/{run_id}/cancel",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/cancel",
|
||||
json=None,
|
||||
params=query_params,
|
||||
headers=headers,
|
||||
@@ -1063,7 +1070,7 @@ class SyncRunsClient:
|
||||
|
||||
"""
|
||||
return self.http.request_reconnect(
|
||||
f"/threads/{thread_id}/runs/{run_id}/join",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/join",
|
||||
"GET",
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -1117,7 +1124,7 @@ class SyncRunsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return self.http.stream(
|
||||
f"/threads/{thread_id}/runs/{run_id}/stream",
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}/stream",
|
||||
"GET",
|
||||
params=query_params,
|
||||
headers={
|
||||
@@ -1158,5 +1165,7 @@ class SyncRunsClient:
|
||||
|
||||
"""
|
||||
self.http.delete(
|
||||
f"/threads/{thread_id}/runs/{run_id}", headers=headers, params=params
|
||||
f"/threads/{_quote_path_param(thread_id)}/runs/{_quote_path_param(run_id)}",
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from typing import Any, Literal, overload
|
||||
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param
|
||||
from langgraph_sdk._sync.http import SyncHttpClient
|
||||
from langgraph_sdk.schema import (
|
||||
Checkpoint,
|
||||
@@ -88,7 +89,7 @@ class SyncThreadsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return self.http.get(
|
||||
f"/threads/{thread_id}",
|
||||
f"/threads/{_quote_path_param(thread_id)}",
|
||||
headers=headers,
|
||||
params=query_params or None,
|
||||
)
|
||||
@@ -250,7 +251,7 @@ class SyncThreadsClient:
|
||||
if return_minimal:
|
||||
request_headers["Prefer"] = "return=minimal"
|
||||
return self.http.patch(
|
||||
f"/threads/{thread_id}",
|
||||
f"/threads/{_quote_path_param(thread_id)}",
|
||||
json=payload,
|
||||
headers=request_headers or None,
|
||||
params=params,
|
||||
@@ -282,7 +283,9 @@ class SyncThreadsClient:
|
||||
```
|
||||
|
||||
"""
|
||||
self.http.delete(f"/threads/{thread_id}", headers=headers, params=params)
|
||||
self.http.delete(
|
||||
f"/threads/{_quote_path_param(thread_id)}", headers=headers, params=params
|
||||
)
|
||||
|
||||
def search(
|
||||
self,
|
||||
@@ -421,7 +424,10 @@ class SyncThreadsClient:
|
||||
|
||||
"""
|
||||
return self.http.post(
|
||||
f"/threads/{thread_id}/copy", json=None, headers=headers, params=params
|
||||
f"/threads/{_quote_path_param(thread_id)}/copy",
|
||||
json=None,
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
def prune(
|
||||
@@ -576,7 +582,7 @@ class SyncThreadsClient:
|
||||
"""
|
||||
if checkpoint:
|
||||
return self.http.post(
|
||||
f"/threads/{thread_id}/state/checkpoint",
|
||||
f"/threads/{_quote_path_param(thread_id)}/state/checkpoint",
|
||||
json={"checkpoint": checkpoint, "subgraphs": subgraphs},
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -586,7 +592,7 @@ class SyncThreadsClient:
|
||||
if params:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return self.http.get(
|
||||
f"/threads/{thread_id}/state/{checkpoint_id}",
|
||||
f"/threads/{_quote_path_param(thread_id)}/state/{_quote_path_param(checkpoint_id)}",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
@@ -595,7 +601,7 @@ class SyncThreadsClient:
|
||||
if params:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return self.http.get(
|
||||
f"/threads/{thread_id}/state",
|
||||
f"/threads/{_quote_path_param(thread_id)}/state",
|
||||
params=get_params,
|
||||
headers=headers,
|
||||
)
|
||||
@@ -657,7 +663,10 @@ class SyncThreadsClient:
|
||||
if as_node:
|
||||
payload["as_node"] = as_node
|
||||
return self.http.post(
|
||||
f"/threads/{thread_id}/state", json=payload, headers=headers, params=params
|
||||
f"/threads/{_quote_path_param(thread_id)}/state",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
|
||||
def get_history(
|
||||
@@ -707,7 +716,7 @@ class SyncThreadsClient:
|
||||
if checkpoint:
|
||||
payload["checkpoint"] = checkpoint
|
||||
return self.http.post(
|
||||
f"/threads/{thread_id}/history",
|
||||
f"/threads/{_quote_path_param(thread_id)}/history",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
@@ -752,7 +761,7 @@ class SyncThreadsClient:
|
||||
if params:
|
||||
query_params.update(params)
|
||||
return self.http.stream(
|
||||
f"/threads/{thread_id}/stream",
|
||||
f"/threads/{_quote_path_param(thread_id)}/stream",
|
||||
"GET",
|
||||
headers={
|
||||
**({"Last-Event-ID": last_event_id} if last_event_id else {}),
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
"""Regression tests for path-segment encoding of caller-supplied identifiers.
|
||||
|
||||
Covers GHSA-w39p-vh2g-g8g5: identifier values interpolated into request paths
|
||||
are encoded so the resulting request addresses the resource the SDK method
|
||||
indicates, even if the identifier contains characters with special meaning in
|
||||
URL paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from langgraph_sdk._shared.utilities import _quote_path_param
|
||||
from langgraph_sdk.client import (
|
||||
AssistantsClient,
|
||||
CronClient,
|
||||
HttpClient,
|
||||
RunsClient,
|
||||
SyncAssistantsClient,
|
||||
SyncCronClient,
|
||||
SyncHttpClient,
|
||||
SyncRunsClient,
|
||||
SyncThreadsClient,
|
||||
ThreadsClient,
|
||||
)
|
||||
|
||||
|
||||
class TestQuotePathParam:
|
||||
"""Unit tests for the encoding helper itself."""
|
||||
|
||||
def test_uuid_round_trips_unchanged(self) -> None:
|
||||
uuid_value = "550e8400-e29b-41d4-a716-446655440000"
|
||||
assert _quote_path_param(uuid_value) == uuid_value
|
||||
|
||||
def test_simple_opaque_id_round_trips_unchanged(self) -> None:
|
||||
assert _quote_path_param("thread_123") == "thread_123"
|
||||
assert _quote_path_param("asst_abc") == "asst_abc"
|
||||
|
||||
def test_slash_is_encoded(self) -> None:
|
||||
assert _quote_path_param("foo/bar") == "foo%2Fbar"
|
||||
|
||||
def test_bare_dot_segments_are_encoded(self) -> None:
|
||||
# All-dot strings are encoded to make them opaque to HTTP stacks that
|
||||
# collapse "./.." path segments client-side.
|
||||
assert _quote_path_param(".") == "%2E"
|
||||
assert _quote_path_param("..") == "%2E%2E"
|
||||
assert _quote_path_param("...") == "%2E%2E%2E"
|
||||
# Mixed values that happen to contain dots are not affected.
|
||||
assert _quote_path_param("agent.v1") == "agent.v1"
|
||||
# Subsequent ``/`` characters are encoded regardless.
|
||||
assert _quote_path_param("../bar") == "..%2Fbar"
|
||||
|
||||
def test_full_pivot_payload_is_encoded(self) -> None:
|
||||
# A caller-supplied identifier that, if interpolated raw, would route
|
||||
# the request to a different resource type.
|
||||
payload = "../assistants/abc-123"
|
||||
encoded = _quote_path_param(payload)
|
||||
assert encoded == "..%2Fassistants%2Fabc-123"
|
||||
assert "/" not in encoded
|
||||
|
||||
def test_non_string_values_are_coerced_to_str(self) -> None:
|
||||
import uuid
|
||||
|
||||
uid = uuid.UUID("550e8400-e29b-41d4-a716-446655440000")
|
||||
assert _quote_path_param(uid) == str(uid)
|
||||
assert _quote_path_param(42) == "42"
|
||||
|
||||
def test_none_value_raises_type_error(self) -> None:
|
||||
with pytest.raises(TypeError, match="must not be None"):
|
||||
_quote_path_param(None)
|
||||
|
||||
def test_bytes_value_raises_type_error(self) -> None:
|
||||
with pytest.raises(TypeError, match="must not be bytes"):
|
||||
_quote_path_param(b"bytes")
|
||||
with pytest.raises(TypeError, match="must not be bytes"):
|
||||
_quote_path_param(bytearray(b"bytes"))
|
||||
|
||||
|
||||
def _wire_path(request: httpx.Request) -> str:
|
||||
"""Return the path as it goes on the wire (preserves percent-encoding)."""
|
||||
return request.url.raw_path.decode("ascii")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAsyncPathEncoding:
|
||||
"""Async-client tests that verify the encoded path actually lands on the wire.
|
||||
|
||||
Note: ``request.url.path`` is the percent-decoded display form. The bytes
|
||||
that actually go on the wire are in ``request.url.raw_path``; that is what
|
||||
the server's router sees and what these tests inspect.
|
||||
"""
|
||||
|
||||
async def test_threads_get_with_pivot_payload_stays_on_threads(self) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"thread_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.get("../assistants/abc-123")
|
||||
|
||||
assert len(captured) == 1
|
||||
wire = captured[0]
|
||||
# The identifier is encoded so the wire path stays inside `/threads/...`.
|
||||
# The encoded segment must not contain literal slashes that could let
|
||||
# the server re-route to a different resource type.
|
||||
assert wire.startswith("/threads/")
|
||||
segment = wire[len("/threads/") :]
|
||||
assert "/" not in segment
|
||||
assert "%2F" in segment
|
||||
assert segment == "..%2Fassistants%2Fabc-123"
|
||||
|
||||
async def test_threads_update_with_pivot_payload_stays_on_threads(self) -> None:
|
||||
captured: list[tuple[str, str]] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append((request.method, _wire_path(request)))
|
||||
return httpx.Response(200, json={"thread_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.update("../assistants/abc-123", metadata={"x": 1})
|
||||
|
||||
assert len(captured) == 1
|
||||
method, wire = captured[0]
|
||||
assert method == "PATCH"
|
||||
assert wire.startswith("/threads/")
|
||||
segment = wire[len("/threads/") :]
|
||||
assert "/" not in segment
|
||||
|
||||
async def test_threads_delete_with_pivot_payload_stays_on_threads(self) -> None:
|
||||
captured: list[tuple[str, str]] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append((request.method, _wire_path(request)))
|
||||
return httpx.Response(200)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.delete("../runs/crons/some-cron-id")
|
||||
|
||||
assert len(captured) == 1
|
||||
method, wire = captured[0]
|
||||
assert method == "DELETE"
|
||||
assert wire.startswith("/threads/")
|
||||
segment = wire[len("/threads/") :]
|
||||
assert "/" not in segment
|
||||
|
||||
async def test_assistants_get_with_pivot_payload_stays_on_assistants(
|
||||
self,
|
||||
) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"assistant_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
assistants_client = AssistantsClient(HttpClient(client))
|
||||
await assistants_client.get("../threads/abc-123")
|
||||
|
||||
assert len(captured) == 1
|
||||
wire = captured[0]
|
||||
assert wire.startswith("/assistants/")
|
||||
segment = wire[len("/assistants/") :]
|
||||
assert "/" not in segment
|
||||
|
||||
async def test_runs_delete_double_id_pivot_stays_on_threads_runs(self) -> None:
|
||||
captured: list[tuple[str, str]] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append((request.method, _wire_path(request)))
|
||||
return httpx.Response(200)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
runs_client = RunsClient(HttpClient(client))
|
||||
# Both identifier values supplied as path-traversal payloads.
|
||||
await runs_client.delete("..", "../runs/crons/cron-id")
|
||||
|
||||
assert len(captured) == 1
|
||||
method, wire = captured[0]
|
||||
assert method == "DELETE"
|
||||
# The path should match `/threads/{quoted_thread}/runs/{quoted_run}`
|
||||
# exactly. Neither segment should contain literal slashes.
|
||||
assert wire.startswith("/threads/")
|
||||
assert "/runs/crons/" not in wire
|
||||
parts = wire.split("/")
|
||||
# Expected shape: ['', 'threads', '<encoded ..>', 'runs', '<encoded ..>']
|
||||
assert len(parts) == 5
|
||||
assert parts[1] == "threads"
|
||||
assert parts[3] == "runs"
|
||||
# Encoded thread_id and run_id are between literal slashes.
|
||||
assert parts[2] == "%2E%2E"
|
||||
assert parts[4] == "..%2Fruns%2Fcrons%2Fcron-id"
|
||||
|
||||
async def test_crons_delete_with_pivot_payload_stays_on_crons(self) -> None:
|
||||
captured: list[tuple[str, str]] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append((request.method, _wire_path(request)))
|
||||
return httpx.Response(200)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
crons_client = CronClient(HttpClient(client))
|
||||
await crons_client.delete("../../assistants/abc-123")
|
||||
|
||||
assert len(captured) == 1
|
||||
method, wire = captured[0]
|
||||
assert method == "DELETE"
|
||||
assert wire.startswith("/runs/crons/")
|
||||
segment = wire[len("/runs/crons/") :]
|
||||
assert "/" not in segment
|
||||
|
||||
async def test_threads_get_state_with_pivot_checkpoint_id_stays_on_state(
|
||||
self,
|
||||
) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.get_state(thread_id="tid-1", checkpoint_id="../runs")
|
||||
|
||||
assert len(captured) == 1
|
||||
wire = captured[0]
|
||||
# Wire path must stay on `/threads/{tid}/state/...`, not pivot to
|
||||
# `/threads/tid-1/runs`.
|
||||
assert wire.startswith("/threads/tid-1/state/")
|
||||
# Strip query string before checking the checkpoint segment.
|
||||
path_only = wire.split("?", 1)[0]
|
||||
segment = path_only[len("/threads/tid-1/state/") :]
|
||||
assert "/" not in segment
|
||||
assert segment == "..%2Fruns"
|
||||
|
||||
async def test_assistants_get_subgraphs_with_pivot_namespace_stays_on_subgraphs(
|
||||
self,
|
||||
) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
assistants_client = AssistantsClient(HttpClient(client))
|
||||
await assistants_client.get_subgraphs("aid-1", namespace="../foo")
|
||||
|
||||
assert len(captured) == 1
|
||||
wire = captured[0]
|
||||
# Wire path must stay on `/assistants/{aid}/subgraphs/...`.
|
||||
assert wire.startswith("/assistants/aid-1/subgraphs/")
|
||||
# Strip query string before checking the namespace segment.
|
||||
path_only = wire.split("?", 1)[0]
|
||||
segment = path_only[len("/assistants/aid-1/subgraphs/") :]
|
||||
assert "/" not in segment
|
||||
assert segment == "..%2Ffoo"
|
||||
|
||||
async def test_bare_double_dot_thread_id_survives_to_wire(self) -> None:
|
||||
"""The all-dot encoding branch must survive httpx's relative-path collapse."""
|
||||
captured: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"thread_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.get("..")
|
||||
|
||||
assert len(captured) == 1
|
||||
# The all-dot identifier is fully percent-encoded so httpx does NOT
|
||||
# collapse it client-side as a relative-path traversal.
|
||||
assert captured[0].endswith("/threads/%2E%2E")
|
||||
|
||||
async def test_bare_single_dot_thread_id_survives_to_wire(self) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"thread_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.get(".")
|
||||
|
||||
assert len(captured) == 1
|
||||
assert captured[0].endswith("/threads/%2E")
|
||||
|
||||
async def test_uuid_identifier_lands_on_intended_path(self) -> None:
|
||||
"""Legitimate UUID identifiers round-trip without encoding artifacts."""
|
||||
captured: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"thread_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.get("550e8400-e29b-41d4-a716-446655440000")
|
||||
|
||||
assert captured == ["/threads/550e8400-e29b-41d4-a716-446655440000"]
|
||||
|
||||
|
||||
class TestSyncPathEncoding:
|
||||
"""Sync-client tests that mirror the async coverage on a representative subset."""
|
||||
|
||||
def test_threads_get_with_pivot_payload_stays_on_threads(self) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"thread_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = SyncThreadsClient(SyncHttpClient(client))
|
||||
threads_client.get("../assistants/abc-123")
|
||||
|
||||
assert len(captured) == 1
|
||||
wire = captured[0]
|
||||
assert wire.startswith("/threads/")
|
||||
segment = wire[len("/threads/") :]
|
||||
assert "/" not in segment
|
||||
assert segment == "..%2Fassistants%2Fabc-123"
|
||||
|
||||
def test_assistants_get_with_pivot_payload_stays_on_assistants(self) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"assistant_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
assistants_client = SyncAssistantsClient(SyncHttpClient(client))
|
||||
assistants_client.get("../threads/abc-123")
|
||||
|
||||
assert len(captured) == 1
|
||||
wire = captured[0]
|
||||
assert wire.startswith("/assistants/")
|
||||
segment = wire[len("/assistants/") :]
|
||||
assert "/" not in segment
|
||||
|
||||
def test_runs_delete_double_id_pivot_stays_on_threads_runs(self) -> None:
|
||||
captured: list[tuple[str, str]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append((request.method, _wire_path(request)))
|
||||
return httpx.Response(200)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
runs_client = SyncRunsClient(SyncHttpClient(client))
|
||||
runs_client.delete("..", "../runs/crons/cron-id")
|
||||
|
||||
assert len(captured) == 1
|
||||
method, wire = captured[0]
|
||||
assert method == "DELETE"
|
||||
assert wire.startswith("/threads/")
|
||||
assert "/runs/crons/" not in wire
|
||||
parts = wire.split("/")
|
||||
assert len(parts) == 5
|
||||
assert parts[1] == "threads"
|
||||
assert parts[3] == "runs"
|
||||
assert parts[2] == "%2E%2E"
|
||||
assert parts[4] == "..%2Fruns%2Fcrons%2Fcron-id"
|
||||
|
||||
def test_crons_delete_with_pivot_payload_stays_on_crons(self) -> None:
|
||||
captured: list[tuple[str, str]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append((request.method, _wire_path(request)))
|
||||
return httpx.Response(200)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
crons_client = SyncCronClient(SyncHttpClient(client))
|
||||
crons_client.delete("../../assistants/abc-123")
|
||||
|
||||
assert len(captured) == 1
|
||||
method, wire = captured[0]
|
||||
assert method == "DELETE"
|
||||
assert wire.startswith("/runs/crons/")
|
||||
segment = wire[len("/runs/crons/") :]
|
||||
assert "/" not in segment
|
||||
|
||||
def test_uuid_identifier_lands_on_intended_path(self) -> None:
|
||||
captured: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(_wire_path(request))
|
||||
return httpx.Response(200, json={"thread_id": "anything"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = SyncThreadsClient(SyncHttpClient(client))
|
||||
threads_client.get("550e8400-e29b-41d4-a716-446655440000")
|
||||
|
||||
assert captured == ["/threads/550e8400-e29b-41d4-a716-446655440000"]
|
||||
Reference in New Issue
Block a user