Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add upset examples multipart endpoint #1209

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions python/langsmith/client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Client for interacting with the LangSmith API.

Check notice on line 1 in python/langsmith/client.py

View workflow job for this annotation

GitHub Actions / benchmark

Benchmark results

......................................... create_5_000_run_trees: Mean +- std dev: 617 ms +- 42 ms ......................................... create_10_000_run_trees: Mean +- std dev: 1.18 sec +- 0.05 sec ......................................... create_20_000_run_trees: Mean +- std dev: 1.18 sec +- 0.05 sec ......................................... dumps_class_nested_py_branch_and_leaf_200x400: Mean +- std dev: 715 us +- 7 us ......................................... dumps_class_nested_py_leaf_50x100: Mean +- std dev: 25.3 ms +- 0.6 ms ......................................... dumps_class_nested_py_leaf_100x200: Mean +- std dev: 104 ms +- 2 ms ......................................... dumps_dataclass_nested_50x100: Mean +- std dev: 25.6 ms +- 0.2 ms ......................................... WARNING: the benchmark result may be unstable * the standard deviation (15.0 ms) is 23% of the mean (64.7 ms) Try to rerun the benchmark with more runs, values and/or loops. Run 'python -m pyperf system tune' command to reduce the system jitter. Use pyperf stats, pyperf dump and pyperf hist to analyze results. Use --quiet option to hide these warnings. dumps_pydantic_nested_50x100: Mean +- std dev: 64.7 ms +- 15.0 ms ......................................... WARNING: the benchmark result may be unstable * the standard deviation (29.4 ms) is 14% of the mean (215 ms) Try to rerun the benchmark with more runs, values and/or loops. Run 'python -m pyperf system tune' command to reduce the system jitter. Use pyperf stats, pyperf dump and pyperf hist to analyze results. Use --quiet option to hide these warnings. dumps_pydanticv1_nested_50x100: Mean +- std dev: 215 ms +- 29 ms

Check notice on line 1 in python/langsmith/client.py

View workflow job for this annotation

GitHub Actions / benchmark

Comparison against main

+-----------------------------------------------+---------+-----------------------+ | Benchmark | main | changes | +===============================================+=========+=======================+ | dumps_dataclass_nested_50x100 | 25.4 ms | 25.6 ms: 1.01x slower | +-----------------------------------------------+---------+-----------------------+ | dumps_class_nested_py_leaf_50x100 | 25.1 ms | 25.3 ms: 1.01x slower | +-----------------------------------------------+---------+-----------------------+ | dumps_class_nested_py_branch_and_leaf_200x400 | 705 us | 715 us: 1.01x slower | +-----------------------------------------------+---------+-----------------------+ | Geometric mean | (ref) | 1.00x faster | +-----------------------------------------------+---------+-----------------------+ Benchmark hidden because not significant (6): dumps_pydantic_nested_50x100, dumps_pydanticv1_nested_50x100, create_10_000_run_trees, dumps_class_nested_py_leaf_100x200, create_20_000_run_trees, create_5_000_run_trees

Use the client to customize API keys / workspace ocnnections, SSl certs,
etc. for tracing.
Expand Down Expand Up @@ -82,6 +82,7 @@
_SIZE_LIMIT_BYTES,
)
from langsmith._internal._multipart import (
MultipartPart,
MultipartPartsAndContext,
join_multipart_parts_and_context,
)
Expand Down Expand Up @@ -3369,6 +3370,133 @@
created_at=created_at,
)

def upsert_examples_multipart(
self,
*,
upserts: List[ls_schemas.ExampleCreateWithAttachments] = [],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should not initialize this to empty list due to the way python handles mutable default arguments, see here https://nikos7am.com/posts/mutable-default-arguments/

to fix, just remove the default

) -> None:
"""Upsert examples."""
# not sure if the below checks are necessary
if not isinstance(upserts, list):
raise TypeError(f"upserts must be a list, got {type(upserts)}")
for item in upserts:
if not isinstance(item, ls_schemas.ExampleCreateWithAttachments):
raise TypeError(f"Each item must be ExampleCreateWithAttachments, got {type(item)}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not necessary, we don't check types like this elsewhere


parts: list[MultipartPart] = []

for example in upserts:
if example.id is not None:
example_id = str(example.id) # is the conversion to string neccessary?
isahers1 marked this conversation as resolved.
Show resolved Hide resolved
else:
example_id = str(uuid.uuid4())

remaining_values = {
isahers1 marked this conversation as resolved.
Show resolved Hide resolved
"dataset_id": example.dataset_id,
"created_at": example.created_at,
}
if example.metadata is not None:
remaining_values["metadata"] = example.metadata
if example.split is not None:
remaining_values["split"] = example.split
valb = _dumps_json(remaining_values)

(
parts.append(
(
f"{example_id}",
(
None,
valb,
"application/json",
{"Content-Length": str(len(valb))},
isahers1 marked this conversation as resolved.
Show resolved Hide resolved
),
)
),
)

inputsb = _dumps_json(example.inputs)
outputsb = _dumps_json(example.outputs)

(
parts.append(
(
f"{example_id}.inputs",
(
None,
inputsb,
"application/json",
{"Content-Length": str(len(inputsb))},
isahers1 marked this conversation as resolved.
Show resolved Hide resolved
),
)
),
)

(
parts.append(
(
f"{example_id}.outputs",
(
None,
outputsb,
"application/json",
{"Content-Length": str(len(outputsb))},
isahers1 marked this conversation as resolved.
Show resolved Hide resolved
),
)
),
)

if example.attachments:
for name, attachment in example.attachments.items():
if isinstance(attachment, tuple):
mime_type, data = attachment
(
parts.append(
(
f"{example_id}.attachment.{name}",
(
None,
data,
f"{mime_type}; length={len(data)}",
{},
isahers1 marked this conversation as resolved.
Show resolved Hide resolved
),
)
),
)
else:
(
parts.append(
(
f"{example_id}.attachment.{name}",
(
None,
attachment.data,
f"{attachment.mime_type}; length={len(attachment.data)}",
{},
),
)
),
)

encoder = rqtb_multipart.MultipartEncoder(parts, boundary=BOUNDARY)
if encoder.len <= 20_000_000: # ~20 MB
data = encoder.to_string()
else:
data = encoder

response = self.request_with_retries(
"POST",
"/v1/platform/examples/multipart", # No clue what this is supposed to be
isahers1 marked this conversation as resolved.
Show resolved Hide resolved
request_kwargs={
"data": data,
"headers": {
**self._headers,
"Content-Type": encoder.content_type,
},
},
)
ls_utils.raise_for_status_with_text(response)

def create_examples(
self,
*,
Expand Down
6 changes: 6 additions & 0 deletions python/langsmith/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ class ExampleCreate(ExampleBase):
split: Optional[Union[str, List[str]]] = None


class ExampleCreateWithAttachments(ExampleCreate):
"""Example create with attachments."""

attachments: Optional[Attachments] = None


class Example(ExampleBase):
"""Example model."""

Expand Down
69 changes: 68 additions & 1 deletion python/tests/integration_tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@
from requests_toolbelt import MultipartEncoder, MultipartEncoderMonitor

from langsmith.client import ID_TYPE, Client
from langsmith.schemas import DataType
from langsmith.schemas import DataType, ExampleCreateWithAttachments
from langsmith.utils import (
LangSmithNotFoundError,
LangSmithConnectionError,
LangSmithError,
get_env_var,
Expand Down Expand Up @@ -368,6 +369,72 @@ def test_error_surfaced_invalid_uri(uri: str) -> None:
with pytest.raises(LangSmithConnectionError):
client.create_run("My Run", inputs={"text": "hello world"}, run_type="llm")

# NEED TO FIX ONCE CHANGES PUSH TO PROD
@pytest.mark.parametrize("uri", ["https://dev.api.smith.langchain.com"])
def test_upsert_examples_multipart(uri: str) -> None:
"""Test upserting examples with attachments via multipart endpoint."""
dataset_name = "__test_upsert_examples_multipart" + uuid4().hex[:4]
langchain_client = Client(api_url=uri, api_key="NEED TO HARDCODE FOR TESTING")
if langchain_client.has_dataset(dataset_name=dataset_name):
langchain_client.delete_dataset(dataset_name=dataset_name)

dataset = langchain_client.create_dataset(
dataset_name,
description="Test dataset for multipart example upload",
data_type=DataType.kv,
)

# Test example with all fields
example_id = uuid4()
example_1 = ExampleCreateWithAttachments(
id=example_id,
dataset_id=dataset.id,
inputs={"text": "hello world"},
outputs={"response": "greeting"},
attachments={
"test_file": ("text/plain", b"test content"),
},
)
# Test example without id
example_2 = ExampleCreateWithAttachments(
dataset_id=dataset.id,
inputs={"text": "foo bar"},
outputs={"response": "baz"},
attachments={
"my_file": ("text/plain", b"more test content"),
},
)

langchain_client.upsert_examples_multipart(upserts=[example_1, example_2])

created_example = langchain_client.read_example(example_id)
assert created_example.inputs["text"] == "hello world"
assert created_example.outputs["response"] == "greeting"

all_examples_in_dataset = [example for example in langchain_client.list_examples(dataset_id=dataset.id)]
assert len(all_examples_in_dataset) == 2

# Test that adding invalid example fails - even if valid examples are added alongside
example_3 = ExampleCreateWithAttachments(
dataset_id=uuid4(), # not a real dataset
inputs={"text": "foo bar"},
outputs={"response": "baz"},
attachments={
"my_file": ("text/plain", b"more test content"),
},
)

with pytest.raises(LangSmithNotFoundError):
langchain_client.upsert_examples_multipart(upserts=[example_3])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why should this raise a not found?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because it can't find the dataset I believe


all_examples_in_dataset = [example for example in langchain_client.list_examples(dataset_id=dataset.id)]
assert len(all_examples_in_dataset) == 2

# Throw type errors when not passing ExampleCreateWithAttachments
with pytest.raises(TypeError):
langchain_client.upsert_examples_multipart(upserts=[{"foo":"bar"}])

langchain_client.delete_dataset(dataset_name=dataset_name)

def test_create_dataset(langchain_client: Client) -> None:
dataset_name = "__test_create_dataset" + uuid4().hex[:4]
Expand Down
87 changes: 87 additions & 0 deletions python/tests/unit_tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,93 @@ def test_create_run_mutate(
assert outputs == {"messages": ["hi", "there"]}


@mock.patch("langsmith.client.requests.Session")
def test_upsert_examples_multipart(mock_session_cls: mock.Mock) -> None:
"""Test that upsert_examples_multipart sends correct multipart data."""
mock_session = MagicMock()
mock_response = MagicMock()
mock_response.status_code = 200
mock_session.request.return_value = mock_response
mock_session_cls.return_value = mock_session

client = Client(api_url="http://localhost:1984", api_key="123")

# Create test data
example_id = uuid.uuid4()
dataset_id = uuid.uuid4()
created_at = datetime(2015, 1, 1, 0, 0, 0)

example = ls_schemas.ExampleCreateWithAttachments(
id=example_id,
dataset_id=dataset_id,
created_at=created_at,
inputs={"input": "test input"},
outputs={"output": "test output"},
metadata={"meta": "data"},
split="train",
attachments={
"file1": ("text/plain", b"test data"),
"file2": ls_schemas.Attachment(
mime_type="application/json", data=b'{"key": "value"}'
),
},
)
client.upsert_examples_multipart(upserts=[example])

# Verify the request
assert mock_session.request.call_count == 2 # we always make a call to /info
call_args = mock_session.request.call_args

assert call_args[0][0] == "POST"
assert call_args[0][1].endswith("/v1/examples/multipart")

# Parse the multipart data
request_data = call_args[1]["data"]
content_type = call_args[1]["headers"]["Content-Type"]
boundary = parse_options_header(content_type)[1]["boundary"]

parser = MultipartParser(
io.BytesIO(
request_data
if isinstance(request_data, bytes)
else request_data.to_string()
),
boundary,
)
parts = list(parser.parts())

# Verify all expected parts are present
expected_parts = {
str(example_id): {
"dataset_id": str(dataset_id),
"created_at": created_at.isoformat(),
"metadata": {"meta": "data"},
"split": "train",
},
f"{example_id}.inputs": {"input": "test input"},
f"{example_id}.outputs": {"output": "test output"},
f"{example_id}.attachment.file1": "test data",
f"{example_id}.attachment.file2": '{"key": "value"}',
}

assert len(parts) == len(expected_parts)

for part in parts:
name = part.name
assert name in expected_parts, f"Unexpected part: {name}"

if name.endswith(".attachment.file1"):
assert part.value == expected_parts[name]
assert part.headers["Content-Type"] == "text/plain"
elif name.endswith(".attachment.file2"):
assert part.value == expected_parts[name]
assert part.headers["Content-Type"] == "application/json"
else:
value = json.loads(part.value)
assert value == expected_parts[name]
assert part.headers["Content-Type"] == "application/json"


class CallTracker:
def __init__(self) -> None:
self.counter = 0
Expand Down
Loading