From 5c7ba19723d457d32be6ff4d1a0ba4190af6c49f Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Wed, 23 Oct 2024 13:18:39 -0700 Subject: [PATCH 01/39] benchmark create_run --- python/bench/create_run.py | 116 +++++++++++++++++ python/tests/integration_tests/test_client.py | 123 ++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 python/bench/create_run.py diff --git a/python/bench/create_run.py b/python/bench/create_run.py new file mode 100644 index 000000000..70baf563f --- /dev/null +++ b/python/bench/create_run.py @@ -0,0 +1,116 @@ +import statistics +import time +from contextlib import contextmanager +from typing import Dict +from uuid import uuid4 + +import pytest +import pytest_socket +from unittest.mock import patch, Mock + +from langsmith.client import Client + + +def create_large_json(length: int) -> Dict: + """Create a large JSON object for benchmarking purposes.""" + large_array = [ + { + "index": i, + "data": f"This is element number {i}", + "nested": {"id": i, "value": f"Nested value for element {i}"}, + } + for i in range(length) + ] + + return { + "name": "Huge JSON", + "description": "This is a very large JSON object for benchmarking purposes.", + "array": large_array, + "metadata": { + "created_at": "2024-10-22T19:00:00Z", + "author": "Python Program", + "version": 1.0, + }, + } + + +def create_run_data(run_id: str, json_size: int) -> Dict: + """Create a single run data object.""" + return { + "name": "Run Name", + "id": run_id, + "run_type": "chain", + "inputs": create_large_json(json_size), + "outputs": create_large_json(json_size), + "extra": {"extra_data": "value"}, + "trace_id": "trace_id", + "dotted_order": "1.1", + "tags": ["tag1", "tag2"], + "session_name": "Session Name", + } + + +@contextmanager +def timer(): + """Simple context manager to measure execution time.""" + start = time.perf_counter() + yield + end = time.perf_counter() + print(end - start) + return end - start + + +def benchmark_run_creation(num_runs: int, json_size: int, samples: int = 10) -> Dict: + """ + Benchmark run creation with specified parameters. + Returns timing statistics. + """ + timings = [] + + for _ in range(samples): + runs = [create_run_data(str(uuid4()), json_size) for i in range(num_runs)] + + mock_session = Mock() + mock_response = Mock() + mock_response.status_code = 202 + mock_response.text = "Accepted" + mock_response.json.return_value = {"status": "success"} + mock_session.request.return_value = mock_response + client = Client(session=mock_session, api_key="xxx") + + start = time.perf_counter() + for run in runs: + client.create_run(**run) + + # wait for client.tracing_queue to be empty + client.tracing_queue.join() + + elapsed = time.perf_counter() - start + + timings.append(elapsed) + + return { + "mean": statistics.mean(timings), + "median": statistics.median(timings), + "stdev": statistics.stdev(timings) if len(timings) > 1 else 0, + "min": min(timings), + "max": max(timings), + } + + +@pytest.mark.parametrize("json_size", [1_000, 5_000]) +@pytest.mark.parametrize("num_runs", [500, 1_000]) +def test_benchmark_runs(json_size: int, num_runs: int): + """ + Run benchmarks with different combinations of parameters and report results. + """ + + results = benchmark_run_creation(num_runs=num_runs, json_size=json_size) + + print(f"\nBenchmark Results for {num_runs} runs with JSON size {json_size}:") + print(f"Mean time: {results['mean']:.4f} seconds") + print(f"Median time: {results['median']:.4f} seconds") + print(f"Std Dev: {results['stdev']:.4f} seconds") + print(f"Min time: {results['min']:.4f} seconds") + print(f"Max time: {results['max']:.4f} seconds") + print(f"Throughput: {num_runs / results['mean']:.2f} runs/second") diff --git a/python/tests/integration_tests/test_client.py b/python/tests/integration_tests/test_client.py index be4f27147..d3cc1b177 100644 --- a/python/tests/integration_tests/test_client.py +++ b/python/tests/integration_tests/test_client.py @@ -7,6 +7,7 @@ import string import sys import time +import warnings from datetime import timedelta from typing import Any, Callable, Dict from uuid import uuid4 @@ -629,6 +630,16 @@ def test_batch_ingest_runs( later_time = ( datetime.datetime.now(datetime.timezone.utc) + timedelta(seconds=1) ).strftime("%Y%m%dT%H%M%S%fZ") + + """ + Here we create: + - run 1: a top level trace with inputs and outputs + - run 3: a top level trace with an error with inputs and outputs + - run 2: a child of run 1 with inputs, no outputs + and we update: + - run 2 (the child): to add outputs + """ + runs_to_create = [ { "id": str(trace_id), @@ -716,6 +727,118 @@ def test_batch_ingest_runs( assert run3.error == "error" +""" +Multipart partitions: +- num created: [0], [1], >1 +- num updated: [0], [1], >1 +- num created + num updated: [0], [1], >1 +- individual id: created only, updated only, both +- [updated is root trace], [updated is run] + +Error cases: +- dual created +- dual updated +- created and dual updated [? maybe not an error] +- dual created and single updated +- retry doesn't fail +""" + + +def test_multipart_ingest_runs_empty(langchain_client: Client) -> None: + + runs_to_create: list[dict] = [] + runs_to_update: list[dict] = [] + + # make sure no warnings logged + with warnings.catch_warnings(): + warnings.simplefilter("error") + + langchain_client.multipart_ingest_runs( + create=runs_to_create, update=runs_to_update + ) + + +def test_multipart_ingest_runs_create_then_update(langchain_client: Client) -> None: + _session = "__test_multipart_ingest_runs_create_then_update" + + trace_a_id = uuid4() + current_time = datetime.datetime.now(datetime.timezone.utc).strftime( + "%Y%m%dT%H%M%S%fZ" + ) + + runs_to_create: list[dict] = [ + { + "id": str(trace_a_id), + "session_name": _session, + "name": "trace a root", + "run_type": "chain", + "dotted_order": f"{current_time}{str(trace_a_id)}", + "trace_id": str(trace_a_id), + "inputs": {"input1": 1, "input2": 2}, + } + ] + + # make sure no warnings logged + with warnings.catch_warnings(): + warnings.simplefilter("error") + + langchain_client.multipart_ingest_runs(create=runs_to_create, update=[]) + + runs_to_update: list[dict] = [ + { + "id": str(trace_a_id), + "dotted_order": f"{current_time}{str(trace_a_id)}", + "trace_id": str(trace_a_id), + "outputs": {"output1": 3, "output2": 4}, + } + ] + with warnings.catch_warnings(): + warnings.simplefilter("error") + + langchain_client.multipart_ingest_runs(create=[], update=runs_to_update) + + +def test_multipart_ingest_runs_update_then_create(langchain_client: Client) -> None: + _session = "__test_multipart_ingest_runs_update_then_create" + + trace_a_id = uuid4() + current_time = datetime.datetime.now(datetime.timezone.utc).strftime( + "%Y%m%dT%H%M%S%fZ" + ) + + runs_to_update: list[dict] = [ + { + "id": str(trace_a_id), + "dotted_order": f"{current_time}{str(trace_a_id)}", + "trace_id": str(trace_a_id), + "outputs": {"output1": 3, "output2": 4}, + } + ] + + # make sure no warnings logged + with warnings.catch_warnings(): + warnings.simplefilter("error") + + langchain_client.multipart_ingest_runs(create=[], update=runs_to_update) + + runs_to_create: list[dict] = [ + { + "id": str(trace_a_id), + "session_name": _session, + "name": "trace a root", + "run_type": "chain", + "dotted_order": f"{current_time}{str(trace_a_id)}", + "trace_id": str(trace_a_id), + "inputs": {"input1": 1, "input2": 2}, + } + ] + + with warnings.catch_warnings(): + warnings.simplefilter("error") + + langchain_client.multipart_ingest_runs(create=runs_to_create, update=[]) + + @freeze_time("2023-01-01") def test_get_info() -> None: langchain_client = Client(api_key="not-a-real-key") From 2e52e940d2f02cf8dd6ebf2d9f3cdb32411a468f Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Wed, 23 Oct 2024 13:19:11 -0700 Subject: [PATCH 02/39] x --- python/tests/integration_tests/test_client.py | 123 ------------------ 1 file changed, 123 deletions(-) diff --git a/python/tests/integration_tests/test_client.py b/python/tests/integration_tests/test_client.py index d3cc1b177..be4f27147 100644 --- a/python/tests/integration_tests/test_client.py +++ b/python/tests/integration_tests/test_client.py @@ -7,7 +7,6 @@ import string import sys import time -import warnings from datetime import timedelta from typing import Any, Callable, Dict from uuid import uuid4 @@ -630,16 +629,6 @@ def test_batch_ingest_runs( later_time = ( datetime.datetime.now(datetime.timezone.utc) + timedelta(seconds=1) ).strftime("%Y%m%dT%H%M%S%fZ") - - """ - Here we create: - - run 1: a top level trace with inputs and outputs - - run 3: a top level trace with an error with inputs and outputs - - run 2: a child of run 1 with inputs, no outputs - and we update: - - run 2 (the child): to add outputs - """ - runs_to_create = [ { "id": str(trace_id), @@ -727,118 +716,6 @@ def test_batch_ingest_runs( assert run3.error == "error" -""" -Multipart partitions: -- num created: [0], [1], >1 -- num updated: [0], [1], >1 -- num created + num updated: [0], [1], >1 -- individual id: created only, updated only, both -- [updated is root trace], [updated is run] - -Error cases: -- dual created -- dual updated -- created and dual updated [? maybe not an error] -- dual created and single updated -- retry doesn't fail -""" - - -def test_multipart_ingest_runs_empty(langchain_client: Client) -> None: - - runs_to_create: list[dict] = [] - runs_to_update: list[dict] = [] - - # make sure no warnings logged - with warnings.catch_warnings(): - warnings.simplefilter("error") - - langchain_client.multipart_ingest_runs( - create=runs_to_create, update=runs_to_update - ) - - -def test_multipart_ingest_runs_create_then_update(langchain_client: Client) -> None: - _session = "__test_multipart_ingest_runs_create_then_update" - - trace_a_id = uuid4() - current_time = datetime.datetime.now(datetime.timezone.utc).strftime( - "%Y%m%dT%H%M%S%fZ" - ) - - runs_to_create: list[dict] = [ - { - "id": str(trace_a_id), - "session_name": _session, - "name": "trace a root", - "run_type": "chain", - "dotted_order": f"{current_time}{str(trace_a_id)}", - "trace_id": str(trace_a_id), - "inputs": {"input1": 1, "input2": 2}, - } - ] - - # make sure no warnings logged - with warnings.catch_warnings(): - warnings.simplefilter("error") - - langchain_client.multipart_ingest_runs(create=runs_to_create, update=[]) - - runs_to_update: list[dict] = [ - { - "id": str(trace_a_id), - "dotted_order": f"{current_time}{str(trace_a_id)}", - "trace_id": str(trace_a_id), - "outputs": {"output1": 3, "output2": 4}, - } - ] - with warnings.catch_warnings(): - warnings.simplefilter("error") - - langchain_client.multipart_ingest_runs(create=[], update=runs_to_update) - - -def test_multipart_ingest_runs_update_then_create(langchain_client: Client) -> None: - _session = "__test_multipart_ingest_runs_update_then_create" - - trace_a_id = uuid4() - current_time = datetime.datetime.now(datetime.timezone.utc).strftime( - "%Y%m%dT%H%M%S%fZ" - ) - - runs_to_update: list[dict] = [ - { - "id": str(trace_a_id), - "dotted_order": f"{current_time}{str(trace_a_id)}", - "trace_id": str(trace_a_id), - "outputs": {"output1": 3, "output2": 4}, - } - ] - - # make sure no warnings logged - with warnings.catch_warnings(): - warnings.simplefilter("error") - - langchain_client.multipart_ingest_runs(create=[], update=runs_to_update) - - runs_to_create: list[dict] = [ - { - "id": str(trace_a_id), - "session_name": _session, - "name": "trace a root", - "run_type": "chain", - "dotted_order": f"{current_time}{str(trace_a_id)}", - "trace_id": str(trace_a_id), - "inputs": {"input1": 1, "input2": 2}, - } - ] - - with warnings.catch_warnings(): - warnings.simplefilter("error") - - langchain_client.multipart_ingest_runs(create=runs_to_create, update=[]) - - @freeze_time("2023-01-01") def test_get_info() -> None: langchain_client = Client(api_key="not-a-real-key") From 45e2c47767c823d7365ecd992ba5ea1ca4c06421 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Wed, 23 Oct 2024 13:22:20 -0700 Subject: [PATCH 03/39] x --- python/bench/create_run.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/python/bench/create_run.py b/python/bench/create_run.py index 70baf563f..702ff2a2f 100644 --- a/python/bench/create_run.py +++ b/python/bench/create_run.py @@ -1,12 +1,10 @@ import statistics import time -from contextlib import contextmanager from typing import Dict +from unittest.mock import Mock from uuid import uuid4 import pytest -import pytest_socket -from unittest.mock import patch, Mock from langsmith.client import Client @@ -50,16 +48,6 @@ def create_run_data(run_id: str, json_size: int) -> Dict: } -@contextmanager -def timer(): - """Simple context manager to measure execution time.""" - start = time.perf_counter() - yield - end = time.perf_counter() - print(end - start) - return end - start - - def benchmark_run_creation(num_runs: int, json_size: int, samples: int = 10) -> Dict: """ Benchmark run creation with specified parameters. From 9ff3eee1a20e34ab5067987c0b2c572a0b8e71bb Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Thu, 24 Oct 2024 13:05:11 -0700 Subject: [PATCH 04/39] debug --- python/langsmith/client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 75913e87d..4deb26097 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -5841,6 +5841,7 @@ def _ensure_ingest_config( def _tracing_control_thread_func(client_ref: weakref.ref[Client]) -> None: + logger.debug("Starting tracing control thread") client = client_ref() if client is None: return @@ -5900,6 +5901,7 @@ def _tracing_sub_thread_func( except BaseException as e: logger.debug("Error in tracing control thread: %s", e) return + logger.debug("Starting tracing sub thread") tracing_queue = client.tracing_queue assert tracing_queue is not None batch_ingest_config = _ensure_ingest_config(client.info) From 67a4e683aa14ac9274ff2a3ad329f09850c07031 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Fri, 25 Oct 2024 14:10:21 -0700 Subject: [PATCH 05/39] x --- python/bench/create_run.py | 43 +++++++++++++++++++++++++++++--------- python/langsmith/client.py | 12 ++++++++--- python/langsmith/utils.py | 6 +++--- 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/python/bench/create_run.py b/python/bench/create_run.py index 702ff2a2f..6f7261952 100644 --- a/python/bench/create_run.py +++ b/python/bench/create_run.py @@ -1,12 +1,13 @@ +import weakref +import logging import statistics import time +from queue import PriorityQueue from typing import Dict from unittest.mock import Mock from uuid import uuid4 -import pytest - -from langsmith.client import Client +from langsmith.client import Client, _tracing_control_thread_func def create_large_json(length: int) -> Dict: @@ -48,33 +49,50 @@ def create_run_data(run_id: str, json_size: int) -> Dict: } -def benchmark_run_creation(num_runs: int, json_size: int, samples: int = 10) -> Dict: +def benchmark_run_creation(num_runs: int, json_size: int, samples: int = 1) -> Dict: """ Benchmark run creation with specified parameters. Returns timing statistics. """ timings = [] - for _ in range(samples): - runs = [create_run_data(str(uuid4()), json_size) for i in range(num_runs)] + benchmark_thread = True + real_session = True + if real_session: + mock_session = None + else: mock_session = Mock() mock_response = Mock() mock_response.status_code = 202 mock_response.text = "Accepted" mock_response.json.return_value = {"status": "success"} mock_session.request.return_value = mock_response + if benchmark_thread: + client = Client(session=mock_session, api_key="xxx", auto_batch_tracing=False) + client.tracing_queue = PriorityQueue() + else: client = Client(session=mock_session, api_key="xxx") + for _ in range(samples): + runs = [create_run_data(str(uuid4()), json_size) for i in range(num_runs)] + start = time.perf_counter() for run in runs: client.create_run(**run) # wait for client.tracing_queue to be empty - client.tracing_queue.join() + if benchmark_thread: + # reset the timer + start = time.perf_counter() + _tracing_control_thread_func(weakref.ref(client), benchmark_mode=True) + else: + client.tracing_queue.join() elapsed = time.perf_counter() - start + del runs + timings.append(elapsed) return { @@ -86,13 +104,10 @@ def benchmark_run_creation(num_runs: int, json_size: int, samples: int = 10) -> } -@pytest.mark.parametrize("json_size", [1_000, 5_000]) -@pytest.mark.parametrize("num_runs", [500, 1_000]) def test_benchmark_runs(json_size: int, num_runs: int): """ Run benchmarks with different combinations of parameters and report results. """ - results = benchmark_run_creation(num_runs=num_runs, json_size=json_size) print(f"\nBenchmark Results for {num_runs} runs with JSON size {json_size}:") @@ -102,3 +117,11 @@ def test_benchmark_runs(json_size: int, num_runs: int): print(f"Min time: {results['min']:.4f} seconds") print(f"Max time: {results['max']:.4f} seconds") print(f"Throughput: {num_runs / results['mean']:.2f} runs/second") + + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + test_benchmark_runs(1_000, 500) + # test_benchmark_runs(1_000, 1_000) + # test_benchmark_runs(5_000, 500) + # test_benchmark_runs(5_000, 1_000) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 4deb26097..6d78a8684 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -329,8 +329,9 @@ def close_session(session: requests.Session) -> None: session : Session The session to close. """ - logger.debug("Closing Client.session") - session.close() + if session.open: + logger.debug("Closing Client.session") + session.close() def _validate_api_key_if_hosted(api_url: str, api_key: Optional[str]) -> None: @@ -594,6 +595,7 @@ def __init__( self._web_url = web_url self._tenant_id: Optional[uuid.UUID] = None # Create a session and register a finalizer to close it + logger.debug("Creating Client.session") session_ = session if session else requests.Session() self.session = session_ self._info = ( @@ -5840,7 +5842,9 @@ def _ensure_ingest_config( return default_config -def _tracing_control_thread_func(client_ref: weakref.ref[Client]) -> None: +def _tracing_control_thread_func( + client_ref: weakref.ref[Client], benchmark_mode: bool = False +) -> None: logger.debug("Starting tracing control thread") client = client_ref() if client is None: @@ -5863,6 +5867,8 @@ def _tracing_control_thread_func(client_ref: weakref.ref[Client]) -> None: threading.main_thread().is_alive() # or we're the only remaining reference to the client and sys.getrefcount(client) > num_known_refs + len(sub_threads) + or benchmark_mode + and not tracing_queue.empty() ): for thread in sub_threads: if not thread.is_alive(): diff --git a/python/langsmith/utils.py b/python/langsmith/utils.py index 4be0ce8fd..1a59ef19b 100644 --- a/python/langsmith/utils.py +++ b/python/langsmith/utils.py @@ -461,7 +461,7 @@ def filter_logs( for filter in filters: try: logger.removeFilter(filter) - except BaseException: + except ValueError: _LOGGER.warning("Failed to remove filter") @@ -551,7 +551,7 @@ def _middle_copy( if copier is not None: try: return copier(memo) - except BaseException: + except TypeError: pass if _depth >= max_depth: return val @@ -584,7 +584,7 @@ def deepish_copy(val: T) -> T: memo: Dict[int, Any] = {} try: return copy.deepcopy(val, memo) - except BaseException as e: + except TypeError as e: # Generators, locks, etc. cannot be copied # and raise a TypeError (mentioning pickling, since the dunder methods) # are re-used for copying. We'll try to do a compromise and copy From 243df05a2540e9271c3cfd1ae8e825ad6fc21fe4 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Fri, 25 Oct 2024 17:08:15 -0700 Subject: [PATCH 06/39] x --- python/Makefile | 9 ++++++ python/bench/create_run.py | 56 ++++++++++++++++++++++---------------- python/langsmith/client.py | 5 ++-- 3 files changed, 43 insertions(+), 27 deletions(-) diff --git a/python/Makefile b/python/Makefile index 3f8bc2782..f7ca1f502 100644 --- a/python/Makefile +++ b/python/Makefile @@ -13,6 +13,15 @@ benchmark-fast: rm -f $(OUTPUT) poetry run python -m bench -o $(OUTPUT) --fast +PROFILE_NAME ?= output + +profile-background-thread: + mkdir -p profiles + poetry run python -m cProfile -o profiles/$(PROFILE_NAME).prof bench/create_run.py + +view-profile: + poetry run snakeviz profiles/${PROFILE_NAME}.prof + tests: env \ -u LANGCHAIN_PROJECT \ diff --git a/python/bench/create_run.py b/python/bench/create_run.py index 6f7261952..bff79a6c5 100644 --- a/python/bench/create_run.py +++ b/python/bench/create_run.py @@ -1,13 +1,14 @@ -import weakref import logging import statistics import time +import weakref from queue import PriorityQueue from typing import Dict from unittest.mock import Mock from uuid import uuid4 -from langsmith.client import Client, _tracing_control_thread_func +from langsmith._internal._background_thread import tracing_control_thread_func +from langsmith.client import Client def create_large_json(length: int) -> Dict: @@ -49,30 +50,31 @@ def create_run_data(run_id: str, json_size: int) -> Dict: } -def benchmark_run_creation(num_runs: int, json_size: int, samples: int = 1) -> Dict: +def mock_session() -> Mock: + """Create a mock session object.""" + mock_session = Mock() + mock_response = Mock() + mock_response.status_code = 202 + mock_response.text = "Accepted" + mock_response.json.return_value = {"status": "success"} + mock_session.request.return_value = mock_response + return mock_session + + +def benchmark_run_creation( + *, num_runs: int, json_size: int, samples: int, benchmark_thread: bool +) -> Dict: """ Benchmark run creation with specified parameters. Returns timing statistics. """ timings = [] - benchmark_thread = True - real_session = True - - if real_session: - mock_session = None - else: - mock_session = Mock() - mock_response = Mock() - mock_response.status_code = 202 - mock_response.text = "Accepted" - mock_response.json.return_value = {"status": "success"} - mock_session.request.return_value = mock_response if benchmark_thread: - client = Client(session=mock_session, api_key="xxx", auto_batch_tracing=False) + client = Client(session=mock_session(), api_key="xxx", auto_batch_tracing=False) client.tracing_queue = PriorityQueue() else: - client = Client(session=mock_session, api_key="xxx") + client = Client(session=mock_session(), api_key="xxx") for _ in range(samples): runs = [create_run_data(str(uuid4()), json_size) for i in range(num_runs)] @@ -85,8 +87,10 @@ def benchmark_run_creation(num_runs: int, json_size: int, samples: int = 1) -> D if benchmark_thread: # reset the timer start = time.perf_counter() - _tracing_control_thread_func(weakref.ref(client), benchmark_mode=True) + tracing_control_thread_func(weakref.ref(client)) else: + if client.tracing_queue is None: + raise ValueError("Tracing queue is None") client.tracing_queue.join() elapsed = time.perf_counter() - start @@ -104,11 +108,18 @@ def benchmark_run_creation(num_runs: int, json_size: int, samples: int = 1) -> D } -def test_benchmark_runs(json_size: int, num_runs: int): +def test_benchmark_runs( + *, json_size: int, num_runs: int, samples: int, benchmark_thread: bool +): """ Run benchmarks with different combinations of parameters and report results. """ - results = benchmark_run_creation(num_runs=num_runs, json_size=json_size) + results = benchmark_run_creation( + num_runs=num_runs, + json_size=json_size, + samples=samples, + benchmark_thread=benchmark_thread, + ) print(f"\nBenchmark Results for {num_runs} runs with JSON size {json_size}:") print(f"Mean time: {results['mean']:.4f} seconds") @@ -121,7 +132,4 @@ def test_benchmark_runs(json_size: int, num_runs: int): if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) - test_benchmark_runs(1_000, 500) - # test_benchmark_runs(1_000, 1_000) - # test_benchmark_runs(5_000, 500) - # test_benchmark_runs(5_000, 1_000) + test_benchmark_runs(json_size=1_000, num_runs=500, samples=1, benchmark_thread=True) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index f930181fb..56018ae3f 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -196,9 +196,8 @@ def close_session(session: requests.Session) -> None: session : Session The session to close. """ - if session.open: - logger.debug("Closing Client.session") - session.close() + logger.debug("Closing Client.session") + session.close() def _validate_api_key_if_hosted(api_url: str, api_key: Optional[str]) -> None: From bd827fbbbd92e1f63d77b4270426d03cbd32463b Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Fri, 25 Oct 2024 17:21:05 -0700 Subject: [PATCH 07/39] x --- python/langsmith/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/langsmith/utils.py b/python/langsmith/utils.py index 1a59ef19b..4be0ce8fd 100644 --- a/python/langsmith/utils.py +++ b/python/langsmith/utils.py @@ -461,7 +461,7 @@ def filter_logs( for filter in filters: try: logger.removeFilter(filter) - except ValueError: + except BaseException: _LOGGER.warning("Failed to remove filter") @@ -551,7 +551,7 @@ def _middle_copy( if copier is not None: try: return copier(memo) - except TypeError: + except BaseException: pass if _depth >= max_depth: return val @@ -584,7 +584,7 @@ def deepish_copy(val: T) -> T: memo: Dict[int, Any] = {} try: return copy.deepcopy(val, memo) - except TypeError as e: + except BaseException as e: # Generators, locks, etc. cannot be copied # and raise a TypeError (mentioning pickling, since the dunder methods) # are re-used for copying. We'll try to do a compromise and copy From efd0ab87f0b6035b80f6c66cdbe7619e52fa9a0d Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Fri, 25 Oct 2024 17:22:10 -0700 Subject: [PATCH 08/39] x --- python/langsmith/_internal/_background_thread.py | 1 - python/langsmith/client.py | 1 - 2 files changed, 2 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 0e6a7bbfe..525a3513c 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -165,7 +165,6 @@ def _tracing_sub_thread_func( except BaseException as e: logger.debug("Error in tracing control thread: %s", e) return - logger.debug("Starting tracing sub thread") tracing_queue = client.tracing_queue assert tracing_queue is not None batch_ingest_config = _ensure_ingest_config(client.info) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 56018ae3f..4a1601c44 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -446,7 +446,6 @@ def __init__( self._web_url = web_url self._tenant_id: Optional[uuid.UUID] = None # Create a session and register a finalizer to close it - logger.debug("Creating Client.session") session_ = session if session else requests.Session() self.session = session_ self._info = ( From 2a82581283b0da9b731d79492911d583c7fc5b81 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Fri, 25 Oct 2024 17:23:52 -0700 Subject: [PATCH 09/39] x --- python/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/python/.gitignore b/python/.gitignore index 1fcb1529f..e0ab99769 100644 --- a/python/.gitignore +++ b/python/.gitignore @@ -1 +1,2 @@ out +profiles From 6b493762e54bcbd490aa6626c80a8d93dc88f3e2 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Fri, 25 Oct 2024 17:51:02 -0700 Subject: [PATCH 10/39] x --- python/bench/create_run.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/python/bench/create_run.py b/python/bench/create_run.py index bff79a6c5..179fca874 100644 --- a/python/bench/create_run.py +++ b/python/bench/create_run.py @@ -7,7 +7,10 @@ from unittest.mock import Mock from uuid import uuid4 -from langsmith._internal._background_thread import tracing_control_thread_func +from langsmith._internal._background_thread import ( + _tracing_thread_drain_queue, + _tracing_thread_handle_batch, +) from langsmith.client import Client @@ -61,6 +64,17 @@ def mock_session() -> Mock: return mock_session +def process_queue(client: Client) -> None: + if client.tracing_queue is None: + raise ValueError("Tracing queue is None") + while next_batch := _tracing_thread_drain_queue( + client.tracing_queue, limit=100, block=False + ): + _tracing_thread_handle_batch( + client, client.tracing_queue, next_batch, use_multipart=True + ) + + def benchmark_run_creation( *, num_runs: int, json_size: int, samples: int, benchmark_thread: bool ) -> Dict: @@ -76,6 +90,9 @@ def benchmark_run_creation( else: client = Client(session=mock_session(), api_key="xxx") + if client.tracing_queue is None: + raise ValueError("Tracing queue is None") + for _ in range(samples): runs = [create_run_data(str(uuid4()), json_size) for i in range(num_runs)] @@ -87,10 +104,8 @@ def benchmark_run_creation( if benchmark_thread: # reset the timer start = time.perf_counter() - tracing_control_thread_func(weakref.ref(client)) + process_queue(client) else: - if client.tracing_queue is None: - raise ValueError("Tracing queue is None") client.tracing_queue.join() elapsed = time.perf_counter() - start From df8be3ba5224f7e1f8afc35d6ec44a34ba6eda2b Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Fri, 25 Oct 2024 18:43:33 -0700 Subject: [PATCH 11/39] x --- python/bench/create_run.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/python/bench/create_run.py b/python/bench/create_run.py index 179fca874..26cecd8db 100644 --- a/python/bench/create_run.py +++ b/python/bench/create_run.py @@ -64,6 +64,15 @@ def mock_session() -> Mock: return mock_session +def create_dummy_data(json_size, num_runs) -> list: + return [create_run_data(str(uuid4()), json_size) for i in range(num_runs)] + + +def create_runs(runs: list, client: Client) -> None: + for run in runs: + client.create_run(**run) + + def process_queue(client: Client) -> None: if client.tracing_queue is None: raise ValueError("Tracing queue is None") @@ -94,11 +103,11 @@ def benchmark_run_creation( raise ValueError("Tracing queue is None") for _ in range(samples): - runs = [create_run_data(str(uuid4()), json_size) for i in range(num_runs)] + runs = create_dummy_data(json_size, num_runs) start = time.perf_counter() - for run in runs: - client.create_run(**run) + + create_runs(runs, client) # wait for client.tracing_queue to be empty if benchmark_thread: From 5b7e3d79a16da0cb61abbc01a66bc252cee11991 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Fri, 25 Oct 2024 19:56:22 -0700 Subject: [PATCH 12/39] compute parts instead of deepcopy --- .../langsmith/_internal/_background_thread.py | 33 ++++++- python/langsmith/_internal/_multipart.py | 90 ++++++++++++++++++ python/langsmith/client.py | 95 +++++++------------ 3 files changed, 152 insertions(+), 66 deletions(-) create mode 100644 python/langsmith/_internal/_multipart.py diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 525a3513c..f6f11dbcc 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -10,6 +10,8 @@ TYPE_CHECKING, Any, List, + Optional, + Union, ) from langsmith import schemas as ls_schemas @@ -18,13 +20,33 @@ _AUTO_SCALE_UP_NTHREADS_LIMIT, _AUTO_SCALE_UP_QSIZE_TRIGGER, ) +from langsmith._internal._multipart import join_multipart_parts_and_context if TYPE_CHECKING: - from langsmith.client import Client + from langsmith.client import Client, MultipartParts logger = logging.getLogger("langsmith.client") +@dataclass(order=True) +class SerializedRunParts: + """ + A dataclass to hold the serialized parts of a run for sending to the + multipart endpoint + """ + + trace_id: str + id: str + inputs: bytes + outputs: bytes + events: bytes + attachments: Optional[List[bytes]] + + # the run without inputs,outputs,events,attachments + # note this also includes trace_id and id + remaining_run: bytes + + @dataclass(order=True) class TracingQueueItem: """An item in the tracing queue. @@ -37,7 +59,7 @@ class TracingQueueItem: priority: str action: str - item: Any = field(compare=False) + item: Union[Any, MultipartPartsAndContext] = field(compare=False) def _tracing_thread_drain_queue( @@ -67,12 +89,13 @@ def _tracing_thread_handle_batch( batch: List[TracingQueueItem], use_multipart: bool, ) -> None: - create = [it.item for it in batch if it.action == "create"] - update = [it.item for it in batch if it.action == "update"] try: if use_multipart: - client.multipart_ingest_runs(create=create, update=update, pre_sampled=True) + acc = join_multipart_parts_and_context(i.item for i in batch) + client._send_multipart_req(acc) else: + create = [it.item for it in batch if it.action == "create"] + update = [it.item for it in batch if it.action == "update"] client.batch_ingest_runs(create=create, update=update, pre_sampled=True) except Exception: logger.error("Error in tracing queue", exc_info=True) diff --git a/python/langsmith/_internal/_multipart.py b/python/langsmith/_internal/_multipart.py new file mode 100644 index 000000000..f4b844f0d --- /dev/null +++ b/python/langsmith/_internal/_multipart.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import ( + Dict, + Iterable, + List, + Tuple, +) + +from langsmith._internal._serde import dumps_json as _dumps_json + +MultipartParts = List[Tuple[str, Tuple[None, bytes, str, Dict[str, str]]]] + + +@dataclass(order=True) +class MultipartPartsAndContext: + parts: List[MultipartParts] + context: str + + +def convert_to_multipart_parts_and_context( + create_dicts: list[dict], update_dicts: list[dict], *, all_attachments: Dict +) -> MultipartPartsAndContext: + acc_parts: MultipartParts = [] + acc_context: List[str] = [] + + for event, payloads in (("post", create_dicts), ("patch", update_dicts)): + for payload in payloads: + # collect fields to be sent as separate parts + fields = [ + ("inputs", payload.pop("inputs", None)), + ("outputs", payload.pop("outputs", None)), + ("events", payload.pop("events", None)), + ] + # encode the main run payload + payloadb = _dumps_json(payload) + acc_parts.append( + ( + f"{event}.{payload['id']}", + ( + None, + payloadb, + "application/json", + {"Content-Length": str(len(payloadb))}, + ), + ) + ) + # encode the fields we collected + for key, value in fields: + if value is None: + continue + valb = _dumps_json(value) + acc_parts.append( + ( + f"{event}.{payload['id']}.{key}", + ( + None, + valb, + "application/json", + {"Content-Length": str(len(valb))}, + ), + ), + ) + # encode the attachments + if attachments := all_attachments.pop(payload["id"], None): + for n, (ct, ba) in attachments.items(): + acc_parts.append( + ( + f"attachment.{payload['id']}.{n}", + (None, ba, ct, {"Content-Length": str(len(ba))}), + ) + ) + # compute context + acc_context.append( + f"trace={payload.get('trace_id')},id={payload.get('id')}" + ) + _context = "; ".join(acc_context) + return MultipartPartsAndContext(acc_parts, _context) + + +def join_multipart_parts_and_context( + parts_and_contexts: Iterable[MultipartPartsAndContext], +) -> MultipartPartsAndContext: + acc_parts = [] + acc_context = [] + for parts_and_context in parts_and_contexts: + acc_parts.extend(parts_and_context.parts) + acc_context.append(parts_and_context.context) + return MultipartPartsAndContext(acc_parts, "; ".join(acc_context)) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 4a1601c44..b8e4c0920 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -66,7 +66,13 @@ from langsmith import env as ls_env from langsmith import schemas as ls_schemas from langsmith import utils as ls_utils +from langsmith._internal._multipart import ( + MultipartParts, + MultipartPartsAndContext, + convert_to_multipart_parts_and_context, +) from langsmith._internal._background_thread import ( + SerializedRunParts, TracingQueueItem, ) from langsmith._internal._background_thread import ( @@ -101,7 +107,6 @@ class ZoneInfo: # type: ignore[no-redef] WARNED_ATTACHMENTS = False EMPTY_SEQ: tuple[Dict, ...] = () BOUNDARY = uuid.uuid4().hex -MultipartParts = List[Tuple[str, Tuple[None, bytes, str, Dict[str, str]]]] URLLIB3_SUPPORTS_BLOCKSIZE = "key_blocksize" in signature(PoolKey).parameters @@ -1212,18 +1217,34 @@ def create_run( } if not self._filter_for_sampling([run_create]): return - run_create = self._run_transform(run_create, copy=True) - if revision_id is not None: - run_create["extra"]["metadata"]["revision_id"] = revision_id + if ( self.tracing_queue is not None # batch ingest requires trace_id and dotted_order to be set and run_create.get("trace_id") is not None and run_create.get("dotted_order") is not None ): + attachments_collector: Dict[str, ls_schemas.Attachments] = {} + run_create = self._run_transform( + run_create, + copy=False, + attachments_collector=attachments_collector, + ) + if revision_id is not None: + run_create["extra"]["metadata"]["revision_id"] = revision_id + acc = convert_to_multipart_parts_and_context( + [run_create], [], all_attachments=attachments_collector + ) return self.tracing_queue.put( - TracingQueueItem(run_create["dotted_order"], "create", run_create) + TracingQueueItem(run_create["dotted_order"], "create", acc) + ) + else: + run_create = self._run_transform( + run_create, + copy=True, ) + if revision_id is not None: + run_create["extra"]["metadata"]["revision_id"] = revision_id self._insert_runtime_env([run_create]) self._create_run(run_create) @@ -1497,64 +1518,16 @@ def multipart_ingest_runs( self._insert_runtime_env(create_dicts) self._insert_runtime_env(update_dicts) # send the runs in multipart requests - acc_context: List[str] = [] - acc_parts: MultipartParts = [] - for event, payloads in (("post", create_dicts), ("patch", update_dicts)): - for payload in payloads: - # collect fields to be sent as separate parts - fields = [ - ("inputs", payload.pop("inputs", None)), - ("outputs", payload.pop("outputs", None)), - ("events", payload.pop("events", None)), - ] - # encode the main run payload - payloadb = _dumps_json(payload) - acc_parts.append( - ( - f"{event}.{payload['id']}", - ( - None, - payloadb, - "application/json", - {"Content-Length": str(len(payloadb))}, - ), - ) - ) - # encode the fields we collected - for key, value in fields: - if value is None: - continue - valb = _dumps_json(value) - acc_parts.append( - ( - f"{event}.{payload['id']}.{key}", - ( - None, - valb, - "application/json", - {"Content-Length": str(len(valb))}, - ), - ), - ) - # encode the attachments - if attachments := all_attachments.pop(payload["id"], None): - for n, (ct, ba) in attachments.items(): - acc_parts.append( - ( - f"attachment.{payload['id']}.{n}", - (None, ba, ct, {"Content-Length": str(len(ba))}), - ) - ) - # compute context - acc_context.append( - f"trace={payload.get('trace_id')},id={payload.get('id')}" - ) + acc: MultipartPartsAndContext = convert_to_multipart_parts_and_context( + create_dicts, update_dicts, all_attachments=all_attachments + ) + # send the request - self._send_multipart_req(acc_parts, _context="; ".join(acc_context)) + self._send_multipart_req(acc) - def _send_multipart_req( - self, parts: MultipartParts, *, _context: str, attempts: int = 3 - ): + def _send_multipart_req(self, acc: MultipartPartsAndContext, *, attempts: int = 3): + parts = acc.parts + _context = acc.context for api_url, api_key in self._write_api_urls.items(): for idx in range(1, attempts + 1): try: From 0b010d59cfb18436d74457914a4cc7255bd8cc46 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Fri, 25 Oct 2024 19:57:25 -0700 Subject: [PATCH 13/39] x --- .../langsmith/_internal/_background_thread.py | 27 ++++--------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index f6f11dbcc..ff98776f5 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -10,7 +10,6 @@ TYPE_CHECKING, Any, List, - Optional, Union, ) @@ -20,33 +19,17 @@ _AUTO_SCALE_UP_NTHREADS_LIMIT, _AUTO_SCALE_UP_QSIZE_TRIGGER, ) -from langsmith._internal._multipart import join_multipart_parts_and_context +from langsmith._internal._multipart import ( + MultipartPartsAndContext, + join_multipart_parts_and_context, +) if TYPE_CHECKING: - from langsmith.client import Client, MultipartParts + from langsmith.client import Client logger = logging.getLogger("langsmith.client") -@dataclass(order=True) -class SerializedRunParts: - """ - A dataclass to hold the serialized parts of a run for sending to the - multipart endpoint - """ - - trace_id: str - id: str - inputs: bytes - outputs: bytes - events: bytes - attachments: Optional[List[bytes]] - - # the run without inputs,outputs,events,attachments - # note this also includes trace_id and id - remaining_run: bytes - - @dataclass(order=True) class TracingQueueItem: """An item in the tracing queue. From ebedf40e0ed681c9260439e66bd5897892e54b9a Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Fri, 25 Oct 2024 19:57:50 -0700 Subject: [PATCH 14/39] x --- python/langsmith/client.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index b8e4c0920..7b8eb7030 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -66,13 +66,7 @@ from langsmith import env as ls_env from langsmith import schemas as ls_schemas from langsmith import utils as ls_utils -from langsmith._internal._multipart import ( - MultipartParts, - MultipartPartsAndContext, - convert_to_multipart_parts_and_context, -) from langsmith._internal._background_thread import ( - SerializedRunParts, TracingQueueItem, ) from langsmith._internal._background_thread import ( @@ -84,6 +78,10 @@ _BLOCKSIZE_BYTES, _SIZE_LIMIT_BYTES, ) +from langsmith._internal._multipart import ( + MultipartPartsAndContext, + convert_to_multipart_parts_and_context, +) from langsmith._internal._serde import dumps_json as _dumps_json try: From 90594015045f23e0867032be421fb2dd00ba5148 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Fri, 25 Oct 2024 20:04:15 -0700 Subject: [PATCH 15/39] x --- python/langsmith/client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 7b8eb7030..c91539a15 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -1230,6 +1230,7 @@ def create_run( ) if revision_id is not None: run_create["extra"]["metadata"]["revision_id"] = revision_id + self._insert_runtime_env([run_create]) acc = convert_to_multipart_parts_and_context( [run_create], [], all_attachments=attachments_collector ) From eb74e2a7d77194e95d85d091083207fd168df503 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Mon, 28 Oct 2024 14:56:51 -0700 Subject: [PATCH 16/39] x --- python/langsmith/client.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index c91539a15..ebd5dd81c 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -1237,13 +1237,12 @@ def create_run( return self.tracing_queue.put( TracingQueueItem(run_create["dotted_order"], "create", acc) ) - else: - run_create = self._run_transform( - run_create, - copy=True, - ) - if revision_id is not None: - run_create["extra"]["metadata"]["revision_id"] = revision_id + run_create = self._run_transform( + run_create, + copy=True, + ) + if revision_id is not None: + run_create["extra"]["metadata"]["revision_id"] = revision_id self._insert_runtime_env([run_create]) self._create_run(run_create) From 80ffb68458d88fe3d741c49960bee29893141ac5 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Mon, 28 Oct 2024 22:25:56 -0700 Subject: [PATCH 17/39] x --- python/bench/create_run.py | 1 - python/langsmith/_internal/_background_thread.py | 2 +- python/langsmith/_internal/_multipart.py | 10 +++++----- python/langsmith/client.py | 2 +- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/python/bench/create_run.py b/python/bench/create_run.py index 26cecd8db..a907a1f61 100644 --- a/python/bench/create_run.py +++ b/python/bench/create_run.py @@ -1,7 +1,6 @@ import logging import statistics import time -import weakref from queue import PriorityQueue from typing import Dict from unittest.mock import Mock diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index ff98776f5..9e5a50a47 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -79,7 +79,7 @@ def _tracing_thread_handle_batch( else: create = [it.item for it in batch if it.action == "create"] update = [it.item for it in batch if it.action == "update"] - client.batch_ingest_runs(create=create, update=update, pre_sampled=True) + client.batch_ingest_runs(create=create, update=update, pre_sampled=True) # type: ignore except Exception: logger.error("Error in tracing queue", exc_info=True) # exceptions are logged elsewhere, but we need to make sure the diff --git a/python/langsmith/_internal/_multipart.py b/python/langsmith/_internal/_multipart.py index 306843af6..eff2d9e7b 100644 --- a/python/langsmith/_internal/_multipart.py +++ b/python/langsmith/_internal/_multipart.py @@ -10,12 +10,12 @@ from langsmith._internal._serde import dumps_json as _dumps_json -MultipartParts = List[Tuple[str, Tuple[None, bytes, str, Dict[str, str]]]] +MultipartPart = Tuple[str, Tuple[None, bytes, str, Dict[str, str]]] @dataclass(order=True) class MultipartPartsAndContext: - parts: List[MultipartParts] + parts: list[MultipartPart] context: str @@ -27,7 +27,7 @@ def convert_to_multipart_parts_and_context( all_attachments: Dict, ) -> MultipartPartsAndContext: acc_context: List[str] = [] - acc_parts: MultipartParts = [] + acc_parts: list[MultipartPart] = [] for event, payloads in ( ("post", create_dicts), ("patch", update_dicts), @@ -90,8 +90,8 @@ def convert_to_multipart_parts_and_context( def join_multipart_parts_and_context( parts_and_contexts: Iterable[MultipartPartsAndContext], ) -> MultipartPartsAndContext: - acc_parts = [] - acc_context = [] + acc_parts: list[MultipartPart] = [] + acc_context: list[str] = [] for parts_and_context in parts_and_contexts: acc_parts.extend(parts_and_context.parts) acc_context.append(parts_and_context.context) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 2f1136675..414dc5b6b 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -1260,7 +1260,7 @@ def create_run( run_create["extra"]["metadata"]["revision_id"] = revision_id self._insert_runtime_env([run_create]) acc = convert_to_multipart_parts_and_context( - [run_create], [], all_attachments=attachments_collector + [run_create], [], [], all_attachments=attachments_collector ) return self.tracing_queue.put( TracingQueueItem(run_create["dotted_order"], "create", acc) From d1cee6fd980d70213036fa2bafb05c590dfb52b2 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Mon, 28 Oct 2024 22:37:31 -0700 Subject: [PATCH 18/39] x --- python/langsmith/client.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 414dc5b6b..19881790c 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -1646,6 +1646,12 @@ def update_run( "session_id": kwargs.pop("session_id", None), "session_name": kwargs.pop("session_name", None), } + use_multipart = ( + self.tracing_queue is not None + # batch ingest requires trace_id and dotted_order to be set + and data["trace_id"] is not None + and data["dotted_order"] is not None + ) if not self._filter_for_sampling([data], patch=True): return if end_time is not None: @@ -1657,20 +1663,22 @@ def update_run( if inputs is not None: data["inputs"] = self._hide_run_inputs(inputs) if outputs is not None: - outputs = ls_utils.deepish_copy(outputs) + if not use_multipart: + outputs = ls_utils.deepish_copy(outputs) data["outputs"] = self._hide_run_outputs(outputs) if events is not None: data["events"] = events - if ( - self.tracing_queue is not None - # batch ingest requires trace_id and dotted_order to be set - and data["trace_id"] is not None - and data["dotted_order"] is not None - ): - return self.tracing_queue.put( - TracingQueueItem(data["dotted_order"], "update", data) + if use_multipart and self.tracing_queue is not None: + # not collecting attachments currently, use empty dict + attachments_collector: dict[None, None] = {} + acc = convert_to_multipart_parts_and_context( + [], [data], [], all_attachments=attachments_collector + ) + self.tracing_queue.put( + TracingQueueItem(data["dotted_order"], "update", acc) ) - return self._update_run(data) + else: + self._update_run(data) def _update_run(self, run_update: dict) -> None: for api_url, api_key in self._write_api_urls.items(): From 32b868b15f179cb8eb77232eab47047bea154a1f Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Tue, 29 Oct 2024 17:23:49 -0700 Subject: [PATCH 19/39] multipart and batch unify cleanup start --- .../langsmith/_internal/_background_thread.py | 81 ++++++++++++++++--- python/langsmith/_internal/_multipart.py | 21 ++++- 2 files changed, 88 insertions(+), 14 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 9e5a50a47..87d0e683f 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -1,16 +1,20 @@ from __future__ import annotations +import functools import logging import sys import threading import weakref -from dataclasses import dataclass, field +from collections import defaultdict +from dataclasses import dataclass +from itertools import chain from queue import Empty, Queue from typing import ( TYPE_CHECKING, - Any, List, + Literal, Union, + cast, ) from langsmith import schemas as ls_schemas @@ -29,8 +33,11 @@ logger = logging.getLogger("langsmith.client") +_RunData = Union[ls_schemas.Run, ls_schemas.RunLikeDict, dict] -@dataclass(order=True) + +@functools.total_ordering +@dataclass class TracingQueueItem: """An item in the tracing queue. @@ -41,8 +48,22 @@ class TracingQueueItem: """ priority: str - action: str - item: Union[Any, MultipartPartsAndContext] = field(compare=False) + item: Union[ + tuple[Literal["create"], _RunData], + tuple[Literal["update"], _RunData], + tuple[Literal["feedback-multipart"], MultipartPartsAndContext], + tuple[Literal["create-multipart"], MultipartPartsAndContext], + tuple[Literal["update-multipart"], MultipartPartsAndContext], + ] + + def __lt__(self, other: TracingQueueItem) -> bool: + return (self.priority, self.item[0]) < (other.priority, other.item[0]) + + def __eq__(self, other: object) -> bool: + return isinstance(other, TracingQueueItem) and ( + self.priority, + self.item[0], + ) == (other.priority, other.item[0]) def _tracing_thread_drain_queue( @@ -72,14 +93,50 @@ def _tracing_thread_handle_batch( batch: List[TracingQueueItem], use_multipart: bool, ) -> None: + item_by_action = defaultdict(list) + for i in batch: + item_by_action[i.item[0]].append(i.item[1]) + if use_multipart: + if "create" in item_by_action: + # convert create items to create-multipart items + # TODO + pass + if "update" in item_by_action: + # convert update items to update-multipart items + # TODO + pass + else: + if any( + k in item_by_action + for k in ("create-multipart", "update-multipart", "feedback-multipart") + ): + logger.error( + "Multipart items found in queue, but use_multipart is False. " + "This should not happen." + ) + item_by_action.pop("create-multipart", None) + item_by_action.pop("update-multipart", None) + item_by_action.pop("feedback-multipart", None) try: - if use_multipart: - acc = join_multipart_parts_and_context(i.item for i in batch) - client._send_multipart_req(acc) - else: - create = [it.item for it in batch if it.action == "create"] - update = [it.item for it in batch if it.action == "update"] - client.batch_ingest_runs(create=create, update=update, pre_sampled=True) # type: ignore + # sent multipart request + acc_multipart = join_multipart_parts_and_context( + cast(MultipartPartsAndContext, i) + for i in chain( + item_by_action["create-multipart"], item_by_action["update-multipart"] + ) + ) + if acc_multipart: + client._send_multipart_req(acc_multipart) + + # sent batch request + create = item_by_action["create"] + update = item_by_action["update"] + if create or update: + client.batch_ingest_runs( + create=cast(List[_RunData], create), + update=cast(List[_RunData], update), + pre_sampled=True, + ) except Exception: logger.error("Error in tracing queue", exc_info=True) # exceptions are logged elsewhere, but we need to make sure the diff --git a/python/langsmith/_internal/_multipart.py b/python/langsmith/_internal/_multipart.py index eff2d9e7b..1c0a96209 100644 --- a/python/langsmith/_internal/_multipart.py +++ b/python/langsmith/_internal/_multipart.py @@ -8,6 +8,7 @@ Tuple, ) +from langsmith import schemas as ls_schemas from langsmith._internal._serde import dumps_json as _dumps_json MultipartPart = Tuple[str, Tuple[None, bytes, str, Dict[str, str]]] @@ -19,6 +20,17 @@ class MultipartPartsAndContext: context: str +@dataclass +class SerializedRun: + _none: bytes + inputs: bytes + outputs: bytes + events: bytes + feedback: bytes + attachments: ls_schemas.Attachments + _context: str + + def convert_to_multipart_parts_and_context( create_dicts: list[dict], update_dicts: list[dict], @@ -72,11 +84,16 @@ def convert_to_multipart_parts_and_context( ) # encode the attachments if attachments := all_attachments.pop(payload["id"], None): - for n, (ct, ba) in attachments.items(): + for n, (content_type, valb) in attachments.items(): acc_parts.append( ( f"attachment.{payload['id']}.{n}", - (None, ba, ct, {"Content-Length": str(len(ba))}), + ( + None, + valb, + content_type, + {"Content-Length": str(len(valb))}, + ), ) ) # compute context From 1b7b8bc622b49ea3af595fa11ee92bbe4acfd92a Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Tue, 29 Oct 2024 18:32:04 -0700 Subject: [PATCH 20/39] simpler serde --- python/langsmith/_internal/_serde.py | 38 ++++++++++++---------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/python/langsmith/_internal/_serde.py b/python/langsmith/_internal/_serde.py index d7ec9800b..7de6f0d38 100644 --- a/python/langsmith/_internal/_serde.py +++ b/python/langsmith/_internal/_serde.py @@ -120,13 +120,25 @@ def _elide_surrogates(s: bytes) -> bytes: return result -def _dumps_json_single( - obj: Any, default: Optional[Callable[[Any], Any]] = None -) -> bytes: +def dumps_json(obj: Any) -> bytes: + """Serialize an object to a JSON formatted string. + + Parameters + ---------- + obj : Any + The object to serialize. + default : Callable[[Any], Any] or None, default=None + The default function to use for serialization. + + Returns: + ------- + str + The JSON formatted string. + """ try: return orjson.dumps( obj, - default=default or _simple_default, + default=_serialize_json, option=orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_SERIALIZE_DATACLASS | orjson.OPT_SERIALIZE_UUID @@ -147,21 +159,3 @@ def _dumps_json_single( except orjson.JSONDecodeError: result = _elide_surrogates(result) return result - - -def dumps_json(obj: Any, depth: int = 0) -> bytes: - """Serialize an object to a JSON formatted string. - - Parameters - ---------- - obj : Any - The object to serialize. - default : Callable[[Any], Any] or None, default=None - The default function to use for serialization. - - Returns: - ------- - str - The JSON formatted string. - """ - return _dumps_json_single(obj, _serialize_json) From 3ff376efebb6c79beb03c8d68fe3436a594c47a0 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Tue, 29 Oct 2024 19:03:11 -0700 Subject: [PATCH 21/39] explicit conversion dataclasses --- python/langsmith/_internal/_multipart.py | 255 ++++++++++++++++------- python/langsmith/client.py | 31 +-- 2 files changed, 184 insertions(+), 102 deletions(-) diff --git a/python/langsmith/_internal/_multipart.py b/python/langsmith/_internal/_multipart.py index 1c0a96209..b3b539357 100644 --- a/python/langsmith/_internal/_multipart.py +++ b/python/langsmith/_internal/_multipart.py @@ -1,12 +1,8 @@ from __future__ import annotations +import uuid from dataclasses import dataclass -from typing import ( - Dict, - Iterable, - List, - Tuple, -) +from typing import Dict, Iterable, List, Literal, Optional, Tuple, Union, cast from langsmith import schemas as ls_schemas from langsmith._internal._serde import dumps_json as _dumps_json @@ -14,94 +10,205 @@ MultipartPart = Tuple[str, Tuple[None, bytes, str, Dict[str, str]]] -@dataclass(order=True) +@dataclass class MultipartPartsAndContext: parts: list[MultipartPart] context: str @dataclass -class SerializedRun: +class SerializedRunOperation: + operation: Literal["post", "patch"] + id: uuid.UUID + trace_id: uuid.UUID + + # this is the whole object, minus the other fields which + # are popped (inputs/outputs/events/attachments) _none: bytes - inputs: bytes - outputs: bytes - events: bytes + + inputs: Optional[bytes] + outputs: Optional[bytes] + events: Optional[bytes] + attachments: Optional[ls_schemas.Attachments] + + +@dataclass +class SerializedFeedbackOperation: + id: uuid.UUID + trace_id: uuid.UUID feedback: bytes - attachments: ls_schemas.Attachments - _context: str -def convert_to_multipart_parts_and_context( - create_dicts: list[dict], - update_dicts: list[dict], - feedback_dicts: list[dict], - *, - all_attachments: Dict, +def serialize_feedback_dict( + feedback: Union[ls_schemas.Feedback, dict] +) -> SerializedFeedbackOperation: + if hasattr(feedback, "dict") and callable(getattr(feedback, "dict")): + feedback_create: dict = feedback.dict() # type: ignore + else: + feedback_create = cast(dict, feedback) + if "id" not in feedback_create: + feedback_create["id"] = uuid.uuid4() + elif isinstance(feedback_create["id"], str): + feedback_create["id"] = uuid.UUID(feedback_create["id"]) + + return SerializedFeedbackOperation( + id=feedback_create["id"], + trace_id=feedback_create.get("trace_id"), + feedback=_dumps_json(feedback_create), + ) + + +def serialize_run_dict( + operation: Literal["post", "patch"], payload: dict +) -> SerializedRunOperation: + inputs = payload.pop("inputs", None) + outputs = payload.pop("outputs", None) + events = payload.pop("events", None) + attachments = payload.pop("attachments", None) + return SerializedRunOperation( + operation=operation, + id=payload["id"], + trace_id=payload["trace_id"], + _none=_dumps_json(payload), + inputs=_dumps_json(inputs) if inputs is not None else None, + outputs=_dumps_json(outputs) if outputs is not None else None, + events=_dumps_json(events) if events is not None else None, + attachments=attachments if attachments is not None else None, + ) + + +def serialized_feedback_operation_to_multipart_parts_and_context( + op: SerializedFeedbackOperation, +) -> MultipartPartsAndContext: + return MultipartPartsAndContext( + [ + ( + f"feedback.{op.id}", + ( + None, + op.feedback, + "application/json", + {"Content-Length": str(len(op.feedback))}, + ), + ) + ], + f"trace={op.trace_id},id={op.id}", + ) + + +def serialized_run_operation_to_multipart_parts_and_context( + op: SerializedRunOperation, ) -> MultipartPartsAndContext: - acc_context: List[str] = [] acc_parts: list[MultipartPart] = [] - for event, payloads in ( - ("post", create_dicts), - ("patch", update_dicts), - ("feedback", feedback_dicts), + for key, value in ( + ("inputs", op.inputs), + ("outputs", op.outputs), + ("events", op.events), ): - for payload in payloads: - # collect fields to be sent as separate parts - fields = [ - ("inputs", payload.pop("inputs", None)), - ("outputs", payload.pop("outputs", None)), - ("events", payload.pop("events", None)), - ("feedback", payload.pop("feedback", None)), - ] - # encode the main run payload - payloadb = _dumps_json(payload) + if value is None: + continue + valb = value + acc_parts.append( + ( + f"{op.operation}.{op.id}.{key}", + ( + None, + valb, + "application/json", + {"Content-Length": str(len(valb))}, + ), + ), + ) + if op.attachments: + for n, (content_type, valb) in op.attachments.items(): acc_parts.append( ( - f"{event}.{payload['id']}", + f"attachment.{op.id}.{n}", ( None, - payloadb, - "application/json", - {"Content-Length": str(len(payloadb))}, + valb, + content_type, + {"Content-Length": str(len(valb))}, ), ) ) - # encode the fields we collected - for key, value in fields: - if value is None: - continue - valb = _dumps_json(value) - acc_parts.append( - ( - f"{event}.{payload['id']}.{key}", - ( - None, - valb, - "application/json", - {"Content-Length": str(len(valb))}, - ), - ), - ) - # encode the attachments - if attachments := all_attachments.pop(payload["id"], None): - for n, (content_type, valb) in attachments.items(): - acc_parts.append( - ( - f"attachment.{payload['id']}.{n}", - ( - None, - valb, - content_type, - {"Content-Length": str(len(valb))}, - ), - ) - ) - # compute context - acc_context.append( - f"trace={payload.get('trace_id')},id={payload.get('id')}" - ) - _context = "; ".join(acc_context) - return MultipartPartsAndContext(acc_parts, _context) + return MultipartPartsAndContext( + acc_parts, + f"trace={op.trace_id},id={op.id}", + ) + + +# def convert_to_multipart_parts_and_context( +# create_dicts: list[dict], +# update_dicts: list[dict], +# feedback_dicts: list[dict], +# *, +# all_attachments: Dict, +# ) -> MultipartPartsAndContext: +# acc_context: List[str] = [] +# acc_parts: list[MultipartPart] = [] +# for event, payloads in ( +# ("post", create_dicts), +# ("patch", update_dicts), +# ("feedback", feedback_dicts), +# ): +# for payload in payloads: +# # collect fields to be sent as separate parts +# fields = [ +# ("inputs", payload.pop("inputs", None)), +# ("outputs", payload.pop("outputs", None)), +# ("events", payload.pop("events", None)), +# ("feedback", payload.pop("feedback", None)), +# ] +# # encode the main run payload +# payloadb = _dumps_json(payload) +# acc_parts.append( +# ( +# f"{event}.{payload['id']}", +# ( +# None, +# payloadb, +# "application/json", +# {"Content-Length": str(len(payloadb))}, +# ), +# ) +# ) +# # encode the fields we collected +# for key, value in fields: +# if value is None: +# continue +# valb = _dumps_json(value) +# acc_parts.append( +# ( +# f"{event}.{payload['id']}.{key}", +# ( +# None, +# valb, +# "application/json", +# {"Content-Length": str(len(valb))}, +# ), +# ), +# ) +# # encode the attachments +# if attachments := all_attachments.pop(payload["id"], None): +# for n, (content_type, valb) in attachments.items(): +# acc_parts.append( +# ( +# f"attachment.{payload['id']}.{n}", +# ( +# None, +# valb, +# content_type, +# {"Content-Length": str(len(valb))}, +# ), +# ) +# ) +# # compute context +# acc_context.append( +# f"trace={payload.get('trace_id')},id={payload.get('id')}" +# ) +# _context = "; ".join(acc_context) +# return MultipartPartsAndContext(acc_parts, _context) def join_multipart_parts_and_context( diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 19881790c..4f2df13d6 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -1062,7 +1062,6 @@ def _run_transform( run: Union[ls_schemas.Run, dict, ls_schemas.RunLikeDict], update: bool = False, copy: bool = False, - attachments_collector: Optional[Dict[str, ls_schemas.Attachments]] = None, ) -> dict: """Transform the given run object into a dictionary representation. @@ -1070,9 +1069,6 @@ def _run_transform( run (Union[ls_schemas.Run, dict]): The run object to transform. update (bool, optional): Whether the payload is for an "update" event. copy (bool, optional): Whether to deepcopy run inputs/outputs. - attachments_collector (Optional[dict[str, ls_schemas.Attachments]]): - A dictionary to collect attachments. If not passed, attachments - will be dropped. Returns: dict: The transformed run object as a dictionary. @@ -1110,19 +1106,6 @@ def _run_transform( # Drop graph run_create["serialized"].pop("graph", None) - # Collect or drop attachments - if attachments := run_create.pop("attachments", None): - if attachments_collector is not None: - attachments_collector[run_create["id"]] = attachments - elif not WARNED_ATTACHMENTS: - WARNED_ATTACHMENTS = True - logger.warning( - "You're trying to submit a run with attachments, but your current" - " LangSmith integration doesn't support it. Please contact the " - " LangChain team at support at langchain" - " dot dev for assistance on how to upgrade." - ) - return run_create def _feedback_transform( @@ -1133,11 +1116,6 @@ def _feedback_transform( Args: feedback (Union[ls_schemas.Feedback, dict]): The feedback object to transform. - update (bool, optional): Whether the payload is for an "update" event. - copy (bool, optional): Whether to deepcopy feedback inputs/outputs. - attachments_collector (Optional[dict[str, ls_schemas.Attachments]]): - A dictionary to collect attachments. If not passed, attachments - will be dropped. Returns: dict: The transformed feedback object as a dictionary. @@ -1244,12 +1222,9 @@ def create_run( if not self._filter_for_sampling([run_create]): return - if ( - self.tracing_queue is not None - # batch ingest requires trace_id and dotted_order to be set - and run_create.get("trace_id") is not None - and run_create.get("dotted_order") is not None - ): + use_multipart = (self._info or {}).get("use_multipart_endpoint", False) + + if use_multipart: attachments_collector: Dict[str, ls_schemas.Attachments] = {} run_create = self._run_transform( run_create, From 385109136ba7c99c4afbe068c06101815fcf3c51 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Tue, 29 Oct 2024 19:25:03 -0700 Subject: [PATCH 22/39] create run path --- .../langsmith/_internal/_background_thread.py | 20 +++--- python/langsmith/client.py | 70 +++++-------------- 2 files changed, 28 insertions(+), 62 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 87d0e683f..9863a8fd0 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -12,7 +12,6 @@ from typing import ( TYPE_CHECKING, List, - Literal, Union, cast, ) @@ -25,6 +24,8 @@ ) from langsmith._internal._multipart import ( MultipartPartsAndContext, + SerializedFeedbackOperation, + SerializedRunOperation, join_multipart_parts_and_context, ) @@ -48,22 +49,19 @@ class TracingQueueItem: """ priority: str - item: Union[ - tuple[Literal["create"], _RunData], - tuple[Literal["update"], _RunData], - tuple[Literal["feedback-multipart"], MultipartPartsAndContext], - tuple[Literal["create-multipart"], MultipartPartsAndContext], - tuple[Literal["update-multipart"], MultipartPartsAndContext], - ] + item: Union[SerializedRunOperation, SerializedFeedbackOperation] def __lt__(self, other: TracingQueueItem) -> bool: - return (self.priority, self.item[0]) < (other.priority, other.item[0]) + return (self.priority, self.item.__class__) < ( + other.priority, + other.item.__class__, + ) def __eq__(self, other: object) -> bool: return isinstance(other, TracingQueueItem) and ( self.priority, - self.item[0], - ) == (other.priority, other.item[0]) + self.item.__class__, + ) == (other.priority, other.item.__class__) def _tracing_thread_drain_queue( diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 4f2df13d6..332f5ac80 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -81,6 +81,8 @@ from langsmith._internal._multipart import ( MultipartPartsAndContext, convert_to_multipart_parts_and_context, + serialize_feedback_dict, + serialize_run_dict, ) from langsmith._internal._serde import dumps_json as _dumps_json @@ -1108,29 +1110,6 @@ def _run_transform( return run_create - def _feedback_transform( - self, - feedback: Union[ls_schemas.Feedback, dict], - ) -> dict: - """Transform the given feedback object into a dictionary representation. - - Args: - feedback (Union[ls_schemas.Feedback, dict]): The feedback object to transform. - - Returns: - dict: The transformed feedback object as a dictionary. - """ - if hasattr(feedback, "dict") and callable(getattr(feedback, "dict")): - feedback_create: dict = feedback.dict() # type: ignore - else: - feedback_create = cast(dict, feedback) - if "id" not in feedback_create: - feedback_create["id"] = uuid.uuid4() - elif isinstance(feedback_create["id"], str): - feedback_create["id"] = uuid.UUID(feedback_create["id"]) - - return feedback_create - @staticmethod def _insert_runtime_env(runs: Sequence[dict]) -> None: runtime_env = ls_env.get_runtime_environment() @@ -1222,32 +1201,25 @@ def create_run( if not self._filter_for_sampling([run_create]): return - use_multipart = (self._info or {}).get("use_multipart_endpoint", False) - - if use_multipart: - attachments_collector: Dict[str, ls_schemas.Attachments] = {} - run_create = self._run_transform( - run_create, - copy=False, - attachments_collector=attachments_collector, - ) - if revision_id is not None: - run_create["extra"]["metadata"]["revision_id"] = revision_id - self._insert_runtime_env([run_create]) - acc = convert_to_multipart_parts_and_context( - [run_create], [], [], all_attachments=attachments_collector - ) - return self.tracing_queue.put( - TracingQueueItem(run_create["dotted_order"], "create", acc) - ) + if revision_id is not None: + run_create["extra"]["metadata"]["revision_id"] = revision_id run_create = self._run_transform( run_create, - copy=True, + copy=False, ) - if revision_id is not None: - run_create["extra"]["metadata"]["revision_id"] = revision_id self._insert_runtime_env([run_create]) - self._create_run(run_create) + if ( + self.tracing_queue is not None + # batch ingest requires trace_id and dotted_order to be set + and run_create.get("trace_id") is not None + and run_create.get("dotted_order") is not None + ): + serialized_op = serialize_run_dict("post", run_create) + self.tracing_queue.put( + TracingQueueItem(run_create["dotted_order"], serialized_op) + ) + else: + self._create_run(run_create) def _create_run(self, run_create: dict): for api_url, api_key in self._write_api_urls.items(): @@ -1469,13 +1441,9 @@ def multipart_ingest( return # transform and convert to dicts all_attachments: Dict[str, ls_schemas.Attachments] = {} - create_dicts = [ - self._run_transform(run, attachments_collector=all_attachments) - for run in create or EMPTY_SEQ - ] + create_dicts = [self._run_transform(run) for run in create or EMPTY_SEQ] update_dicts = [ - self._run_transform(run, update=True, attachments_collector=all_attachments) - for run in update or EMPTY_SEQ + self._run_transform(run, update=True) for run in update or EMPTY_SEQ ] feedback_dicts = [self._feedback_transform(f) for f in feedback or EMPTY_SEQ] # require trace_id and dotted_order From a6984ad34e18b17242ac3704c636a3872c6d0217 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Wed, 30 Oct 2024 10:42:31 -0700 Subject: [PATCH 23/39] x --- .../langsmith/_internal/_background_thread.py | 142 ++++++++++++------ python/langsmith/_internal/_multipart.py | 10 +- python/langsmith/_internal/_serde.py | 2 - python/langsmith/client.py | 1 - 4 files changed, 103 insertions(+), 52 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 9863a8fd0..008e52df5 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -1,32 +1,37 @@ from __future__ import annotations +import collections import functools import logging import sys import threading import weakref -from collections import defaultdict from dataclasses import dataclass -from itertools import chain from queue import Empty, Queue from typing import ( TYPE_CHECKING, + DefaultDict, List, + Literal, Union, - cast, ) +import orjson + from langsmith import schemas as ls_schemas from langsmith._internal._constants import ( _AUTO_SCALE_DOWN_NEMPTY_TRIGGER, _AUTO_SCALE_UP_NTHREADS_LIMIT, _AUTO_SCALE_UP_QSIZE_TRIGGER, + _SIZE_LIMIT_BYTES, ) from langsmith._internal._multipart import ( MultipartPartsAndContext, SerializedFeedbackOperation, SerializedRunOperation, join_multipart_parts_and_context, + serialized_feedback_operation_to_multipart_parts_and_context, + serialized_run_operation_to_multipart_parts_and_context, ) if TYPE_CHECKING: @@ -91,50 +96,95 @@ def _tracing_thread_handle_batch( batch: List[TracingQueueItem], use_multipart: bool, ) -> None: - item_by_action = defaultdict(list) - for i in batch: - item_by_action[i.item[0]].append(i.item[1]) - if use_multipart: - if "create" in item_by_action: - # convert create items to create-multipart items - # TODO - pass - if "update" in item_by_action: - # convert update items to update-multipart items - # TODO - pass - else: - if any( - k in item_by_action - for k in ("create-multipart", "update-multipart", "feedback-multipart") - ): - logger.error( - "Multipart items found in queue, but use_multipart is False. " - "This should not happen." - ) - item_by_action.pop("create-multipart", None) - item_by_action.pop("update-multipart", None) - item_by_action.pop("feedback-multipart", None) try: - # sent multipart request - acc_multipart = join_multipart_parts_and_context( - cast(MultipartPartsAndContext, i) - for i in chain( - item_by_action["create-multipart"], item_by_action["update-multipart"] - ) - ) - if acc_multipart: - client._send_multipart_req(acc_multipart) - - # sent batch request - create = item_by_action["create"] - update = item_by_action["update"] - if create or update: - client.batch_ingest_runs( - create=cast(List[_RunData], create), - update=cast(List[_RunData], update), - pre_sampled=True, - ) + if use_multipart: + parts: list[MultipartPartsAndContext] = [] + for item in batch: + if isinstance(item.item, SerializedRunOperation): + parts.append( + serialized_run_operation_to_multipart_parts_and_context( + item.item + ) + ) + elif isinstance(item.item, SerializedFeedbackOperation): + parts.append( + serialized_feedback_operation_to_multipart_parts_and_context( + item.item + ) + ) + else: + logger.error("Unknown item type in tracing queue: %s", item) + acc_multipart = join_multipart_parts_and_context(parts) + if acc_multipart: + client._send_multipart_req(acc_multipart) + else: + ids_and_partial_body: dict[ + Literal["post", "patch"], list[tuple[str, bytes]] + ] = { + "post": [], + "patch": [], + } + + # form the partial body and ids + for item in batch: + op = item.item + if isinstance(op, SerializedRunOperation): + curr_dict = orjson.loads(op._none) + if op.inputs: + curr_dict["inputs"] = orjson.Fragment(op.inputs) + if op.outputs: + curr_dict["outputs"] = orjson.Fragment(op.outputs) + if op.events: + curr_dict["events"] = orjson.Fragment(op.events) + if op.attachments: + logger.warning( + "Attachments are not supported in non-multipart mode" + ) + ids_and_partial_body[op.operation].append( + (f"trace={op.trace_id},id={op.id}", orjson.dumps(curr_dict)) + ) + elif isinstance(op, SerializedFeedbackOperation): + logger.warning( + "Feedback operations are not supported in non-multipart mode" + ) + else: + logger.error("Unknown item type in tracing queue: %s", item) + + # send the requests in batches + info = client.info + size_limit_bytes = (info.batch_ingest_config or {}).get( + "size_limit_bytes" + ) or _SIZE_LIMIT_BYTES + + body_chunks: DefaultDict[str, list] = collections.defaultdict(list) + context_ids: DefaultDict[str, list] = collections.defaultdict(list) + body_size = 0 + for key in ["post", "patch"]: + body_deque = collections.deque(ids_and_partial_body[key]) + while body_deque: + if ( + body_size > 0 + and body_size + len(body_deque[0][1]) > size_limit_bytes + ): + client._post_batch_ingest_runs( + orjson.dumps(body_chunks), + _context=f"\n{key}: {'; '.join(context_ids[key])}", + ) + body_size = 0 + body_chunks.clear() + context_ids.clear() + curr_id, curr_body = body_deque.popleft() + body_size += len(curr_body) + body_chunks[key].append(orjson.Fragment(curr_body)) + context_ids[key].append(curr_id) + if body_size: + context = "; ".join( + f"{k}: {'; '.join(v)}" for k, v in context_ids.items() + ) + client._post_batch_ingest_runs( + orjson.dumps(body_chunks), _context="\n" + context + ) + except Exception: logger.error("Error in tracing queue", exc_info=True) # exceptions are logged elsewhere, but we need to make sure the diff --git a/python/langsmith/_internal/_multipart.py b/python/langsmith/_internal/_multipart.py index b3b539357..8cc99ac1e 100644 --- a/python/langsmith/_internal/_multipart.py +++ b/python/langsmith/_internal/_multipart.py @@ -2,7 +2,7 @@ import uuid from dataclasses import dataclass -from typing import Dict, Iterable, List, Literal, Optional, Tuple, Union, cast +from typing import Dict, Iterable, Literal, Optional, Tuple, Union, cast from langsmith import schemas as ls_schemas from langsmith._internal._serde import dumps_json as _dumps_json @@ -40,7 +40,7 @@ class SerializedFeedbackOperation: def serialize_feedback_dict( - feedback: Union[ls_schemas.Feedback, dict] + feedback: Union[ls_schemas.Feedback, dict], ) -> SerializedFeedbackOperation: if hasattr(feedback, "dict") and callable(getattr(feedback, "dict")): feedback_create: dict = feedback.dict() # type: ignore @@ -50,10 +50,14 @@ def serialize_feedback_dict( feedback_create["id"] = uuid.uuid4() elif isinstance(feedback_create["id"], str): feedback_create["id"] = uuid.UUID(feedback_create["id"]) + if "trace_id" not in feedback_create: + feedback_create["trace_id"] = uuid.uuid4() + elif isinstance(feedback_create["trace_id"], str): + feedback_create["trace_id"] = uuid.UUID(feedback_create["trace_id"]) return SerializedFeedbackOperation( id=feedback_create["id"], - trace_id=feedback_create.get("trace_id"), + trace_id=feedback_create["trace_id"], feedback=_dumps_json(feedback_create), ) diff --git a/python/langsmith/_internal/_serde.py b/python/langsmith/_internal/_serde.py index 7de6f0d38..11daf5463 100644 --- a/python/langsmith/_internal/_serde.py +++ b/python/langsmith/_internal/_serde.py @@ -12,8 +12,6 @@ import uuid from typing import ( Any, - Callable, - Optional, ) import orjson diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 332f5ac80..b8b8ee311 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -81,7 +81,6 @@ from langsmith._internal._multipart import ( MultipartPartsAndContext, convert_to_multipart_parts_and_context, - serialize_feedback_dict, serialize_run_dict, ) from langsmith._internal._serde import dumps_json as _dumps_json From 1780616a4fc25e6c9246ac92f38d2a07d1b3bd2e Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Wed, 30 Oct 2024 11:15:14 -0700 Subject: [PATCH 24/39] x --- .../langsmith/_internal/_background_thread.py | 3 +- python/langsmith/_internal/_multipart.py | 2 +- python/langsmith/client.py | 198 +++++++++--------- 3 files changed, 101 insertions(+), 102 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 008e52df5..307d21cef 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -14,6 +14,7 @@ List, Literal, Union, + cast, ) import orjson @@ -159,7 +160,7 @@ def _tracing_thread_handle_batch( body_chunks: DefaultDict[str, list] = collections.defaultdict(list) context_ids: DefaultDict[str, list] = collections.defaultdict(list) body_size = 0 - for key in ["post", "patch"]: + for key in cast(list[Literal["post", "patch"]], ["post", "patch"]): body_deque = collections.deque(ids_and_partial_body[key]) while body_deque: if ( diff --git a/python/langsmith/_internal/_multipart.py b/python/langsmith/_internal/_multipart.py index 8cc99ac1e..b957544d6 100644 --- a/python/langsmith/_internal/_multipart.py +++ b/python/langsmith/_internal/_multipart.py @@ -40,7 +40,7 @@ class SerializedFeedbackOperation: def serialize_feedback_dict( - feedback: Union[ls_schemas.Feedback, dict], + feedback: Union[ls_schemas.FeedbackCreate, dict], ) -> SerializedFeedbackOperation: if hasattr(feedback, "dict") and callable(getattr(feedback, "dict")): feedback_create: dict = feedback.dict() # type: ignore diff --git a/python/langsmith/client.py b/python/langsmith/client.py index b8b8ee311..61ccfc328 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -80,7 +80,7 @@ ) from langsmith._internal._multipart import ( MultipartPartsAndContext, - convert_to_multipart_parts_and_context, + serialize_feedback_dict, serialize_run_dict, ) from langsmith._internal._serde import dumps_json as _dumps_json @@ -1401,99 +1401,99 @@ def _post_batch_ingest_runs(self, body: bytes, *, _context: str): except Exception: logger.warning(f"Failed to batch ingest runs: {repr(e)}") - def multipart_ingest( - self, - create: Optional[ - Sequence[Union[ls_schemas.Run, ls_schemas.RunLikeDict, Dict]] - ] = None, - update: Optional[ - Sequence[Union[ls_schemas.Run, ls_schemas.RunLikeDict, Dict]] - ] = None, - feedback: Optional[Sequence[Union[ls_schemas.Feedback, Dict]]] = None, - *, - pre_sampled: bool = False, - ) -> None: - """Batch ingest/upsert multiple runs in the Langsmith system. - - Args: - create (Optional[Sequence[Union[ls_schemas.Run, RunLikeDict]]]): - A sequence of `Run` objects or equivalent dictionaries representing - runs to be created / posted. - update (Optional[Sequence[Union[ls_schemas.Run, RunLikeDict]]]): - A sequence of `Run` objects or equivalent dictionaries representing - runs that have already been created and should be updated / patched. - pre_sampled (bool, optional): Whether the runs have already been subject - to sampling, and therefore should not be sampled again. - Defaults to False. - - Returns: - None - - Raises: - LangsmithAPIError: If there is an error in the API request. - - Note: - - The run objects MUST contain the dotted_order and trace_id fields - to be accepted by the API. - """ - if not (create or update or feedback): - return - # transform and convert to dicts - all_attachments: Dict[str, ls_schemas.Attachments] = {} - create_dicts = [self._run_transform(run) for run in create or EMPTY_SEQ] - update_dicts = [ - self._run_transform(run, update=True) for run in update or EMPTY_SEQ - ] - feedback_dicts = [self._feedback_transform(f) for f in feedback or EMPTY_SEQ] - # require trace_id and dotted_order - if create_dicts: - for run in create_dicts: - if not run.get("trace_id") or not run.get("dotted_order"): - raise ls_utils.LangSmithUserError( - "Multipart ingest requires trace_id and dotted_order" - " to be set in create dicts." - ) - else: - del run - if update_dicts: - for run in update_dicts: - if not run.get("trace_id") or not run.get("dotted_order"): - raise ls_utils.LangSmithUserError( - "Multipart ingest requires trace_id and dotted_order" - " to be set in update dicts." - ) - else: - del run - # combine post and patch dicts where possible - if update_dicts and create_dicts: - create_by_id = {run["id"]: run for run in create_dicts} - standalone_updates: list[dict] = [] - for run in update_dicts: - if run["id"] in create_by_id: - for k, v in run.items(): - if v is not None: - create_by_id[run["id"]][k] = v - else: - standalone_updates.append(run) - else: - del run - update_dicts = standalone_updates - # filter out runs that are not sampled - if not pre_sampled: - create_dicts = self._filter_for_sampling(create_dicts) - update_dicts = self._filter_for_sampling(update_dicts, patch=True) - if not create_dicts and not update_dicts and not feedback_dicts: - return - # insert runtime environment - self._insert_runtime_env(create_dicts) - self._insert_runtime_env(update_dicts) - # send the runs in multipart requests - acc: MultipartPartsAndContext = convert_to_multipart_parts_and_context( - create_dicts, update_dicts, feedback_dicts, all_attachments=all_attachments - ) - - # send the request - self._send_multipart_req(acc) + # def multipart_ingest( + # self, + # create: Optional[ + # Sequence[Union[ls_schemas.Run, ls_schemas.RunLikeDict, Dict]] + # ] = None, + # update: Optional[ + # Sequence[Union[ls_schemas.Run, ls_schemas.RunLikeDict, Dict]] + # ] = None, + # feedback: Optional[Sequence[Union[ls_schemas.Feedback, Dict]]] = None, + # *, + # pre_sampled: bool = False, + # ) -> None: + # """Batch ingest/upsert multiple runs in the Langsmith system. + + # Args: + # create (Optional[Sequence[Union[ls_schemas.Run, RunLikeDict]]]): + # A sequence of `Run` objects or equivalent dictionaries representing + # runs to be created / posted. + # update (Optional[Sequence[Union[ls_schemas.Run, RunLikeDict]]]): + # A sequence of `Run` objects or equivalent dictionaries representing + # runs that have already been created and should be updated / patched. + # pre_sampled (bool, optional): Whether the runs have already been subject + # to sampling, and therefore should not be sampled again. + # Defaults to False. + + # Returns: + # None + + # Raises: + # LangsmithAPIError: If there is an error in the API request. + + # Note: + # - The run objects MUST contain the dotted_order and trace_id fields + # to be accepted by the API. + # """ + # if not (create or update or feedback): + # return + # # transform and convert to dicts + # all_attachments: Dict[str, ls_schemas.Attachments] = {} + # create_dicts = [self._run_transform(run) for run in create or EMPTY_SEQ] + # update_dicts = [ + # self._run_transform(run, update=True) for run in update or EMPTY_SEQ + # ] + # feedback_dicts = [self._feedback_transform(f) for f in feedback or EMPTY_SEQ] + # # require trace_id and dotted_order + # if create_dicts: + # for run in create_dicts: + # if not run.get("trace_id") or not run.get("dotted_order"): + # raise ls_utils.LangSmithUserError( + # "Multipart ingest requires trace_id and dotted_order" + # " to be set in create dicts." + # ) + # else: + # del run + # if update_dicts: + # for run in update_dicts: + # if not run.get("trace_id") or not run.get("dotted_order"): + # raise ls_utils.LangSmithUserError( + # "Multipart ingest requires trace_id and dotted_order" + # " to be set in update dicts." + # ) + # else: + # del run + # # combine post and patch dicts where possible + # if update_dicts and create_dicts: + # create_by_id = {run["id"]: run for run in create_dicts} + # standalone_updates: list[dict] = [] + # for run in update_dicts: + # if run["id"] in create_by_id: + # for k, v in run.items(): + # if v is not None: + # create_by_id[run["id"]][k] = v + # else: + # standalone_updates.append(run) + # else: + # del run + # update_dicts = standalone_updates + # # filter out runs that are not sampled + # if not pre_sampled: + # create_dicts = self._filter_for_sampling(create_dicts) + # update_dicts = self._filter_for_sampling(update_dicts, patch=True) + # if not create_dicts and not update_dicts and not feedback_dicts: + # return + # # insert runtime environment + # self._insert_runtime_env(create_dicts) + # self._insert_runtime_env(update_dicts) + # # send the runs in multipart requests + # acc: MultipartPartsAndContext = convert_to_multipart_parts_and_context( + # create_dicts, update_dicts, feedback_dicts, all_attachments=all_attachments + # ) + + # # send the request + # self._send_multipart_req(acc) def _send_multipart_req(self, acc: MultipartPartsAndContext, *, attempts: int = 3): parts = acc.parts @@ -1612,12 +1612,9 @@ def update_run( data["events"] = events if use_multipart and self.tracing_queue is not None: # not collecting attachments currently, use empty dict - attachments_collector: dict[None, None] = {} - acc = convert_to_multipart_parts_and_context( - [], [data], [], all_attachments=attachments_collector - ) + serialized_op = serialize_run_dict(operation="patch", payload=data) self.tracing_queue.put( - TracingQueueItem(data["dotted_order"], "update", acc) + TracingQueueItem(data["dotted_order"], serialized_op) ) else: self._update_run(data) @@ -4260,8 +4257,9 @@ def create_feedback( and self.tracing_queue is not None and feedback.trace_id is not None ): + serialized_op = serialize_feedback_dict(feedback) self.tracing_queue.put( - TracingQueueItem(str(feedback.id), "feedback", feedback) + TracingQueueItem(str(feedback.id), serialized_op) ) else: self.request_with_retries( From 07d6fed34cffb22fd617c5852dbb9130cc5b8f6f Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Wed, 30 Oct 2024 11:16:40 -0700 Subject: [PATCH 25/39] x --- python/langsmith/_internal/_multipart.py | 73 ------------------------ 1 file changed, 73 deletions(-) diff --git a/python/langsmith/_internal/_multipart.py b/python/langsmith/_internal/_multipart.py index b957544d6..8051a7c5e 100644 --- a/python/langsmith/_internal/_multipart.py +++ b/python/langsmith/_internal/_multipart.py @@ -142,79 +142,6 @@ def serialized_run_operation_to_multipart_parts_and_context( ) -# def convert_to_multipart_parts_and_context( -# create_dicts: list[dict], -# update_dicts: list[dict], -# feedback_dicts: list[dict], -# *, -# all_attachments: Dict, -# ) -> MultipartPartsAndContext: -# acc_context: List[str] = [] -# acc_parts: list[MultipartPart] = [] -# for event, payloads in ( -# ("post", create_dicts), -# ("patch", update_dicts), -# ("feedback", feedback_dicts), -# ): -# for payload in payloads: -# # collect fields to be sent as separate parts -# fields = [ -# ("inputs", payload.pop("inputs", None)), -# ("outputs", payload.pop("outputs", None)), -# ("events", payload.pop("events", None)), -# ("feedback", payload.pop("feedback", None)), -# ] -# # encode the main run payload -# payloadb = _dumps_json(payload) -# acc_parts.append( -# ( -# f"{event}.{payload['id']}", -# ( -# None, -# payloadb, -# "application/json", -# {"Content-Length": str(len(payloadb))}, -# ), -# ) -# ) -# # encode the fields we collected -# for key, value in fields: -# if value is None: -# continue -# valb = _dumps_json(value) -# acc_parts.append( -# ( -# f"{event}.{payload['id']}.{key}", -# ( -# None, -# valb, -# "application/json", -# {"Content-Length": str(len(valb))}, -# ), -# ), -# ) -# # encode the attachments -# if attachments := all_attachments.pop(payload["id"], None): -# for n, (content_type, valb) in attachments.items(): -# acc_parts.append( -# ( -# f"attachment.{payload['id']}.{n}", -# ( -# None, -# valb, -# content_type, -# {"Content-Length": str(len(valb))}, -# ), -# ) -# ) -# # compute context -# acc_context.append( -# f"trace={payload.get('trace_id')},id={payload.get('id')}" -# ) -# _context = "; ".join(acc_context) -# return MultipartPartsAndContext(acc_parts, _context) - - def join_multipart_parts_and_context( parts_and_contexts: Iterable[MultipartPartsAndContext], ) -> MultipartPartsAndContext: From 3530e05d5388cd746f82c9930150fa68d1145ce1 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Wed, 30 Oct 2024 12:52:08 -0700 Subject: [PATCH 26/39] ingest ops helper funcs --- .../langsmith/_internal/_background_thread.py | 105 ++------- python/langsmith/_internal/_multipart.py | 132 +---------- python/langsmith/_internal/_operations.py | 135 +++++++++++ python/langsmith/client.py | 211 +++++++++++------- 4 files changed, 281 insertions(+), 302 deletions(-) create mode 100644 python/langsmith/_internal/_operations.py diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 307d21cef..fc48fb498 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -26,13 +26,9 @@ _AUTO_SCALE_UP_QSIZE_TRIGGER, _SIZE_LIMIT_BYTES, ) -from langsmith._internal._multipart import ( - MultipartPartsAndContext, +from langsmith._internal._operations import ( SerializedFeedbackOperation, SerializedRunOperation, - join_multipart_parts_and_context, - serialized_feedback_operation_to_multipart_parts_and_context, - serialized_run_operation_to_multipart_parts_and_context, ) if TYPE_CHECKING: @@ -40,8 +36,6 @@ logger = logging.getLogger("langsmith.client") -_RunData = Union[ls_schemas.Run, ls_schemas.RunLikeDict, dict] - @functools.total_ordering @dataclass @@ -99,92 +93,21 @@ def _tracing_thread_handle_batch( ) -> None: try: if use_multipart: - parts: list[MultipartPartsAndContext] = [] - for item in batch: - if isinstance(item.item, SerializedRunOperation): - parts.append( - serialized_run_operation_to_multipart_parts_and_context( - item.item - ) - ) - elif isinstance(item.item, SerializedFeedbackOperation): - parts.append( - serialized_feedback_operation_to_multipart_parts_and_context( - item.item - ) - ) - else: - logger.error("Unknown item type in tracing queue: %s", item) - acc_multipart = join_multipart_parts_and_context(parts) - if acc_multipart: - client._send_multipart_req(acc_multipart) + client._multipart_ingest_ops([item.item for item in batch]) else: - ids_and_partial_body: dict[ - Literal["post", "patch"], list[tuple[str, bytes]] - ] = { - "post": [], - "patch": [], - } - - # form the partial body and ids - for item in batch: - op = item.item - if isinstance(op, SerializedRunOperation): - curr_dict = orjson.loads(op._none) - if op.inputs: - curr_dict["inputs"] = orjson.Fragment(op.inputs) - if op.outputs: - curr_dict["outputs"] = orjson.Fragment(op.outputs) - if op.events: - curr_dict["events"] = orjson.Fragment(op.events) - if op.attachments: - logger.warning( - "Attachments are not supported in non-multipart mode" - ) - ids_and_partial_body[op.operation].append( - (f"trace={op.trace_id},id={op.id}", orjson.dumps(curr_dict)) - ) - elif isinstance(op, SerializedFeedbackOperation): - logger.warning( - "Feedback operations are not supported in non-multipart mode" - ) - else: - logger.error("Unknown item type in tracing queue: %s", item) - - # send the requests in batches - info = client.info - size_limit_bytes = (info.batch_ingest_config or {}).get( - "size_limit_bytes" - ) or _SIZE_LIMIT_BYTES - - body_chunks: DefaultDict[str, list] = collections.defaultdict(list) - context_ids: DefaultDict[str, list] = collections.defaultdict(list) - body_size = 0 - for key in cast(list[Literal["post", "patch"]], ["post", "patch"]): - body_deque = collections.deque(ids_and_partial_body[key]) - while body_deque: - if ( - body_size > 0 - and body_size + len(body_deque[0][1]) > size_limit_bytes - ): - client._post_batch_ingest_runs( - orjson.dumps(body_chunks), - _context=f"\n{key}: {'; '.join(context_ids[key])}", - ) - body_size = 0 - body_chunks.clear() - context_ids.clear() - curr_id, curr_body = body_deque.popleft() - body_size += len(curr_body) - body_chunks[key].append(orjson.Fragment(curr_body)) - context_ids[key].append(curr_id) - if body_size: - context = "; ".join( - f"{k}: {'; '.join(v)}" for k, v in context_ids.items() - ) - client._post_batch_ingest_runs( - orjson.dumps(body_chunks), _context="\n" + context + if any( + isinstance(item.item, SerializedFeedbackOperation) for item in batch + ): + logger.warn( + "Feedback operations are not supported in non-multipart mode" ) + client._batch_ingest_ops( + [ + item.item + for item in batch + if isinstance(item.item, SerializedRunOperation) + ] + ) except Exception: logger.error("Error in tracing queue", exc_info=True) diff --git a/python/langsmith/_internal/_multipart.py b/python/langsmith/_internal/_multipart.py index 8051a7c5e..535097609 100644 --- a/python/langsmith/_internal/_multipart.py +++ b/python/langsmith/_internal/_multipart.py @@ -1,11 +1,7 @@ from __future__ import annotations -import uuid from dataclasses import dataclass -from typing import Dict, Iterable, Literal, Optional, Tuple, Union, cast - -from langsmith import schemas as ls_schemas -from langsmith._internal._serde import dumps_json as _dumps_json +from typing import Dict, Iterable, Tuple MultipartPart = Tuple[str, Tuple[None, bytes, str, Dict[str, str]]] @@ -16,132 +12,6 @@ class MultipartPartsAndContext: context: str -@dataclass -class SerializedRunOperation: - operation: Literal["post", "patch"] - id: uuid.UUID - trace_id: uuid.UUID - - # this is the whole object, minus the other fields which - # are popped (inputs/outputs/events/attachments) - _none: bytes - - inputs: Optional[bytes] - outputs: Optional[bytes] - events: Optional[bytes] - attachments: Optional[ls_schemas.Attachments] - - -@dataclass -class SerializedFeedbackOperation: - id: uuid.UUID - trace_id: uuid.UUID - feedback: bytes - - -def serialize_feedback_dict( - feedback: Union[ls_schemas.FeedbackCreate, dict], -) -> SerializedFeedbackOperation: - if hasattr(feedback, "dict") and callable(getattr(feedback, "dict")): - feedback_create: dict = feedback.dict() # type: ignore - else: - feedback_create = cast(dict, feedback) - if "id" not in feedback_create: - feedback_create["id"] = uuid.uuid4() - elif isinstance(feedback_create["id"], str): - feedback_create["id"] = uuid.UUID(feedback_create["id"]) - if "trace_id" not in feedback_create: - feedback_create["trace_id"] = uuid.uuid4() - elif isinstance(feedback_create["trace_id"], str): - feedback_create["trace_id"] = uuid.UUID(feedback_create["trace_id"]) - - return SerializedFeedbackOperation( - id=feedback_create["id"], - trace_id=feedback_create["trace_id"], - feedback=_dumps_json(feedback_create), - ) - - -def serialize_run_dict( - operation: Literal["post", "patch"], payload: dict -) -> SerializedRunOperation: - inputs = payload.pop("inputs", None) - outputs = payload.pop("outputs", None) - events = payload.pop("events", None) - attachments = payload.pop("attachments", None) - return SerializedRunOperation( - operation=operation, - id=payload["id"], - trace_id=payload["trace_id"], - _none=_dumps_json(payload), - inputs=_dumps_json(inputs) if inputs is not None else None, - outputs=_dumps_json(outputs) if outputs is not None else None, - events=_dumps_json(events) if events is not None else None, - attachments=attachments if attachments is not None else None, - ) - - -def serialized_feedback_operation_to_multipart_parts_and_context( - op: SerializedFeedbackOperation, -) -> MultipartPartsAndContext: - return MultipartPartsAndContext( - [ - ( - f"feedback.{op.id}", - ( - None, - op.feedback, - "application/json", - {"Content-Length": str(len(op.feedback))}, - ), - ) - ], - f"trace={op.trace_id},id={op.id}", - ) - - -def serialized_run_operation_to_multipart_parts_and_context( - op: SerializedRunOperation, -) -> MultipartPartsAndContext: - acc_parts: list[MultipartPart] = [] - for key, value in ( - ("inputs", op.inputs), - ("outputs", op.outputs), - ("events", op.events), - ): - if value is None: - continue - valb = value - acc_parts.append( - ( - f"{op.operation}.{op.id}.{key}", - ( - None, - valb, - "application/json", - {"Content-Length": str(len(valb))}, - ), - ), - ) - if op.attachments: - for n, (content_type, valb) in op.attachments.items(): - acc_parts.append( - ( - f"attachment.{op.id}.{n}", - ( - None, - valb, - content_type, - {"Content-Length": str(len(valb))}, - ), - ) - ) - return MultipartPartsAndContext( - acc_parts, - f"trace={op.trace_id},id={op.id}", - ) - - def join_multipart_parts_and_context( parts_and_contexts: Iterable[MultipartPartsAndContext], ) -> MultipartPartsAndContext: diff --git a/python/langsmith/_internal/_operations.py b/python/langsmith/_internal/_operations.py new file mode 100644 index 000000000..b1d3d1853 --- /dev/null +++ b/python/langsmith/_internal/_operations.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from typing import Literal, Optional, Union, cast + +from langsmith import schemas as ls_schemas +from langsmith._internal._multipart import MultipartPart, MultipartPartsAndContext +from langsmith._internal._serde import dumps_json as _dumps_json + + +@dataclass +class SerializedRunOperation: + operation: Literal["post", "patch"] + id: uuid.UUID + trace_id: uuid.UUID + + # this is the whole object, minus the other fields which + # are popped (inputs/outputs/events/attachments) + _none: bytes + + inputs: Optional[bytes] + outputs: Optional[bytes] + events: Optional[bytes] + attachments: Optional[ls_schemas.Attachments] + + +@dataclass +class SerializedFeedbackOperation: + id: uuid.UUID + trace_id: uuid.UUID + feedback: bytes + + +def serialize_feedback_dict( + feedback: Union[ls_schemas.FeedbackCreate, dict], +) -> SerializedFeedbackOperation: + if hasattr(feedback, "dict") and callable(getattr(feedback, "dict")): + feedback_create: dict = feedback.dict() # type: ignore + else: + feedback_create = cast(dict, feedback) + if "id" not in feedback_create: + feedback_create["id"] = uuid.uuid4() + elif isinstance(feedback_create["id"], str): + feedback_create["id"] = uuid.UUID(feedback_create["id"]) + if "trace_id" not in feedback_create: + feedback_create["trace_id"] = uuid.uuid4() + elif isinstance(feedback_create["trace_id"], str): + feedback_create["trace_id"] = uuid.UUID(feedback_create["trace_id"]) + + return SerializedFeedbackOperation( + id=feedback_create["id"], + trace_id=feedback_create["trace_id"], + feedback=_dumps_json(feedback_create), + ) + + +def serialize_run_dict( + operation: Literal["post", "patch"], payload: dict +) -> SerializedRunOperation: + inputs = payload.pop("inputs", None) + outputs = payload.pop("outputs", None) + events = payload.pop("events", None) + attachments = payload.pop("attachments", None) + return SerializedRunOperation( + operation=operation, + id=payload["id"], + trace_id=payload["trace_id"], + _none=_dumps_json(payload), + inputs=_dumps_json(inputs) if inputs is not None else None, + outputs=_dumps_json(outputs) if outputs is not None else None, + events=_dumps_json(events) if events is not None else None, + attachments=attachments if attachments is not None else None, + ) + + +def serialized_feedback_operation_to_multipart_parts_and_context( + op: SerializedFeedbackOperation, +) -> MultipartPartsAndContext: + return MultipartPartsAndContext( + [ + ( + f"feedback.{op.id}", + ( + None, + op.feedback, + "application/json", + {"Content-Length": str(len(op.feedback))}, + ), + ) + ], + f"trace={op.trace_id},id={op.id}", + ) + + +def serialized_run_operation_to_multipart_parts_and_context( + op: SerializedRunOperation, +) -> MultipartPartsAndContext: + acc_parts: list[MultipartPart] = [] + for key, value in ( + ("inputs", op.inputs), + ("outputs", op.outputs), + ("events", op.events), + ): + if value is None: + continue + valb = value + acc_parts.append( + ( + f"{op.operation}.{op.id}.{key}", + ( + None, + valb, + "application/json", + {"Content-Length": str(len(valb))}, + ), + ), + ) + if op.attachments: + for n, (content_type, valb) in op.attachments.items(): + acc_parts.append( + ( + f"attachment.{op.id}.{n}", + ( + None, + valb, + content_type, + {"Content-Length": str(len(valb))}, + ), + ) + ) + return MultipartPartsAndContext( + acc_parts, + f"trace={op.trace_id},id={op.id}", + ) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 61ccfc328..672d48934 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -80,9 +80,16 @@ ) from langsmith._internal._multipart import ( MultipartPartsAndContext, + join_multipart_parts_and_context, serialize_feedback_dict, serialize_run_dict, ) +from langsmith._internal._operations import ( + SerializedFeedbackOperation, + SerializedRunOperation, + serialized_feedback_operation_to_multipart_parts_and_context, + serialized_run_operation_to_multipart_parts_and_context, +) from langsmith._internal._serde import dumps_json as _dumps_json try: @@ -1253,6 +1260,99 @@ def _hide_run_outputs(self, outputs: dict): return outputs return self._hide_outputs(outputs) + def _post_batch_ingest_runs(self, body: bytes, *, _context: str): + for api_url, api_key in self._write_api_urls.items(): + try: + self.request_with_retries( + "POST", + f"{api_url}/runs/batch", + request_kwargs={ + "data": body, + "headers": { + **self._headers, + X_API_KEY: api_key, + }, + }, + to_ignore=(ls_utils.LangSmithConflictError,), + stop_after_attempt=3, + _context=_context, + ) + except Exception as e: + try: + exc_desc_lines = traceback.format_exception_only(type(e), e) + exc_desc = "".join(exc_desc_lines).rstrip() + logger.warning(f"Failed to batch ingest runs: {exc_desc}") + except Exception: + logger.warning(f"Failed to batch ingest runs: {repr(e)}") + + def _batch_ingest_ops( + self, + ops: Sequence[SerializedRunOperation], + ) -> None: + ids_and_partial_body: dict[ + Literal["post", "patch"], list[tuple[str, bytes]] + ] = { + "post": [], + "patch": [], + } + + # form the partial body and ids + for op in ops: + if isinstance(op, SerializedRunOperation): + curr_dict = orjson.loads(op._none) + if op.inputs: + curr_dict["inputs"] = orjson.Fragment(op.inputs) + if op.outputs: + curr_dict["outputs"] = orjson.Fragment(op.outputs) + if op.events: + curr_dict["events"] = orjson.Fragment(op.events) + if op.attachments: + logger.warning( + "Attachments are not supported in non-multipart mode" + ) + ids_and_partial_body[op.operation].append( + (f"trace={op.trace_id},id={op.id}", orjson.dumps(curr_dict)) + ) + elif isinstance(op, SerializedFeedbackOperation): + logger.warning( + "Feedback operations are not supported in non-multipart mode" + ) + else: + logger.error("Unknown item type in tracing queue: %s", item) + + # send the requests in batches + info = self.info + size_limit_bytes = (info.batch_ingest_config or {}).get( + "size_limit_bytes" + ) or _SIZE_LIMIT_BYTES + + body_chunks: DefaultDict[str, list] = collections.defaultdict(list) + context_ids: DefaultDict[str, list] = collections.defaultdict(list) + body_size = 0 + for key in cast(list[Literal["post", "patch"]], ["post", "patch"]): + body_deque = collections.deque(ids_and_partial_body[key]) + while body_deque: + if ( + body_size > 0 + and body_size + len(body_deque[0][1]) > size_limit_bytes + ): + self._post_batch_ingest_runs( + orjson.dumps(body_chunks), + _context=f"\n{key}: {'; '.join(context_ids[key])}", + ) + body_size = 0 + body_chunks.clear() + context_ids.clear() + curr_id, curr_body = body_deque.popleft() + body_size += len(curr_body) + body_chunks[key].append(orjson.Fragment(curr_body)) + context_ids[key].append(curr_id) + if body_size: + context = "; ".join(f"{k}: {'; '.join(v)}" for k, v in context_ids.items()) + self._post_batch_ingest_runs( + orjson.dumps(body_chunks), _context="\n" + context + ) + def batch_ingest_runs( self, create: Optional[ @@ -1290,9 +1390,12 @@ def batch_ingest_runs( if not create and not update: return # transform and convert to dicts - create_dicts = [self._run_transform(run) for run in create or EMPTY_SEQ] + create_dicts = [ + self._run_transform(run, copy=True) for run in create or EMPTY_SEQ + ] # still copy create dicts because manipulated in create_by_id update_dicts = [ - self._run_transform(run, update=True) for run in update or EMPTY_SEQ + self._run_transform(run, update=True, copy=False) + for run in update or EMPTY_SEQ ] # combine post and patch dicts where possible if update_dicts and create_dicts: @@ -1317,89 +1420,37 @@ def batch_ingest_runs( "Batch ingest requires trace_id and dotted_order to be set." ) # filter out runs that are not sampled - if pre_sampled: - raw_body = { - "post": create_dicts, - "patch": update_dicts, - } - else: - raw_body = { - "post": self._filter_for_sampling(create_dicts), - "patch": self._filter_for_sampling(update_dicts, patch=True), - } - if not raw_body["post"] and not raw_body["patch"]: - return + if not pre_sampled: + create_dicts = self._filter_for_sampling(create_dicts) + update_dicts = self._filter_for_sampling(update_dicts, patch=True) - self._insert_runtime_env(raw_body["post"] + raw_body["patch"]) - info = self.info + self._insert_runtime_env(create_dicts + update_dicts) - size_limit_bytes = (info.batch_ingest_config or {}).get( - "size_limit_bytes" - ) or _SIZE_LIMIT_BYTES - # Get orjson fragments to avoid going over the max request size - partial_body = { - "post": [_dumps_json(run) for run in raw_body["post"]], - "patch": [_dumps_json(run) for run in raw_body["patch"]], - } - ids = { - "post": [ - f"trace={run.get('trace_id')},id={run.get('id')}" - for run in raw_body["post"] - ], - "patch": [ - f"trace={run.get('trace_id')},id={run.get('id')}" - for run in raw_body["patch"] - ], - } + # convert to serialized ops + serialized_ops = [serialize_run_dict("post", run) for run in create_dicts] + [ + serialize_run_dict("patch", run) for run in update_dicts + ] - body_chunks: DefaultDict[str, list] = collections.defaultdict(list) - context_ids: DefaultDict[str, list] = collections.defaultdict(list) - body_size = 0 - for key in ["post", "patch"]: - body = collections.deque(partial_body[key]) - ids_ = collections.deque(ids[key]) - while body: - if body_size > 0 and body_size + len(body[0]) > size_limit_bytes: - self._post_batch_ingest_runs( - orjson.dumps(body_chunks), - _context=f"\n{key}: {'; '.join(context_ids[key])}", - ) - body_size = 0 - body_chunks.clear() - context_ids.clear() - body_size += len(body[0]) - body_chunks[key].append(orjson.Fragment(body.popleft())) - context_ids[key].append(ids_.popleft()) - if body_size: - context = "; ".join(f"{k}: {'; '.join(v)}" for k, v in context_ids.items()) - self._post_batch_ingest_runs( - orjson.dumps(body_chunks), _context="\n" + context - ) + self._batch_ingest_ops(serialized_ops) - def _post_batch_ingest_runs(self, body: bytes, *, _context: str): - for api_url, api_key in self._write_api_urls.items(): - try: - self.request_with_retries( - "POST", - f"{api_url}/runs/batch", - request_kwargs={ - "data": body, - "headers": { - **self._headers, - X_API_KEY: api_key, - }, - }, - to_ignore=(ls_utils.LangSmithConflictError,), - stop_after_attempt=3, - _context=_context, + def _multipart_ingest_ops( + self, ops: Sequence[Union[SerializedRunOperation, SerializedFeedbackOperation]] + ) -> None: + parts: list[MultipartPartsAndContext] = [] + for op in ops: + if isinstance(op, SerializedRunOperation): + parts.append( + serialized_run_operation_to_multipart_parts_and_context(op) ) - except Exception as e: - try: - exc_desc_lines = traceback.format_exception_only(type(e), e) - exc_desc = "".join(exc_desc_lines).rstrip() - logger.warning(f"Failed to batch ingest runs: {exc_desc}") - except Exception: - logger.warning(f"Failed to batch ingest runs: {repr(e)}") + elif isinstance(op, SerializedFeedbackOperation): + parts.append( + serialized_feedback_operation_to_multipart_parts_and_context(op) + ) + else: + logger.error("Unknown operation type in tracing queue: %s", type(op)) + acc_multipart = join_multipart_parts_and_context(parts) + if acc_multipart: + self._send_multipart_req(acc_multipart) # def multipart_ingest( # self, From 9b10ec8dcebb688cc9e400549c2f4b6047795763 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Wed, 30 Oct 2024 12:59:52 -0700 Subject: [PATCH 27/39] x --- .../langsmith/_internal/_background_thread.py | 7 - python/langsmith/client.py | 232 +++++++++--------- python/tests/unit_tests/test_client.py | 13 +- 3 files changed, 116 insertions(+), 136 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index fc48fb498..8f763f519 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -1,6 +1,5 @@ from __future__ import annotations -import collections import functools import logging import sys @@ -10,21 +9,15 @@ from queue import Empty, Queue from typing import ( TYPE_CHECKING, - DefaultDict, List, - Literal, Union, - cast, ) -import orjson - from langsmith import schemas as ls_schemas from langsmith._internal._constants import ( _AUTO_SCALE_DOWN_NEMPTY_TRIGGER, _AUTO_SCALE_UP_NTHREADS_LIMIT, _AUTO_SCALE_UP_QSIZE_TRIGGER, - _SIZE_LIMIT_BYTES, ) from langsmith._internal._operations import ( SerializedFeedbackOperation, diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 672d48934..7c01d49b9 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -81,12 +81,12 @@ from langsmith._internal._multipart import ( MultipartPartsAndContext, join_multipart_parts_and_context, - serialize_feedback_dict, - serialize_run_dict, ) from langsmith._internal._operations import ( SerializedFeedbackOperation, SerializedRunOperation, + serialize_feedback_dict, + serialize_run_dict, serialized_feedback_operation_to_multipart_parts_and_context, serialized_run_operation_to_multipart_parts_and_context, ) @@ -1318,7 +1318,7 @@ def _batch_ingest_ops( "Feedback operations are not supported in non-multipart mode" ) else: - logger.error("Unknown item type in tracing queue: %s", item) + logger.error("Unknown item type in tracing queue: %s", type(op)) # send the requests in batches info = self.info @@ -1433,119 +1433,6 @@ def batch_ingest_runs( self._batch_ingest_ops(serialized_ops) - def _multipart_ingest_ops( - self, ops: Sequence[Union[SerializedRunOperation, SerializedFeedbackOperation]] - ) -> None: - parts: list[MultipartPartsAndContext] = [] - for op in ops: - if isinstance(op, SerializedRunOperation): - parts.append( - serialized_run_operation_to_multipart_parts_and_context(op) - ) - elif isinstance(op, SerializedFeedbackOperation): - parts.append( - serialized_feedback_operation_to_multipart_parts_and_context(op) - ) - else: - logger.error("Unknown operation type in tracing queue: %s", type(op)) - acc_multipart = join_multipart_parts_and_context(parts) - if acc_multipart: - self._send_multipart_req(acc_multipart) - - # def multipart_ingest( - # self, - # create: Optional[ - # Sequence[Union[ls_schemas.Run, ls_schemas.RunLikeDict, Dict]] - # ] = None, - # update: Optional[ - # Sequence[Union[ls_schemas.Run, ls_schemas.RunLikeDict, Dict]] - # ] = None, - # feedback: Optional[Sequence[Union[ls_schemas.Feedback, Dict]]] = None, - # *, - # pre_sampled: bool = False, - # ) -> None: - # """Batch ingest/upsert multiple runs in the Langsmith system. - - # Args: - # create (Optional[Sequence[Union[ls_schemas.Run, RunLikeDict]]]): - # A sequence of `Run` objects or equivalent dictionaries representing - # runs to be created / posted. - # update (Optional[Sequence[Union[ls_schemas.Run, RunLikeDict]]]): - # A sequence of `Run` objects or equivalent dictionaries representing - # runs that have already been created and should be updated / patched. - # pre_sampled (bool, optional): Whether the runs have already been subject - # to sampling, and therefore should not be sampled again. - # Defaults to False. - - # Returns: - # None - - # Raises: - # LangsmithAPIError: If there is an error in the API request. - - # Note: - # - The run objects MUST contain the dotted_order and trace_id fields - # to be accepted by the API. - # """ - # if not (create or update or feedback): - # return - # # transform and convert to dicts - # all_attachments: Dict[str, ls_schemas.Attachments] = {} - # create_dicts = [self._run_transform(run) for run in create or EMPTY_SEQ] - # update_dicts = [ - # self._run_transform(run, update=True) for run in update or EMPTY_SEQ - # ] - # feedback_dicts = [self._feedback_transform(f) for f in feedback or EMPTY_SEQ] - # # require trace_id and dotted_order - # if create_dicts: - # for run in create_dicts: - # if not run.get("trace_id") or not run.get("dotted_order"): - # raise ls_utils.LangSmithUserError( - # "Multipart ingest requires trace_id and dotted_order" - # " to be set in create dicts." - # ) - # else: - # del run - # if update_dicts: - # for run in update_dicts: - # if not run.get("trace_id") or not run.get("dotted_order"): - # raise ls_utils.LangSmithUserError( - # "Multipart ingest requires trace_id and dotted_order" - # " to be set in update dicts." - # ) - # else: - # del run - # # combine post and patch dicts where possible - # if update_dicts and create_dicts: - # create_by_id = {run["id"]: run for run in create_dicts} - # standalone_updates: list[dict] = [] - # for run in update_dicts: - # if run["id"] in create_by_id: - # for k, v in run.items(): - # if v is not None: - # create_by_id[run["id"]][k] = v - # else: - # standalone_updates.append(run) - # else: - # del run - # update_dicts = standalone_updates - # # filter out runs that are not sampled - # if not pre_sampled: - # create_dicts = self._filter_for_sampling(create_dicts) - # update_dicts = self._filter_for_sampling(update_dicts, patch=True) - # if not create_dicts and not update_dicts and not feedback_dicts: - # return - # # insert runtime environment - # self._insert_runtime_env(create_dicts) - # self._insert_runtime_env(update_dicts) - # # send the runs in multipart requests - # acc: MultipartPartsAndContext = convert_to_multipart_parts_and_context( - # create_dicts, update_dicts, feedback_dicts, all_attachments=all_attachments - # ) - - # # send the request - # self._send_multipart_req(acc) - def _send_multipart_req(self, acc: MultipartPartsAndContext, *, attempts: int = 3): parts = acc.parts _context = acc.context @@ -1589,6 +1476,117 @@ def _send_multipart_req(self, acc: MultipartPartsAndContext, *, attempts: int = # do not retry by default return + def _multipart_ingest_ops( + self, ops: Sequence[Union[SerializedRunOperation, SerializedFeedbackOperation]] + ) -> None: + parts: list[MultipartPartsAndContext] = [] + for op in ops: + if isinstance(op, SerializedRunOperation): + parts.append( + serialized_run_operation_to_multipart_parts_and_context(op) + ) + elif isinstance(op, SerializedFeedbackOperation): + parts.append( + serialized_feedback_operation_to_multipart_parts_and_context(op) + ) + else: + logger.error("Unknown operation type in tracing queue: %s", type(op)) + acc_multipart = join_multipart_parts_and_context(parts) + if acc_multipart: + self._send_multipart_req(acc_multipart) + + def multipart_ingest( + self, + create: Optional[ + Sequence[Union[ls_schemas.Run, ls_schemas.RunLikeDict, Dict]] + ] = None, + update: Optional[ + Sequence[Union[ls_schemas.Run, ls_schemas.RunLikeDict, Dict]] + ] = None, + *, + pre_sampled: bool = False, + ) -> None: + """Batch ingest/upsert multiple runs in the Langsmith system. + + Args: + create (Optional[Sequence[Union[ls_schemas.Run, RunLikeDict]]]): + A sequence of `Run` objects or equivalent dictionaries representing + runs to be created / posted. + update (Optional[Sequence[Union[ls_schemas.Run, RunLikeDict]]]): + A sequence of `Run` objects or equivalent dictionaries representing + runs that have already been created and should be updated / patched. + pre_sampled (bool, optional): Whether the runs have already been subject + to sampling, and therefore should not be sampled again. + Defaults to False. + + Returns: + None + + Raises: + LangsmithAPIError: If there is an error in the API request. + + Note: + - The run objects MUST contain the dotted_order and trace_id fields + to be accepted by the API. + """ + if not (create or update): + return + # transform and convert to dicts + create_dicts = [self._run_transform(run) for run in create or EMPTY_SEQ] + update_dicts = [ + self._run_transform(run, update=True) for run in update or EMPTY_SEQ + ] + # require trace_id and dotted_order + if create_dicts: + for run in create_dicts: + if not run.get("trace_id") or not run.get("dotted_order"): + raise ls_utils.LangSmithUserError( + "Multipart ingest requires trace_id and dotted_order" + " to be set in create dicts." + ) + else: + del run + if update_dicts: + for run in update_dicts: + if not run.get("trace_id") or not run.get("dotted_order"): + raise ls_utils.LangSmithUserError( + "Multipart ingest requires trace_id and dotted_order" + " to be set in update dicts." + ) + else: + del run + # combine post and patch dicts where possible + if update_dicts and create_dicts: + create_by_id = {run["id"]: run for run in create_dicts} + standalone_updates: list[dict] = [] + for run in update_dicts: + if run["id"] in create_by_id: + for k, v in run.items(): + if v is not None: + create_by_id[run["id"]][k] = v + else: + standalone_updates.append(run) + else: + del run + update_dicts = standalone_updates + # filter out runs that are not sampled + if not pre_sampled: + create_dicts = self._filter_for_sampling(create_dicts) + update_dicts = self._filter_for_sampling(update_dicts, patch=True) + if not create_dicts and not update_dicts: + return + # insert runtime environment + self._insert_runtime_env(create_dicts) + self._insert_runtime_env(update_dicts) + + # format as serialized operations + serialized_ops = [serialize_run_dict("post", run) for run in create_dicts] + [ + serialize_run_dict("patch", run) for run in update_dicts + ] + + # sent the runs in multipart requests + self._multipart_ingest_ops(serialized_ops) + def update_run( self, run_id: ID_TYPE, @@ -4298,7 +4296,6 @@ def create_feedback( feedback_group_id=_ensure_uuid(feedback_group_id, accept_null=True), ) - feedback_block = _dumps_json(feedback.dict(exclude_none=True)) use_multipart = (self.info.batch_ingest_config or {}).get( "use_multipart_endpoint", False ) @@ -4313,6 +4310,7 @@ def create_feedback( TracingQueueItem(str(feedback.id), serialized_op) ) else: + feedback_block = _dumps_json(feedback.dict(exclude_none=True)) self.request_with_retries( "POST", "/feedback", diff --git a/python/tests/unit_tests/test_client.py b/python/tests/unit_tests/test_client.py index d5e4b5cde..34b9baf1b 100644 --- a/python/tests/unit_tests/test_client.py +++ b/python/tests/unit_tests/test_client.py @@ -1061,18 +1061,7 @@ def test_batch_ingest_run_splits_large_batches( ] if use_multipart_endpoint: - feedback = [ - { - "run_id": run_id, - "trace_id": run_id, - "key": "test_key", - "score": 0.9, - "value": "test_value", - "comment": "test_comment", - } - for run_id in run_ids - ] - client.multipart_ingest(create=posts, update=patches, feedback=feedback) + client.multipart_ingest(create=posts, update=patches) # multipart endpoint should only send one request expected_num_requests = 1 # count the number of POST requests From 3df031f01ccdcff72d443e0cc7b8b334bc2792f2 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Wed, 30 Oct 2024 13:03:47 -0700 Subject: [PATCH 28/39] x --- python/langsmith/client.py | 130 ++++++++++++++++++------------------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 7c01d49b9..3176625e2 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -1260,31 +1260,6 @@ def _hide_run_outputs(self, outputs: dict): return outputs return self._hide_outputs(outputs) - def _post_batch_ingest_runs(self, body: bytes, *, _context: str): - for api_url, api_key in self._write_api_urls.items(): - try: - self.request_with_retries( - "POST", - f"{api_url}/runs/batch", - request_kwargs={ - "data": body, - "headers": { - **self._headers, - X_API_KEY: api_key, - }, - }, - to_ignore=(ls_utils.LangSmithConflictError,), - stop_after_attempt=3, - _context=_context, - ) - except Exception as e: - try: - exc_desc_lines = traceback.format_exception_only(type(e), e) - exc_desc = "".join(exc_desc_lines).rstrip() - logger.warning(f"Failed to batch ingest runs: {exc_desc}") - except Exception: - logger.warning(f"Failed to batch ingest runs: {repr(e)}") - def _batch_ingest_ops( self, ops: Sequence[SerializedRunOperation], @@ -1433,48 +1408,30 @@ def batch_ingest_runs( self._batch_ingest_ops(serialized_ops) - def _send_multipart_req(self, acc: MultipartPartsAndContext, *, attempts: int = 3): - parts = acc.parts - _context = acc.context + def _post_batch_ingest_runs(self, body: bytes, *, _context: str): for api_url, api_key in self._write_api_urls.items(): - for idx in range(1, attempts + 1): - try: - encoder = MultipartEncoder(parts, boundary=BOUNDARY) - self.request_with_retries( - "POST", - f"{api_url}/runs/multipart", - request_kwargs={ - "data": encoder, - "headers": { - **self._headers, - X_API_KEY: api_key, - "Content-Type": encoder.content_type, - }, + try: + self.request_with_retries( + "POST", + f"{api_url}/runs/batch", + request_kwargs={ + "data": body, + "headers": { + **self._headers, + X_API_KEY: api_key, }, - stop_after_attempt=1, - _context=_context, - ) - break - except ls_utils.LangSmithConflictError: - break - except ( - ls_utils.LangSmithConnectionError, - ls_utils.LangSmithRequestTimeout, - ls_utils.LangSmithAPIError, - ) as exc: - if idx == attempts: - logger.warning(f"Failed to multipart ingest runs: {exc}") - else: - continue - except Exception as e: - try: - exc_desc_lines = traceback.format_exception_only(type(e), e) - exc_desc = "".join(exc_desc_lines).rstrip() - logger.warning(f"Failed to multipart ingest runs: {exc_desc}") - except Exception: - logger.warning(f"Failed to multipart ingest runs: {repr(e)}") - # do not retry by default - return + }, + to_ignore=(ls_utils.LangSmithConflictError,), + stop_after_attempt=3, + _context=_context, + ) + except Exception as e: + try: + exc_desc_lines = traceback.format_exception_only(type(e), e) + exc_desc = "".join(exc_desc_lines).rstrip() + logger.warning(f"Failed to batch ingest runs: {exc_desc}") + except Exception: + logger.warning(f"Failed to batch ingest runs: {repr(e)}") def _multipart_ingest_ops( self, ops: Sequence[Union[SerializedRunOperation, SerializedFeedbackOperation]] @@ -1587,6 +1544,49 @@ def multipart_ingest( # sent the runs in multipart requests self._multipart_ingest_ops(serialized_ops) + def _send_multipart_req(self, acc: MultipartPartsAndContext, *, attempts: int = 3): + parts = acc.parts + _context = acc.context + for api_url, api_key in self._write_api_urls.items(): + for idx in range(1, attempts + 1): + try: + encoder = MultipartEncoder(parts, boundary=BOUNDARY) + self.request_with_retries( + "POST", + f"{api_url}/runs/multipart", + request_kwargs={ + "data": encoder, + "headers": { + **self._headers, + X_API_KEY: api_key, + "Content-Type": encoder.content_type, + }, + }, + stop_after_attempt=1, + _context=_context, + ) + break + except ls_utils.LangSmithConflictError: + break + except ( + ls_utils.LangSmithConnectionError, + ls_utils.LangSmithRequestTimeout, + ls_utils.LangSmithAPIError, + ) as exc: + if idx == attempts: + logger.warning(f"Failed to multipart ingest runs: {exc}") + else: + continue + except Exception as e: + try: + exc_desc_lines = traceback.format_exception_only(type(e), e) + exc_desc = "".join(exc_desc_lines).rstrip() + logger.warning(f"Failed to multipart ingest runs: {exc_desc}") + except Exception: + logger.warning(f"Failed to multipart ingest runs: {repr(e)}") + # do not retry by default + return + def update_run( self, run_id: ID_TYPE, From 39800d5f06b0a400c6fdfa13d995ddcb55a3788b Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Wed, 30 Oct 2024 13:43:47 -0700 Subject: [PATCH 29/39] x --- .../langsmith/_internal/_background_thread.py | 18 ++---- python/langsmith/_internal/_operations.py | 64 ++++++++++++++++++- python/langsmith/client.py | 39 ++++++----- python/tests/unit_tests/test_client.py | 6 +- 4 files changed, 91 insertions(+), 36 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 8f763f519..a0c7b2817 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -9,8 +9,10 @@ from queue import Empty, Queue from typing import ( TYPE_CHECKING, + Iterable, List, Union, + cast, ) from langsmith import schemas as ls_schemas @@ -22,6 +24,7 @@ from langsmith._internal._operations import ( SerializedFeedbackOperation, SerializedRunOperation, + combine_serialized_run_operations, ) if TYPE_CHECKING: @@ -85,22 +88,15 @@ def _tracing_thread_handle_batch( use_multipart: bool, ) -> None: try: + ops = combine_serialized_run_operations(item.item for item in batch) if use_multipart: - client._multipart_ingest_ops([item.item for item in batch]) + client._multipart_ingest_ops(ops) else: - if any( - isinstance(item.item, SerializedFeedbackOperation) for item in batch - ): + if any(isinstance(op, SerializedFeedbackOperation) for op in ops): logger.warn( "Feedback operations are not supported in non-multipart mode" ) - client._batch_ingest_ops( - [ - item.item - for item in batch - if isinstance(item.item, SerializedRunOperation) - ] - ) + client._batch_ingest_ops(cast(Iterable[SerializedRunOperation], ops)) except Exception: logger.error("Error in tracing queue", exc_info=True) diff --git a/python/langsmith/_internal/_operations.py b/python/langsmith/_internal/_operations.py index b1d3d1853..f51ac1bc2 100644 --- a/python/langsmith/_internal/_operations.py +++ b/python/langsmith/_internal/_operations.py @@ -1,8 +1,11 @@ from __future__ import annotations +import itertools import uuid from dataclasses import dataclass -from typing import Literal, Optional, Union, cast +from typing import Iterable, Literal, Optional, Union, cast + +import orjson from langsmith import schemas as ls_schemas from langsmith._internal._multipart import MultipartPart, MultipartPartsAndContext @@ -74,6 +77,52 @@ def serialize_run_dict( ) +def combine_serialized_run_operations( + ops: Iterable[Union[SerializedRunOperation, SerializedFeedbackOperation]], +) -> Iterable[Union[SerializedRunOperation, SerializedFeedbackOperation]]: + create_ops_by_id = { + op.id: op + for op in ops + if isinstance(op, SerializedRunOperation) and op.operation == "post" + } + passthrough_ops: list[ + Union[SerializedRunOperation, SerializedFeedbackOperation] + ] = [] + for op in ops: + if isinstance(op, SerializedRunOperation): + if op.operation == "post": + continue + + # must be patch + + create_op = create_ops_by_id.get(op.id) + if create_op is None: + passthrough_ops.append(op) + continue + + if op._none is not None and op._none != create_op._none: + # TODO optimize this more - this would currently be slowest + # for large payloads + create_op_dict = orjson.loads(create_op._none) + op_dict = orjson.loads(op._none) + create_op_dict.update(op_dict) + create_op._none = orjson.dumps(create_op_dict) + + if op.inputs is not None: + create_op.inputs = op.inputs + if op.outputs is not None: + create_op.outputs = op.outputs + if op.events is not None: + create_op.events = op.events + if op.attachments is not None: + if create_op.attachments is None: + create_op.attachments = {} + create_op.attachments.update(op.attachments) + else: + passthrough_ops.append(op) + return itertools.chain(create_ops_by_id.values(), passthrough_ops) + + def serialized_feedback_operation_to_multipart_parts_and_context( op: SerializedFeedbackOperation, ) -> MultipartPartsAndContext: @@ -97,6 +146,19 @@ def serialized_run_operation_to_multipart_parts_and_context( op: SerializedRunOperation, ) -> MultipartPartsAndContext: acc_parts: list[MultipartPart] = [] + + # this is main object, minus inputs/outputs/events/attachments + acc_parts.append( + ( + f"{op.operation}.{op.id}", + ( + None, + op._none, + "application/json", + {"Content-Length": str(len(op._none))}, + ), + ) + ) for key, value in ( ("inputs", op.inputs), ("outputs", op.outputs), diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 3176625e2..d24b534d7 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -21,6 +21,7 @@ import importlib import importlib.metadata import io +import itertools import json import logging import os @@ -85,6 +86,7 @@ from langsmith._internal._operations import ( SerializedFeedbackOperation, SerializedRunOperation, + combine_serialized_run_operations, serialize_feedback_dict, serialize_run_dict, serialized_feedback_operation_to_multipart_parts_and_context, @@ -1262,7 +1264,7 @@ def _hide_run_outputs(self, outputs: dict): def _batch_ingest_ops( self, - ops: Sequence[SerializedRunOperation], + ops: Iterable[SerializedRunOperation], ) -> None: ids_and_partial_body: dict[ Literal["post", "patch"], list[tuple[str, bytes]] @@ -1372,18 +1374,6 @@ def batch_ingest_runs( self._run_transform(run, update=True, copy=False) for run in update or EMPTY_SEQ ] - # combine post and patch dicts where possible - if update_dicts and create_dicts: - create_by_id = {run["id"]: run for run in create_dicts} - standalone_updates: list[dict] = [] - for run in update_dicts: - if run["id"] in create_by_id: - create_by_id[run["id"]].update( - {k: v for k, v in run.items() if v is not None} - ) - else: - standalone_updates.append(run) - update_dicts = standalone_updates for run in create_dicts: if not run.get("trace_id") or not run.get("dotted_order"): raise ls_utils.LangSmithUserError( @@ -1402,9 +1392,15 @@ def batch_ingest_runs( self._insert_runtime_env(create_dicts + update_dicts) # convert to serialized ops - serialized_ops = [serialize_run_dict("post", run) for run in create_dicts] + [ - serialize_run_dict("patch", run) for run in update_dicts - ] + serialized_ops = cast( + Iterable[SerializedRunOperation], + combine_serialized_run_operations( + itertools.chain( + (serialize_run_dict("post", run) for run in create_dicts), + (serialize_run_dict("patch", run) for run in update_dicts), + ) + ), + ) self._batch_ingest_ops(serialized_ops) @@ -1434,7 +1430,7 @@ def _post_batch_ingest_runs(self, body: bytes, *, _context: str): logger.warning(f"Failed to batch ingest runs: {repr(e)}") def _multipart_ingest_ops( - self, ops: Sequence[Union[SerializedRunOperation, SerializedFeedbackOperation]] + self, ops: Iterable[Union[SerializedRunOperation, SerializedFeedbackOperation]] ) -> None: parts: list[MultipartPartsAndContext] = [] for op in ops: @@ -1537,9 +1533,12 @@ def multipart_ingest( self._insert_runtime_env(update_dicts) # format as serialized operations - serialized_ops = [serialize_run_dict("post", run) for run in create_dicts] + [ - serialize_run_dict("patch", run) for run in update_dicts - ] + serialized_ops = combine_serialized_run_operations( + itertools.chain( + (serialize_run_dict("post", run) for run in create_dicts), + (serialize_run_dict("patch", run) for run in update_dicts), + ) + ) # sent the runs in multipart requests self._multipart_ingest_ops(serialized_ops) diff --git a/python/tests/unit_tests/test_client.py b/python/tests/unit_tests/test_client.py index 34b9baf1b..09e8120d2 100644 --- a/python/tests/unit_tests/test_client.py +++ b/python/tests/unit_tests/test_client.py @@ -287,9 +287,6 @@ def test_create_run_unicode() -> None: def test_create_run_mutate( use_multipart_endpoint: bool, monkeypatch: pytest.MonkeyPatch ) -> None: - if use_multipart_endpoint: - monkeypatch.setenv("LANGSMITH_FF_MULTIPART", "true") - # TODO remove this when removing FF inputs = {"messages": ["hi"], "mygen": (i for i in range(10))} session = mock.Mock() session.request = mock.Mock() @@ -352,8 +349,9 @@ def test_create_run_mutate( boundary = parse_options_header(headers["Content-Type"])[1]["boundary"] parser = MultipartParser(data, boundary) parts.extend(parser.parts()) + import pdb - assert len(parts) == 3 + pdb.set_trace() assert [p.name for p in parts] == [ f"post.{id_}", f"post.{id_}.inputs", From fd455b68c30f8dc2cdf2f74bd7a9cc11c70c3d70 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Wed, 30 Oct 2024 14:19:19 -0700 Subject: [PATCH 30/39] x --- python/langsmith/_internal/_background_thread.py | 6 ++++-- python/langsmith/_internal/_operations.py | 4 ++-- python/langsmith/client.py | 6 +++--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index a0c7b2817..79e930ff4 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -9,7 +9,6 @@ from queue import Empty, Queue from typing import ( TYPE_CHECKING, - Iterable, List, Union, cast, @@ -96,7 +95,10 @@ def _tracing_thread_handle_batch( logger.warn( "Feedback operations are not supported in non-multipart mode" ) - client._batch_ingest_ops(cast(Iterable[SerializedRunOperation], ops)) + ops = [ + op for op in ops if not isinstance(op, SerializedFeedbackOperation) + ] + client._batch_ingest_ops(cast(List[SerializedRunOperation], ops)) except Exception: logger.error("Error in tracing queue", exc_info=True) diff --git a/python/langsmith/_internal/_operations.py b/python/langsmith/_internal/_operations.py index f51ac1bc2..c14a9c698 100644 --- a/python/langsmith/_internal/_operations.py +++ b/python/langsmith/_internal/_operations.py @@ -79,7 +79,7 @@ def serialize_run_dict( def combine_serialized_run_operations( ops: Iterable[Union[SerializedRunOperation, SerializedFeedbackOperation]], -) -> Iterable[Union[SerializedRunOperation, SerializedFeedbackOperation]]: +) -> list[Union[SerializedRunOperation, SerializedFeedbackOperation]]: create_ops_by_id = { op.id: op for op in ops @@ -120,7 +120,7 @@ def combine_serialized_run_operations( create_op.attachments.update(op.attachments) else: passthrough_ops.append(op) - return itertools.chain(create_ops_by_id.values(), passthrough_ops) + return list(itertools.chain(create_ops_by_id.values(), passthrough_ops)) def serialized_feedback_operation_to_multipart_parts_and_context( diff --git a/python/langsmith/client.py b/python/langsmith/client.py index d24b534d7..63de14f34 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -1264,7 +1264,7 @@ def _hide_run_outputs(self, outputs: dict): def _batch_ingest_ops( self, - ops: Iterable[SerializedRunOperation], + ops: List[SerializedRunOperation], ) -> None: ids_and_partial_body: dict[ Literal["post", "patch"], list[tuple[str, bytes]] @@ -1393,7 +1393,7 @@ def batch_ingest_runs( # convert to serialized ops serialized_ops = cast( - Iterable[SerializedRunOperation], + List[SerializedRunOperation], combine_serialized_run_operations( itertools.chain( (serialize_run_dict("post", run) for run in create_dicts), @@ -1430,7 +1430,7 @@ def _post_batch_ingest_runs(self, body: bytes, *, _context: str): logger.warning(f"Failed to batch ingest runs: {repr(e)}") def _multipart_ingest_ops( - self, ops: Iterable[Union[SerializedRunOperation, SerializedFeedbackOperation]] + self, ops: list[Union[SerializedRunOperation, SerializedFeedbackOperation]] ) -> None: parts: list[MultipartPartsAndContext] = [] for op in ops: From 1ccb3cc6dc695318289c68e785bb6ec7c5ddf73b Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Wed, 30 Oct 2024 17:28:03 -0700 Subject: [PATCH 31/39] x --- python/tests/unit_tests/test_client.py | 2 - python/tests/unit_tests/test_operations.py | 112 +++++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 python/tests/unit_tests/test_operations.py diff --git a/python/tests/unit_tests/test_client.py b/python/tests/unit_tests/test_client.py index 09e8120d2..b158b48cd 100644 --- a/python/tests/unit_tests/test_client.py +++ b/python/tests/unit_tests/test_client.py @@ -349,9 +349,7 @@ def test_create_run_mutate( boundary = parse_options_header(headers["Content-Type"])[1]["boundary"] parser = MultipartParser(data, boundary) parts.extend(parser.parts()) - import pdb - pdb.set_trace() assert [p.name for p in parts] == [ f"post.{id_}", f"post.{id_}.inputs", diff --git a/python/tests/unit_tests/test_operations.py b/python/tests/unit_tests/test_operations.py new file mode 100644 index 000000000..aaf27ee02 --- /dev/null +++ b/python/tests/unit_tests/test_operations.py @@ -0,0 +1,112 @@ +from langsmith._internal._operations import ( + combine_serialized_run_operations, + SerializedRunOperation, + SerializedFeedbackOperation, + serialize_run_dict, + serialize_feedback_dict, +) + + +def test_combine_serialized_run_operations(): + # Arrange + serialized_run_operations = [ + SerializedRunOperation( + operation="post", + id="id1", + trace_id="trace_id1", + _none="none1", + inputs="inputs1", + outputs="outputs1", + events="events1", + attachments=None, + ), + SerializedRunOperation( + operation="patch", + id="id1", + trace_id="trace_id1", + _none="none1", + inputs="inputs1-patched", + outputs="outputs1-patched", + events="events1", + attachments=None, + ), + SerializedFeedbackOperation( + id="id2", + trace_id="trace_id2", + feedback="feedback2", + ), + SerializedRunOperation( + operation="post", + id="id3", + trace_id="trace_id3", + _none="none3", + inputs="inputs3", + outputs="outputs3", + events="events3", + attachments=None, + ), + SerializedRunOperation( + operation="patch", + id="id4", + trace_id="trace_id4", + _none="none4", + inputs="inputs4-patched", + outputs="outputs4-patched", + events="events4", + attachments=None, + ), + SerializedRunOperation( + operation="post", + id="id5", + trace_id="trace_id5", + _none="none5", + inputs="inputs5", + outputs=None, + events="events5", + attachments=None, + ), + SerializedRunOperation( + operation="patch", + id="id5", + trace_id="trace_id5", + _none=None, + inputs=None, + outputs="outputs5-patched", + events=None, + attachments=None, + ), + ] + + # Act + result = combine_serialized_run_operations(serialized_run_operations) + + # Assert + assert result == [ + # merged 1+2 + SerializedRunOperation( + operation="post", + id="id1", + trace_id="trace_id1", + _none="none1", + inputs="inputs1-patched", + outputs="outputs1-patched", + events="events1", + attachments=None, + ), + # 4 passthrough + serialized_run_operations[3], + # merged 6+7 + SerializedRunOperation( + operation="post", + id="id5", + trace_id="trace_id5", + _none="none5", + inputs="inputs5", + outputs="outputs5-patched", + events="events5", + attachments=None, + ), + # 3,5 are passthrough in that order + serialized_run_operations[2], + serialized_run_operations[4], + ] From a21cbdb657aea849933fee42003eb22e285fe14b Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Wed, 30 Oct 2024 17:44:11 -0700 Subject: [PATCH 32/39] x --- .../langsmith/_internal/_background_thread.py | 2 +- python/langsmith/_internal/_operations.py | 8 +++++--- python/langsmith/client.py | 18 +++++++++++------- python/tests/unit_tests/test_operations.py | 14 +++++++------- 4 files changed, 24 insertions(+), 18 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 79e930ff4..27e533517 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -87,7 +87,7 @@ def _tracing_thread_handle_batch( use_multipart: bool, ) -> None: try: - ops = combine_serialized_run_operations(item.item for item in batch) + ops = combine_serialized_run_operations([item.item for item in batch]) if use_multipart: client._multipart_ingest_ops(ops) else: diff --git a/python/langsmith/_internal/_operations.py b/python/langsmith/_internal/_operations.py index c14a9c698..6c1388638 100644 --- a/python/langsmith/_internal/_operations.py +++ b/python/langsmith/_internal/_operations.py @@ -3,7 +3,7 @@ import itertools import uuid from dataclasses import dataclass -from typing import Iterable, Literal, Optional, Union, cast +from typing import Literal, Optional, Union, cast import orjson @@ -78,7 +78,7 @@ def serialize_run_dict( def combine_serialized_run_operations( - ops: Iterable[Union[SerializedRunOperation, SerializedFeedbackOperation]], + ops: list[Union[SerializedRunOperation, SerializedFeedbackOperation]], ) -> list[Union[SerializedRunOperation, SerializedFeedbackOperation]]: create_ops_by_id = { op.id: op @@ -104,7 +104,9 @@ def combine_serialized_run_operations( # TODO optimize this more - this would currently be slowest # for large payloads create_op_dict = orjson.loads(create_op._none) - op_dict = orjson.loads(op._none) + op_dict = { + k: v for k, v in orjson.loads(op._none).items() if v is not None + } create_op_dict.update(op_dict) create_op._none = orjson.dumps(create_op_dict) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 63de14f34..293cb30fc 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -1393,11 +1393,13 @@ def batch_ingest_runs( # convert to serialized ops serialized_ops = cast( - List[SerializedRunOperation], + list[SerializedRunOperation], combine_serialized_run_operations( - itertools.chain( - (serialize_run_dict("post", run) for run in create_dicts), - (serialize_run_dict("patch", run) for run in update_dicts), + list( + itertools.chain( + (serialize_run_dict("post", run) for run in create_dicts), + (serialize_run_dict("patch", run) for run in update_dicts), + ) ) ), ) @@ -1534,9 +1536,11 @@ def multipart_ingest( # format as serialized operations serialized_ops = combine_serialized_run_operations( - itertools.chain( - (serialize_run_dict("post", run) for run in create_dicts), - (serialize_run_dict("patch", run) for run in update_dicts), + list( + itertools.chain( + (serialize_run_dict("post", run) for run in create_dicts), + (serialize_run_dict("patch", run) for run in update_dicts), + ) ) ) diff --git a/python/tests/unit_tests/test_operations.py b/python/tests/unit_tests/test_operations.py index aaf27ee02..028b3f8c4 100644 --- a/python/tests/unit_tests/test_operations.py +++ b/python/tests/unit_tests/test_operations.py @@ -1,9 +1,9 @@ +import orjson + from langsmith._internal._operations import ( - combine_serialized_run_operations, - SerializedRunOperation, SerializedFeedbackOperation, - serialize_run_dict, - serialize_feedback_dict, + SerializedRunOperation, + combine_serialized_run_operations, ) @@ -14,7 +14,7 @@ def test_combine_serialized_run_operations(): operation="post", id="id1", trace_id="trace_id1", - _none="none1", + _none=orjson.dumps({"a": 1}), inputs="inputs1", outputs="outputs1", events="events1", @@ -24,7 +24,7 @@ def test_combine_serialized_run_operations(): operation="patch", id="id1", trace_id="trace_id1", - _none="none1", + _none=orjson.dumps({"b": "2"}), inputs="inputs1-patched", outputs="outputs1-patched", events="events1", @@ -87,7 +87,7 @@ def test_combine_serialized_run_operations(): operation="post", id="id1", trace_id="trace_id1", - _none="none1", + _none=orjson.dumps({"a": 1, "b": "2"}), inputs="inputs1-patched", outputs="outputs1-patched", events="events1", From c9ed00de4c5d4a841c97eeb484a3a34422be2d20 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Thu, 31 Oct 2024 13:00:40 -0700 Subject: [PATCH 33/39] x --- python/bench/create_run.py | 2 +- python/langsmith/_internal/_background_thread.py | 4 ++-- python/langsmith/_internal/_operations.py | 2 +- python/langsmith/client.py | 6 +++--- python/tests/unit_tests/test_operations.py | 6 +++--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/python/bench/create_run.py b/python/bench/create_run.py index a907a1f61..7114d887b 100644 --- a/python/bench/create_run.py +++ b/python/bench/create_run.py @@ -155,4 +155,4 @@ def test_benchmark_runs( if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) - test_benchmark_runs(json_size=1_000, num_runs=500, samples=1, benchmark_thread=True) + test_benchmark_runs(json_size=5000, num_runs=1000, samples=1, benchmark_thread=True) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 27e533517..c6ab8caef 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -23,7 +23,7 @@ from langsmith._internal._operations import ( SerializedFeedbackOperation, SerializedRunOperation, - combine_serialized_run_operations, + combine_serialized_queue_operations, ) if TYPE_CHECKING: @@ -87,7 +87,7 @@ def _tracing_thread_handle_batch( use_multipart: bool, ) -> None: try: - ops = combine_serialized_run_operations([item.item for item in batch]) + ops = combine_serialized_queue_operations([item.item for item in batch]) if use_multipart: client._multipart_ingest_ops(ops) else: diff --git a/python/langsmith/_internal/_operations.py b/python/langsmith/_internal/_operations.py index 6c1388638..9cbcce6d1 100644 --- a/python/langsmith/_internal/_operations.py +++ b/python/langsmith/_internal/_operations.py @@ -77,7 +77,7 @@ def serialize_run_dict( ) -def combine_serialized_run_operations( +def combine_serialized_queue_operations( ops: list[Union[SerializedRunOperation, SerializedFeedbackOperation]], ) -> list[Union[SerializedRunOperation, SerializedFeedbackOperation]]: create_ops_by_id = { diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 293cb30fc..e9b4175ea 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -86,7 +86,7 @@ from langsmith._internal._operations import ( SerializedFeedbackOperation, SerializedRunOperation, - combine_serialized_run_operations, + combine_serialized_queue_operations, serialize_feedback_dict, serialize_run_dict, serialized_feedback_operation_to_multipart_parts_and_context, @@ -1394,7 +1394,7 @@ def batch_ingest_runs( # convert to serialized ops serialized_ops = cast( list[SerializedRunOperation], - combine_serialized_run_operations( + combine_serialized_queue_operations( list( itertools.chain( (serialize_run_dict("post", run) for run in create_dicts), @@ -1535,7 +1535,7 @@ def multipart_ingest( self._insert_runtime_env(update_dicts) # format as serialized operations - serialized_ops = combine_serialized_run_operations( + serialized_ops = combine_serialized_queue_operations( list( itertools.chain( (serialize_run_dict("post", run) for run in create_dicts), diff --git a/python/tests/unit_tests/test_operations.py b/python/tests/unit_tests/test_operations.py index 028b3f8c4..a6b5cdeb3 100644 --- a/python/tests/unit_tests/test_operations.py +++ b/python/tests/unit_tests/test_operations.py @@ -3,11 +3,11 @@ from langsmith._internal._operations import ( SerializedFeedbackOperation, SerializedRunOperation, - combine_serialized_run_operations, + combine_serialized_queue_operations, ) -def test_combine_serialized_run_operations(): +def test_combine_serialized_queue_operations(): # Arrange serialized_run_operations = [ SerializedRunOperation( @@ -78,7 +78,7 @@ def test_combine_serialized_run_operations(): ] # Act - result = combine_serialized_run_operations(serialized_run_operations) + result = combine_serialized_queue_operations(serialized_run_operations) # Assert assert result == [ From 07f7df92d273b98f21bf5da7e1c980f7bb622375 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Thu, 31 Oct 2024 13:10:09 -0700 Subject: [PATCH 34/39] x --- python/tests/integration_tests/test_client.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/python/tests/integration_tests/test_client.py b/python/tests/integration_tests/test_client.py index d5a39ab42..02af08d9d 100644 --- a/python/tests/integration_tests/test_client.py +++ b/python/tests/integration_tests/test_client.py @@ -682,20 +682,7 @@ def test_batch_ingest_runs( }, ] if use_multipart_endpoint: - feedback = [ - { - "run_id": run["id"], - "trace_id": run["trace_id"], - "key": "test_key", - "score": 0.9, - "value": "test_value", - "comment": "test_comment", - } - for run in runs_to_create - ] - langchain_client.multipart_ingest( - create=runs_to_create, update=runs_to_update, feedback=feedback - ) + langchain_client.multipart_ingest(create=runs_to_create, update=runs_to_update) else: langchain_client.batch_ingest_runs(create=runs_to_create, update=runs_to_update) runs = [] From ff90574e89973030a8c1f767dd37ddf20bb6fbfd Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Thu, 31 Oct 2024 13:23:21 -0700 Subject: [PATCH 35/39] x --- .../langsmith/_internal/_background_thread.py | 2 +- python/langsmith/client.py | 14 ++++++---- python/tests/integration_tests/test_client.py | 28 ------------------- 3 files changed, 10 insertions(+), 34 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index c6ab8caef..2225fe267 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -98,7 +98,7 @@ def _tracing_thread_handle_batch( ops = [ op for op in ops if not isinstance(op, SerializedFeedbackOperation) ] - client._batch_ingest_ops(cast(List[SerializedRunOperation], ops)) + client._batch_ingest_run_ops(cast(List[SerializedRunOperation], ops)) except Exception: logger.error("Error in tracing queue", exc_info=True) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index ad3734376..2192f86fb 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -1262,7 +1262,7 @@ def _hide_run_outputs(self, outputs: dict): return outputs return self._hide_outputs(outputs) - def _batch_ingest_ops( + def _batch_ingest_run_ops( self, ops: List[SerializedRunOperation], ) -> None: @@ -1285,7 +1285,8 @@ def _batch_ingest_ops( curr_dict["events"] = orjson.Fragment(op.events) if op.attachments: logger.warning( - "Attachments are not supported in non-multipart mode" + "Attachments are not supported when use_multipart_endpoint " + "is False" ) ids_and_partial_body[op.operation].append( (f"trace={op.trace_id},id={op.id}", orjson.dumps(curr_dict)) @@ -1368,8 +1369,8 @@ def batch_ingest_runs( return # transform and convert to dicts create_dicts = [ - self._run_transform(run, copy=True) for run in create or EMPTY_SEQ - ] # still copy create dicts because manipulated in create_by_id + self._run_transform(run, copy=False) for run in create or EMPTY_SEQ + ] update_dicts = [ self._run_transform(run, update=True, copy=False) for run in update or EMPTY_SEQ @@ -1389,6 +1390,9 @@ def batch_ingest_runs( create_dicts = self._filter_for_sampling(create_dicts) update_dicts = self._filter_for_sampling(update_dicts, patch=True) + if not create_dicts and not update_dicts: + return + self._insert_runtime_env(create_dicts + update_dicts) # convert to serialized ops @@ -1404,7 +1408,7 @@ def batch_ingest_runs( ), ) - self._batch_ingest_ops(serialized_ops) + self._batch_ingest_run_ops(serialized_ops) def _post_batch_ingest_runs(self, body: bytes, *, _context: str): for api_url, api_key in self._write_api_urls.items(): diff --git a/python/tests/integration_tests/test_client.py b/python/tests/integration_tests/test_client.py index 02af08d9d..0cf762859 100644 --- a/python/tests/integration_tests/test_client.py +++ b/python/tests/integration_tests/test_client.py @@ -722,34 +722,6 @@ def test_batch_ingest_runs( assert run3.inputs == {"input1": 1, "input2": 2} assert run3.error == "error" - if use_multipart_endpoint: - feedbacks = list( - langchain_client.list_feedback(run_ids=[run.id for run in runs]) - ) - assert len(feedbacks) == 3 - for feedback in feedbacks: - assert feedback.key == "test_key" - assert feedback.score == 0.9 - assert feedback.value == "test_value" - assert feedback.comment == "test_comment" - - -""" -Multipart partitions: -- num created: [0], [1], >1 -- num updated: [0], [1], >1 -- num created + num updated: [0], [1], >1 -- individual id: created only, updated only, both -- [updated is root trace], [updated is run] - -Error cases: -- dual created -- dual updated -- created and dual updated [? maybe not an error] -- dual created and single updated -- retry doesn't fail -""" - def test_multipart_ingest_empty( langchain_client: Client, caplog: pytest.LogCaptureFixture From 912969d789d443333a9edb0699111abd71641fa3 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Thu, 31 Oct 2024 13:45:34 -0700 Subject: [PATCH 36/39] x --- python/langsmith/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index 2192f86fb..d58ddc915 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -1397,7 +1397,7 @@ def batch_ingest_runs( # convert to serialized ops serialized_ops = cast( - list[SerializedRunOperation], + List[SerializedRunOperation], combine_serialized_queue_operations( list( itertools.chain( From f33f9621c33534fad72a16f6f6f39ae0c4cf06e1 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Thu, 31 Oct 2024 15:46:58 -0700 Subject: [PATCH 37/39] x --- python/langsmith/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/langsmith/client.py b/python/langsmith/client.py index d58ddc915..a343bbfb2 100644 --- a/python/langsmith/client.py +++ b/python/langsmith/client.py @@ -1307,7 +1307,7 @@ def _batch_ingest_run_ops( body_chunks: DefaultDict[str, list] = collections.defaultdict(list) context_ids: DefaultDict[str, list] = collections.defaultdict(list) body_size = 0 - for key in cast(list[Literal["post", "patch"]], ["post", "patch"]): + for key in cast(List[Literal["post", "patch"]], ["post", "patch"]): body_deque = collections.deque(ids_and_partial_body[key]) while body_deque: if ( From cc943172d323ca3c95408f792a900676e58e10bc Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Thu, 31 Oct 2024 15:56:21 -0700 Subject: [PATCH 38/39] x --- .../langsmith/_internal/_background_thread.py | 12 +++- python/langsmith/_internal/_multipart.py | 8 ++- python/langsmith/_internal/_operations.py | 69 ++++++++++++++++++- 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/python/langsmith/_internal/_background_thread.py b/python/langsmith/_internal/_background_thread.py index 2225fe267..b6aee1f4e 100644 --- a/python/langsmith/_internal/_background_thread.py +++ b/python/langsmith/_internal/_background_thread.py @@ -5,7 +5,6 @@ import sys import threading import weakref -from dataclasses import dataclass from queue import Empty, Queue from typing import ( TYPE_CHECKING, @@ -33,7 +32,6 @@ @functools.total_ordering -@dataclass class TracingQueueItem: """An item in the tracing queue. @@ -46,6 +44,16 @@ class TracingQueueItem: priority: str item: Union[SerializedRunOperation, SerializedFeedbackOperation] + __slots__ = ("priority", "item") + + def __init__( + self, + priority: str, + item: Union[SerializedRunOperation, SerializedFeedbackOperation], + ) -> None: + self.priority = priority + self.item = item + def __lt__(self, other: TracingQueueItem) -> bool: return (self.priority, self.item.__class__) < ( other.priority, diff --git a/python/langsmith/_internal/_multipart.py b/python/langsmith/_internal/_multipart.py index 535097609..ca7c6e656 100644 --- a/python/langsmith/_internal/_multipart.py +++ b/python/langsmith/_internal/_multipart.py @@ -1,16 +1,20 @@ from __future__ import annotations -from dataclasses import dataclass from typing import Dict, Iterable, Tuple MultipartPart = Tuple[str, Tuple[None, bytes, str, Dict[str, str]]] -@dataclass class MultipartPartsAndContext: parts: list[MultipartPart] context: str + __slots__ = ("parts", "context") + + def __init__(self, parts: list[MultipartPart], context: str) -> None: + self.parts = parts + self.context = context + def join_multipart_parts_and_context( parts_and_contexts: Iterable[MultipartPartsAndContext], diff --git a/python/langsmith/_internal/_operations.py b/python/langsmith/_internal/_operations.py index 9cbcce6d1..1ba99a6db 100644 --- a/python/langsmith/_internal/_operations.py +++ b/python/langsmith/_internal/_operations.py @@ -2,7 +2,6 @@ import itertools import uuid -from dataclasses import dataclass from typing import Literal, Optional, Union, cast import orjson @@ -12,7 +11,6 @@ from langsmith._internal._serde import dumps_json as _dumps_json -@dataclass class SerializedRunOperation: operation: Literal["post", "patch"] id: uuid.UUID @@ -27,13 +25,78 @@ class SerializedRunOperation: events: Optional[bytes] attachments: Optional[ls_schemas.Attachments] + __slots__ = ( + "operation", + "id", + "trace_id", + "_none", + "inputs", + "outputs", + "events", + "attachments", + ) + + def __init__( + self, + operation: Literal["post", "patch"], + id: uuid.UUID, + trace_id: uuid.UUID, + _none: bytes, + inputs: Optional[bytes] = None, + outputs: Optional[bytes] = None, + events: Optional[bytes] = None, + attachments: Optional[ls_schemas.Attachments] = None, + ) -> None: + self.operation = operation + self.id = id + self.trace_id = trace_id + self._none = _none + self.inputs = inputs + self.outputs = outputs + self.events = events + self.attachments = attachments + + def __eq__(self, other: object) -> bool: + return isinstance(other, SerializedRunOperation) and ( + self.operation, + self.id, + self.trace_id, + self._none, + self.inputs, + self.outputs, + self.events, + self.attachments, + ) == ( + other.operation, + other.id, + other.trace_id, + other._none, + other.inputs, + other.outputs, + other.events, + other.attachments, + ) + -@dataclass class SerializedFeedbackOperation: id: uuid.UUID trace_id: uuid.UUID feedback: bytes + __slots__ = ("id", "trace_id", "feedback") + + def __init__(self, id: uuid.UUID, trace_id: uuid.UUID, feedback: bytes) -> None: + self.id = id + self.trace_id = trace_id + self.feedback = feedback + + def __eq__(self, other: object) -> bool: + return isinstance(other, SerializedFeedbackOperation) and ( + self.id, + self.trace_id, + self.feedback, + ) == (other.id, other.trace_id, other.feedback) + def serialize_feedback_dict( feedback: Union[ls_schemas.FeedbackCreate, dict], From a41bdbe05764e69d63963e7779f4c6da6f101f73 Mon Sep 17 00:00:00 2001 From: Erick Friis Date: Thu, 31 Oct 2024 15:58:34 -0700 Subject: [PATCH 39/39] prerelease version --- python/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 5bf9b11aa..f062863d1 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "langsmith" -version = "0.1.138" +version = "0.1.139rc1" description = "Client library to connect to the LangSmith LLM Tracing and Evaluation Platform." authors = ["LangChain "] license = "MIT"