From f4214a4be3d61937b8c71bab4a63cb14e20ae888 Mon Sep 17 00:00:00 2001 From: MarkRx Date: Fri, 27 Dec 2024 10:23:17 -0700 Subject: [PATCH 1/4] Added annotation queue methods to async python langsmith client --- python/langsmith/async_client.py | 209 +++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) diff --git a/python/langsmith/async_client.py b/python/langsmith/async_client.py index 8245edbab..7573e7c80 100644 --- a/python/langsmith/async_client.py +++ b/python/langsmith/async_client.py @@ -25,6 +25,7 @@ from langsmith import utils as ls_utils from langsmith._internal import _beta_decorator as ls_beta +ID_TYPE = Union[uuid.UUID, str] class AsyncClient: """Async Client for interacting with the LangSmith API.""" @@ -826,6 +827,214 @@ async def list_feedback( if limit is not None and ix >= limit: break + async def delete_feedback(self, feedback_id: ID_TYPE) -> None: + """Delete a feedback by ID. + + Args: + feedback_id (Union[UUID, str]): + The ID of the feedback to delete. + + Returns: + None + """ + response = await self._arequest_with_retries( + "DELETE", + f"/feedback/{ls_client._as_uuid(feedback_id, 'feedback_id')}" + ) + ls_utils.raise_for_status_with_text(response) + + # Annotation Queue API + + async def list_annotation_queues( + self, + *, + queue_ids: Optional[List[ID_TYPE]] = None, + name: Optional[str] = None, + name_contains: Optional[str] = None, + limit: Optional[int] = None, + ) -> AsyncIterator[ls_schemas.AnnotationQueue]: + """List the annotation queues on the LangSmith API. + + Args: + queue_ids (Optional[List[Union[UUID, str]]]): + The IDs of the queues to filter by. + name (Optional[str]): + The name of the queue to filter by. + name_contains (Optional[str]): + The substring that the queue name should contain. + limit (Optional[int]): + The maximum number of queues to return. + + Yields: + The annotation queues. + """ + params: dict = { + "ids": ( + [ls_client._as_uuid(id_, f"queue_ids[{i}]") for i, id_ in enumerate(queue_ids)] + if queue_ids is not None + else None + ), + "name": name, + "name_contains": name_contains, + "limit": min(limit, 100) if limit is not None else 100, + } + ix = 0 + async for feedback in self._aget_paginated_list("/annotation-queues", params=params): + yield ls_schemas.AnnotationQueue(**feedback) + ix += 1 + if limit is not None and ix >= limit: + break + + async def create_annotation_queue( + self, + *, + name: str, + description: Optional[str] = None, + queue_id: Optional[ID_TYPE] = None, + ) -> ls_schemas.AnnotationQueue: + """Create an annotation queue on the LangSmith API. + + Args: + name (str): + The name of the annotation queue. + description (Optional[str]): + The description of the annotation queue. + queue_id (Optional[Union[UUID, str]]): + The ID of the annotation queue. + + Returns: + AnnotationQueue: The created annotation queue object. + """ + body = { + "name": name, + "description": description, + "id": queue_id or str(uuid.uuid4()), + } + response = await self._arequest_with_retries( + "POST", + "/annotation-queues", + json={k: v for k, v in body.items() if v is not None}, + ) + ls_utils.raise_for_status_with_text(response) + return ls_schemas.AnnotationQueue( + **response.json(), + ) + + async def read_annotation_queue(self, queue_id: ID_TYPE) -> ls_schemas.AnnotationQueue: + """Read an annotation queue with the specified queue ID. + + Args: + queue_id (Union[UUID, str]): The ID of the annotation queue to read. + + Returns: + AnnotationQueue: The annotation queue object. + """ + # TODO: Replace when actual endpoint is added + return await self.list_annotation_queues(queue_ids=[queue_id]).__anext__() + + async def update_annotation_queue( + self, queue_id: ID_TYPE, *, name: str, description: Optional[str] = None + ) -> None: + """Update an annotation queue with the specified queue_id. + + Args: + queue_id (Union[UUID, str]): The ID of the annotation queue to update. + name (str): The new name for the annotation queue. + description (Optional[str]): The new description for the + annotation queue. Defaults to None. + + Returns: + None + """ + response = await self._arequest_with_retries( + "PATCH", + f"/annotation-queues/{ls_client._as_uuid(queue_id, 'queue_id')}", + json={ + "name": name, + "description": description, + }, + ) + ls_utils.raise_for_status_with_text(response) + + async def delete_annotation_queue(self, queue_id: ID_TYPE) -> None: + """Delete an annotation queue with the specified queue ID. + + Args: + queue_id (Union[UUID, str]): The ID of the annotation queue to delete. + + Returns: + None + """ + response = await self._arequest_with_retries( + "DELETE", + f"/annotation-queues/{ls_client._as_uuid(queue_id, 'queue_id')}", + headers={"Accept": "application/json", **self._client.headers}, + ) + ls_utils.raise_for_status_with_text(response) + + async def add_runs_to_annotation_queue( + self, queue_id: ID_TYPE, *, run_ids: List[ID_TYPE] + ) -> None: + """Add runs to an annotation queue with the specified queue ID. + + Args: + queue_id (Union[UUID, str]): The ID of the annotation queue. + run_ids (List[Union[UUID, str]]): The IDs of the runs to be added to the annotation + queue. + + Returns: + None + """ + response = await self._arequest_with_retries( + "POST", + f"/annotation-queues/{ls_client._as_uuid(queue_id, 'queue_id')}/runs", + json=[str(ls_client._as_uuid(id_, f"run_ids[{i}]")) for i, id_ in enumerate(run_ids)], + ) + ls_utils.raise_for_status_with_text(response) + + async def delete_run_from_annotation_queue( + self, queue_id: ID_TYPE, *, run_id: ID_TYPE + ) -> None: + """Delete a run from an annotation queue with the specified queue ID and run ID. + + Args: + queue_id (Union[UUID, str]): The ID of the annotation queue. + run_id (Union[UUID, str]): The ID of the run to be added to the annotation + queue. + + Returns: + None + """ + response = await self._arequest_with_retries( + "DELETE", + f"/annotation-queues/{ls_client._as_uuid(queue_id, 'queue_id')}/runs/{ls_client._as_uuid(run_id, 'run_id')}", + ) + ls_utils.raise_for_status_with_text(response) + + async def get_run_from_annotation_queue( + self, queue_id: ID_TYPE, *, index: int + ) -> ls_schemas.RunWithAnnotationQueueInfo: + """Get a run from an annotation queue at the specified index. + + Args: + queue_id (Union[UUID, str]): The ID of the annotation queue. + index (int): The index of the run to retrieve. + + Returns: + RunWithAnnotationQueueInfo: The run at the specified index. + + Raises: + LangSmithNotFoundError: If the run is not found at the given index. + LangSmithError: For other API-related errors. + """ + base_url = f"/annotation-queues/{ls_client._as_uuid(queue_id, 'queue_id')}/run" + response = await self._arequest_with_retries( + "GET", + f"{base_url}/{index}" + ) + ls_utils.raise_for_status_with_text(response) + return ls_schemas.RunWithAnnotationQueueInfo(**response.json()) + @ls_beta.warn_beta async def index_dataset( self, From 60d65209631c0ad9e772d1a63e9cf22a287f8918 Mon Sep 17 00:00:00 2001 From: MarkRx Date: Fri, 27 Dec 2024 11:09:00 -0700 Subject: [PATCH 2/4] Fix python async client not filtering out params with None values --- python/langsmith/async_client.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/python/langsmith/async_client.py b/python/langsmith/async_client.py index 7573e7c80..35cee5f3d 100644 --- a/python/langsmith/async_client.py +++ b/python/langsmith/async_client.py @@ -96,6 +96,14 @@ async def _arequest_with_retries( ) -> httpx.Response: """Make an async HTTP request with retries.""" max_retries = cast(int, self._retry_config.get("max_retries", 3)) + + # Python requests library used by the normal Client filters out params with None values + # The httpx library does not. Filter them out here to keep behavior consistent + if 'params' in kwargs: + params = kwargs['params'] + filtered_params = {k: v for k, v in params.items() if v is not None} + kwargs['params'] = filtered_params + for attempt in range(max_retries): try: response = await self._client.request(method, endpoint, **kwargs) From 9ebe8e17a33794ae4a608092500ca0f3cd5dd802 Mon Sep 17 00:00:00 2001 From: isaac hershenson Date: Tue, 7 Jan 2025 13:42:54 -0800 Subject: [PATCH 3/4] add tests --- python/langsmith/async_client.py | 64 ++++--- python/langsmith/client.py | 2 +- .../integration_tests/test_async_client.py | 167 ++++++++++++++++++ python/tests/integration_tests/test_client.py | 126 +++++++++++++ 4 files changed, 329 insertions(+), 30 deletions(-) diff --git a/python/langsmith/async_client.py b/python/langsmith/async_client.py index 35cee5f3d..51cb594fa 100644 --- a/python/langsmith/async_client.py +++ b/python/langsmith/async_client.py @@ -99,10 +99,10 @@ async def _arequest_with_retries( # Python requests library used by the normal Client filters out params with None values # The httpx library does not. Filter them out here to keep behavior consistent - if 'params' in kwargs: - params = kwargs['params'] + if "params" in kwargs: + params = kwargs["params"] filtered_params = {k: v for k, v in params.items() if v is not None} - kwargs['params'] = filtered_params + kwargs["params"] = filtered_params for attempt in range(max_retries): try: @@ -846,20 +846,19 @@ async def delete_feedback(self, feedback_id: ID_TYPE) -> None: None """ response = await self._arequest_with_retries( - "DELETE", - f"/feedback/{ls_client._as_uuid(feedback_id, 'feedback_id')}" + "DELETE", f"/feedback/{ls_client._as_uuid(feedback_id, 'feedback_id')}" ) ls_utils.raise_for_status_with_text(response) # Annotation Queue API async def list_annotation_queues( - self, - *, - queue_ids: Optional[List[ID_TYPE]] = None, - name: Optional[str] = None, - name_contains: Optional[str] = None, - limit: Optional[int] = None, + self, + *, + queue_ids: Optional[List[ID_TYPE]] = None, + name: Optional[str] = None, + name_contains: Optional[str] = None, + limit: Optional[int] = None, ) -> AsyncIterator[ls_schemas.AnnotationQueue]: """List the annotation queues on the LangSmith API. @@ -878,7 +877,10 @@ async def list_annotation_queues( """ params: dict = { "ids": ( - [ls_client._as_uuid(id_, f"queue_ids[{i}]") for i, id_ in enumerate(queue_ids)] + [ + ls_client._as_uuid(id_, f"queue_ids[{i}]") + for i, id_ in enumerate(queue_ids) + ] if queue_ids is not None else None ), @@ -887,18 +889,20 @@ async def list_annotation_queues( "limit": min(limit, 100) if limit is not None else 100, } ix = 0 - async for feedback in self._aget_paginated_list("/annotation-queues", params=params): + async for feedback in self._aget_paginated_list( + "/annotation-queues", params=params + ): yield ls_schemas.AnnotationQueue(**feedback) ix += 1 if limit is not None and ix >= limit: break async def create_annotation_queue( - self, - *, - name: str, - description: Optional[str] = None, - queue_id: Optional[ID_TYPE] = None, + self, + *, + name: str, + description: Optional[str] = None, + queue_id: Optional[ID_TYPE] = None, ) -> ls_schemas.AnnotationQueue: """Create an annotation queue on the LangSmith API. @@ -916,7 +920,7 @@ async def create_annotation_queue( body = { "name": name, "description": description, - "id": queue_id or str(uuid.uuid4()), + "id": str(queue_id) if queue_id is not None else str(uuid.uuid4()), } response = await self._arequest_with_retries( "POST", @@ -928,7 +932,9 @@ async def create_annotation_queue( **response.json(), ) - async def read_annotation_queue(self, queue_id: ID_TYPE) -> ls_schemas.AnnotationQueue: + async def read_annotation_queue( + self, queue_id: ID_TYPE + ) -> ls_schemas.AnnotationQueue: """Read an annotation queue with the specified queue ID. Args: @@ -941,7 +947,7 @@ async def read_annotation_queue(self, queue_id: ID_TYPE) -> ls_schemas.Annotatio return await self.list_annotation_queues(queue_ids=[queue_id]).__anext__() async def update_annotation_queue( - self, queue_id: ID_TYPE, *, name: str, description: Optional[str] = None + self, queue_id: ID_TYPE, *, name: str, description: Optional[str] = None ) -> None: """Update an annotation queue with the specified queue_id. @@ -981,7 +987,7 @@ async def delete_annotation_queue(self, queue_id: ID_TYPE) -> None: ls_utils.raise_for_status_with_text(response) async def add_runs_to_annotation_queue( - self, queue_id: ID_TYPE, *, run_ids: List[ID_TYPE] + self, queue_id: ID_TYPE, *, run_ids: List[ID_TYPE] ) -> None: """Add runs to an annotation queue with the specified queue ID. @@ -996,12 +1002,15 @@ async def add_runs_to_annotation_queue( response = await self._arequest_with_retries( "POST", f"/annotation-queues/{ls_client._as_uuid(queue_id, 'queue_id')}/runs", - json=[str(ls_client._as_uuid(id_, f"run_ids[{i}]")) for i, id_ in enumerate(run_ids)], + json=[ + str(ls_client._as_uuid(id_, f"run_ids[{i}]")) + for i, id_ in enumerate(run_ids) + ], ) ls_utils.raise_for_status_with_text(response) async def delete_run_from_annotation_queue( - self, queue_id: ID_TYPE, *, run_id: ID_TYPE + self, queue_id: ID_TYPE, *, run_id: ID_TYPE ) -> None: """Delete a run from an annotation queue with the specified queue ID and run ID. @@ -1020,7 +1029,7 @@ async def delete_run_from_annotation_queue( ls_utils.raise_for_status_with_text(response) async def get_run_from_annotation_queue( - self, queue_id: ID_TYPE, *, index: int + self, queue_id: ID_TYPE, *, index: int ) -> ls_schemas.RunWithAnnotationQueueInfo: """Get a run from an annotation queue at the specified index. @@ -1036,10 +1045,7 @@ async def get_run_from_annotation_queue( LangSmithError: For other API-related errors. """ base_url = f"/annotation-queues/{ls_client._as_uuid(queue_id, 'queue_id')}/run" - response = await self._arequest_with_retries( - "GET", - f"{base_url}/{index}" - ) + response = await self._arequest_with_retries("GET", f"{base_url}/{index}") ls_utils.raise_for_status_with_text(response) return ls_schemas.RunWithAnnotationQueueInfo(**response.json()) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index a0783d62e..e19078f8d 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -5659,7 +5659,7 @@ def create_annotation_queue( body = { "name": name, "description": description, - "id": queue_id or str(uuid.uuid4()), + "id": str(queue_id) if queue_id is not None else str(uuid.uuid4()), } response = self.request_with_retries( "POST", diff --git a/python/tests/integration_tests/test_async_client.py b/python/tests/integration_tests/test_async_client.py index ecd03c87f..5928ac26d 100644 --- a/python/tests/integration_tests/test_async_client.py +++ b/python/tests/integration_tests/test_async_client.py @@ -276,3 +276,170 @@ async def check_feedbacks(): return len(feedbacks) == 3 await wait_for(check_feedbacks, timeout=10) + + feedbacks = [ + feedback async for feedback in async_client.list_feedback(run_ids=[run_id]) + ] + assert len(feedbacks) == 3 + + +@pytest.mark.asyncio +async def test_delete_feedback(async_client: AsyncClient): + """Test deleting feedback.""" + project_name = "__test_delete_feedback" + uuid.uuid4().hex[:8] + run_id = uuid.uuid4() + + await async_client.create_run( + name="test_run", + inputs={"input": "hello"}, + run_type="llm", + project_name=project_name, + id=run_id, + start_time=datetime.datetime.now(datetime.timezone.utc), + ) + + # Create feedback + feedback = await async_client.create_feedback( + run_id=run_id, + key="test_feedback", + value=1, + comment="test comment", + ) + + # Delete the feedback + await async_client.delete_feedback(feedback.id) + + # Verify feedback is deleted by checking list_feedback + feedbacks = [ + feedback async for feedback in async_client.list_feedback(run_ids=[run_id]) + ] + assert len(feedbacks) == 0 + + +@pytest.mark.asyncio +async def test_annotation_queue_crud(async_client: AsyncClient): + """Test basic CRUD operations for annotation queues.""" + queue_name = f"test_queue_{uuid.uuid4().hex[:8]}" + queue_id = uuid.uuid4() + + # Test creation + queue = await async_client.create_annotation_queue( + name=queue_name, description="Test queue", queue_id=queue_id + ) + assert queue.name == queue_name + assert queue.id == queue_id + + # Test reading + read_queue = await async_client.read_annotation_queue(queue_id) + assert read_queue.id == queue_id + assert read_queue.name == queue_name + + # Test updating + new_name = f"updated_{queue_name}" + await async_client.update_annotation_queue( + queue_id=queue_id, name=new_name, description="Updated description" + ) + + updated_queue = await async_client.read_annotation_queue(queue_id) + assert updated_queue.name == new_name + + # Test deletion + await async_client.delete_annotation_queue(queue_id) + + # Verify deletion + queues = [ + queue + async for queue in async_client.list_annotation_queues(queue_ids=[queue_id]) + ] + assert len(queues) == 0 + + +@pytest.mark.asyncio +async def test_list_annotation_queues(async_client: AsyncClient): + """Test listing and filtering annotation queues.""" + queue_names = [f"test_queue_{i}_{uuid.uuid4().hex[:8]}" for i in range(3)] + queue_ids = [] + + try: + # Create test queues + for name in queue_names: + queue = await async_client.create_annotation_queue( + name=name, description="Test queue" + ) + queue_ids.append(queue.id) + + # Test listing with various filters + queues = [ + queue + async for queue in async_client.list_annotation_queues( + queue_ids=queue_ids[:2], limit=2 + ) + ] + assert len(queues) == 2 + + # Test name filter + queues = [ + queue + async for queue in async_client.list_annotation_queues(name=queue_names[0]) + ] + assert len(queues) == 1 + assert queues[0].name == queue_names[0] + + # Test name_contains filter + queues = [ + queue + async for queue in async_client.list_annotation_queues( + name_contains="test_queue" + ) + ] + assert len(queues) >= 3 # Could be more if other tests left queues + + finally: + # Clean up + for queue_id in queue_ids: + await async_client.delete_annotation_queue(queue_id) + + +@pytest.mark.asyncio +async def test_annotation_queue_runs(async_client: AsyncClient): + """Test managing runs within an annotation queue.""" + queue_name = f"test_queue_{uuid.uuid4().hex[:8]}" + project_name = f"test_project_{uuid.uuid4().hex[:8]}" + + # Create a queue + queue = await async_client.create_annotation_queue( + name=queue_name, description="Test queue" + ) + + # Create some test runs + run_ids = [uuid.uuid4() for _ in range(3)] + for i in range(3): + await async_client.create_run( + name=f"test_run_{i}", + inputs={"input": f"test_{i}"}, + run_type="llm", + project_name=project_name, + start_time=datetime.datetime.now(datetime.timezone.utc), + id=run_ids[i], + ) + + # Add runs to queue + await async_client.add_runs_to_annotation_queue(queue_id=queue.id, run_ids=run_ids) + + # Test getting run at index + run_info = await async_client.get_run_from_annotation_queue( + queue_id=queue.id, index=0 + ) + assert run_info.id in run_ids + + # Test deleting a run from queue + await async_client.delete_run_from_annotation_queue( + queue_id=queue.id, run_id=run_ids[2] + ) + + # Test that runs are deleted + run = await async_client.get_run_from_annotation_queue(queue_id=queue.id, index=0) + assert run.id == run_ids[1] + + # Clean up + await async_client.delete_annotation_queue(queue.id) diff --git a/python/tests/integration_tests/test_client.py b/python/tests/integration_tests/test_client.py index 3bcd9d04c..d8f78743d 100644 --- a/python/tests/integration_tests/test_client.py +++ b/python/tests/integration_tests/test_client.py @@ -1983,3 +1983,129 @@ def test_update_examples_multipart(langchain_client: Client) -> None: # Clean up langchain_client.delete_dataset(dataset_id=dataset.id) + + +def test_annotation_queue_crud(langchain_client: Client): + """Test basic CRUD operations for annotation queues.""" + queue_name = f"test_queue_{uuid.uuid4().hex[:8]}" + queue_id = uuid.uuid4() + + # Test creation + queue = langchain_client.create_annotation_queue( + name=queue_name, description="Test queue", queue_id=queue_id + ) + assert queue.name == queue_name + assert queue.id == queue_id + + # Test reading + read_queue = langchain_client.read_annotation_queue(queue_id) + assert read_queue.id == queue_id + assert read_queue.name == queue_name + + # Test updating + new_name = f"updated_{queue_name}" + langchain_client.update_annotation_queue( + queue_id=queue_id, name=new_name, description="Updated description" + ) + + updated_queue = langchain_client.read_annotation_queue(queue_id) + assert updated_queue.name == new_name + + # Test deletion + langchain_client.delete_annotation_queue(queue_id) + + # Verify deletion + queues = list(langchain_client.list_annotation_queues(queue_ids=[queue_id])) + assert len(queues) == 0 + + +def test_list_annotation_queues(langchain_client: Client): + """Test listing and filtering annotation queues.""" + queue_names = [f"test_queue_{i}_{uuid.uuid4().hex[:8]}" for i in range(3)] + queue_ids = [] + + try: + # Create test queues + for name in queue_names: + queue = langchain_client.create_annotation_queue( + name=name, description="Test queue" + ) + queue_ids.append(queue.id) + + # Test listing with various filters + queues = list( + langchain_client.list_annotation_queues(queue_ids=queue_ids[:2], limit=2) + ) + assert len(queues) == 2 + + # Test name filter + queues = list(langchain_client.list_annotation_queues(name=queue_names[0])) + assert len(queues) == 1 + assert queues[0].name == queue_names[0] + + # Test name_contains filter + queues = list( + langchain_client.list_annotation_queues(name_contains="test_queue") + ) + assert len(queues) >= 3 # Could be more if other tests left queues + + finally: + # Clean up + for queue_id in queue_ids: + langchain_client.delete_annotation_queue(queue_id) + + +def test_annotation_queue_runs(langchain_client: Client): + """Test managing runs within an annotation queue.""" + queue_name = f"test_queue_{uuid.uuid4().hex[:8]}" + project_name = f"test_project_{uuid.uuid4().hex[:8]}" + + # Create a queue + queue = langchain_client.create_annotation_queue( + name=queue_name, description="Test queue" + ) + + # Create some test runs + run_ids = [uuid.uuid4() for _ in range(3)] + for i in range(3): + langchain_client.create_run( + name=f"test_run_{i}", + inputs={"input": f"test_{i}"}, + run_type="llm", + project_name=project_name, + start_time=datetime.datetime.now(datetime.timezone.utc), + id=run_ids[i], + ) + + def _get_run(run_id: ID_TYPE, has_end: bool = False) -> bool: + try: + r = langchain_client.read_run(run_id) # type: ignore + if has_end: + return r.end_time is not None + return True + except LangSmithError: + return False + + wait_for(lambda: _get_run(run_ids[0])) + wait_for(lambda: _get_run(run_ids[1])) + wait_for(lambda: _get_run(run_ids[2])) + # Add runs to queue + langchain_client.add_runs_to_annotation_queue(queue_id=queue.id, run_ids=run_ids) + + # Test getting run at index + run_info = langchain_client.get_run_from_annotation_queue( + queue_id=queue.id, index=0 + ) + assert run_info.id in run_ids + + # Test deleting a run from queue + langchain_client.delete_run_from_annotation_queue( + queue_id=queue.id, run_id=run_ids[2] + ) + + # Test that runs are deleted + run = langchain_client.get_run_from_annotation_queue(queue_id=queue.id, index=0) + assert run.id == run_ids[1] + + # Clean up + langchain_client.delete_annotation_queue(queue.id) From 878e74807f5236b88b5ea6b5c8ad5e0ef460db95 Mon Sep 17 00:00:00 2001 From: isaac hershenson Date: Wed, 8 Jan 2025 11:09:05 -0800 Subject: [PATCH 4/4] fmt --- python/langsmith/async_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/langsmith/async_client.py b/python/langsmith/async_client.py index 51cb594fa..0be62924d 100644 --- a/python/langsmith/async_client.py +++ b/python/langsmith/async_client.py @@ -27,6 +27,7 @@ ID_TYPE = Union[uuid.UUID, str] + class AsyncClient: """Async Client for interacting with the LangSmith API."""