Skip to content

Commit 86f95fd

Browse files
committed
add create/deletion methods, add tests
1 parent 3a7c8a4 commit 86f95fd

File tree

7 files changed

+91
-40
lines changed

7 files changed

+91
-40
lines changed

ddtrace/llmobs/_constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,10 @@
4545
EVP_SUBDOMAIN_HEADER_NAME = "X-Datadog-EVP-Subdomain"
4646
SPAN_SUBDOMAIN_NAME = "llmobs-intake"
4747
EVAL_SUBDOMAIN_NAME = "api"
48+
EXP_SUBDOMAIN_NAME = "api"
4849
AGENTLESS_SPAN_BASE_URL = "https://{}".format(SPAN_SUBDOMAIN_NAME)
4950
AGENTLESS_EVAL_BASE_URL = "https://{}".format(EVAL_SUBDOMAIN_NAME)
51+
AGENTLESS_EXP_BASE_URL = "https://{}".format(EXP_SUBDOMAIN_NAME)
5052

5153
EVP_PAYLOAD_SIZE_LIMIT = 5 << 20 # 5MB (actual limit is 5.1MB)
5254
EVP_EVENT_SIZE_LIMIT = (1 << 20) - 1024 # 999KB (actual limit is 1MB)

ddtrace/llmobs/_experiment.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,25 +18,19 @@ class DatasetRecord(TypedDict):
1818

1919

2020
class Dataset:
21-
_name: str
21+
name: str
2222
_id: str
2323
_data: List[DatasetRecord]
2424

25-
def __init__(self, name: str, id: str, data: List[DatasetRecord]) -> None:
26-
self._name = name
27-
self._id = id
25+
def __init__(self, name: str, dataset_id: str, data: List[DatasetRecord]) -> None:
26+
self.name = name
27+
self._id = dataset_id
2828
self._data = data
2929

30-
def __str__(self) -> str:
31-
return f"Dataset(name={self._name}, id={self._id}, data={self._data})"
32-
3330

3431
class Experiment:
3532
def __init__(self, name: str, dataset: Dataset, description: str = "") -> None:
3633
self.name = name
3734
self._dataset = dataset
3835
self._experiment_id: Optional[str] = None
3936
self._project_id: Optional[str] = None
40-
41-
def run(self):
42-
pass

ddtrace/llmobs/_llmobs.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from typing import TypedDict
1515
from typing import Union
1616
from typing import cast
17-
from urllib.parse import quote
1817

1918
import ddtrace
2019
from ddtrace import config
@@ -562,6 +561,14 @@ def enable(
562561
def pull_dataset(cls, name: str) -> Dataset:
563562
return cls._instance._dne_client.dataset_pull(name)
564563

564+
@classmethod
565+
def create_dataset(cls, name: str, description: str) -> Dataset:
566+
return cls._instance._dne_client.dataset_create(name, description)
567+
568+
@classmethod
569+
def _delete_dataset(cls, dataset_id: str) -> None:
570+
return cls._instance._dne_client.dataset_delete(dataset_id)
571+
565572
def experiment(self, name: str, dataset: Dataset) -> Experiment:
566573
return Experiment(name, dataset)
567574

ddtrace/llmobs/_writer.py

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import atexit
2+
import json
23
from typing import Any
34
from typing import Dict
45
from typing import List
@@ -25,6 +26,7 @@
2526
from ddtrace.internal.utils.retry import fibonacci_backoff_with_jitter
2627
from ddtrace.llmobs import _telemetry as telemetry
2728
from ddtrace.llmobs._constants import AGENTLESS_EVAL_BASE_URL
29+
from ddtrace.llmobs._constants import AGENTLESS_EXP_BASE_URL
2830
from ddtrace.llmobs._constants import AGENTLESS_SPAN_BASE_URL
2931
from ddtrace.llmobs._constants import DROPPED_IO_COLLECTION_ERROR
3032
from ddtrace.llmobs._constants import DROPPED_VALUE_TEXT
@@ -38,6 +40,7 @@
3840
from ddtrace.llmobs._constants import SPAN_SUBDOMAIN_NAME
3941
from ddtrace.llmobs._experiment import Dataset
4042
from ddtrace.llmobs._experiment import DatasetRecord
43+
from ddtrace.llmobs._experiment import JSONType
4144
from ddtrace.llmobs._utils import safe_json
4245
from ddtrace.settings._agent import config as agent_config
4346

@@ -270,57 +273,73 @@ def _data(self, events: List[LLMObsEvaluationMetricEvent]) -> Dict[str, Any]:
270273

271274
class LLMObsExperimentsClient(BaseLLMObsWriter):
272275

273-
def request(self, method: str, path: str, body: bytes = b"") -> Response:
276+
AGENTLESS_BASE_URL = AGENTLESS_EXP_BASE_URL
277+
278+
def request(self, method: str, path: str, body: JSONType = None) -> Response:
274279
headers = {
275280
"Content-Type": "application/json",
276281
"DD-API-KEY": self._api_key,
277282
"DD-APPLICATION-KEY": self._app_key,
278283
}
279-
site = self._site
280-
if site == "datad0g.com":
281-
base = "https://dd.datad0g.com"
282-
else:
283-
base = f"https://api.{site}"
284-
285-
conn = get_connection(base)
284+
body = json.dumps(body).encode("utf-8") if body else b""
285+
conn = get_connection(self._intake)
286286
try:
287-
url = base + path
287+
url = self._intake + path
288288
logger.debug("requesting %s", url)
289289
conn.request(method, url, body, headers)
290290
resp = conn.getresponse()
291-
if resp.status >= 300:
292-
raise ValueError(f"Failed to {method} {path}: {resp.status}")
293291
return Response.from_http_response(resp)
294292
finally:
295293
conn.close()
296294

297-
def dataset_pull(self, name: str) -> Dataset:
295+
def dataset_delete(self, dataset_id: str) -> None:
296+
path = "/api/unstable/llm-obs/v1/datasets/delete"
297+
resp = self.request("POST", path, body={
298+
"data": {
299+
"type": "datasets",
300+
"attributes": {
301+
"type": "soft",
302+
"dataset_ids": [dataset_id],
303+
},
304+
},
305+
})
306+
assert resp.status == 200, f"Failed to delete dataset {id}: {resp.get_json()}"
307+
return None
308+
309+
def dataset_create(self, name: str, description: str) -> Dataset:
310+
path = "/api/unstable/llm-obs/v1/datasets"
311+
body = {
312+
"data": {
313+
"type": "datasets",
314+
"attributes": {"name": name, "description": description},
315+
}
316+
}
317+
resp = self.request("POST", path, body)
318+
response_data = resp.get_json()
319+
dataset_id = response_data["data"]["id"]
320+
return Dataset(name, dataset_id, [])
298321

322+
def dataset_pull(self, name: str) -> Dataset:
299323
path = f"/api/unstable/llm-obs/v1/datasets?filter[name]={quote(name)}"
300324
resp = self.request("GET", path)
301325

302326
response_data = resp.get_json()
303-
datasets = response_data.get("data", [])
304-
305-
if not datasets:
327+
data = response_data["data"]
328+
if not data:
306329
raise ValueError(f"Dataset '{name}' not found")
307330

308-
dataset_id = datasets[0]["id"]
331+
dataset_id = data[0]["id"]
309332
url = f"/api/unstable/llm-obs/v1/datasets/{dataset_id}/records"
310-
try:
311-
resp = self.request("GET", url)
312-
records_data = resp.get_json()
313-
except ValueError as e:
314-
if "404" in str(e):
315-
raise ValueError(f"Dataset '{name}' not found") from e
316-
raise
333+
resp = self.request("GET", url)
334+
if resp.status == 404:
335+
raise ValueError(f"Dataset '{name}' not found")
336+
records_data = resp.get_json()
317337

318338
class_records: List[DatasetRecord] = []
319339
for record in records_data.get("data", []):
320340
attrs = record.get("attributes", {})
321341
input_data = attrs.get("input")
322342
expected_output = attrs.get("expected_output")
323-
324343
class_records.append(
325344
{
326345
"record_id": record.get("id"),
@@ -329,7 +348,6 @@ def dataset_pull(self, name: str) -> Dataset:
329348
**attrs.get("metadata", {}),
330349
}
331350
)
332-
333351
return Dataset(name, dataset_id, class_records)
334352

335353

tests/llmobs/_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
cassette_library_dir=os.path.join(os.path.dirname(__file__), "llmobs_cassettes/"),
2525
record_mode="once",
2626
match_on=["path"],
27-
filter_headers=["authorization", "OpenAI-Organization", "api-key", "x-api-key", ("DD-API-KEY", "XXXXXX")],
27+
filter_headers=["authorization", "OpenAI-Organization", "api-key", "x-api-key", ("DD-API-KEY", "XXXXXX"), ("DD-APPLICATION-KEY", "XXXXXX")],
2828
# Ignore requests to the agent
2929
ignore_localhost=True,
3030
)

tests/llmobs/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,9 @@ def llmobs(
273273
llmobs_service.enable(_tracer=tracer, **llmobs_enable_opts)
274274
llmobs_service._instance._llmobs_span_writer = llmobs_span_writer
275275
llmobs_service._instance._llmobs_span_writer.start()
276+
llmobs_service._instance._dne_client._intake = "http://localhost:9126/vcr/datadog"
276277
yield llmobs_service
278+
tracer.shutdown()
277279
llmobs_service.disable()
278280

279281

tests/llmobs/test_experiments.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
1+
import os
12

3+
import pytest
24

3-
def test_dataset_pull(llmobs):
4-
dataset = llmobs.pull_dataset(name="kyle-test")
5-
assert dataset._id == "929531d1-3cd2-473d-ab4e-2423b40c5db5"
5+
6+
@pytest.fixture
7+
def test_dataset(llmobs):
8+
ds = llmobs.create_dataset(name="test-dataset", description="A test dataset")
9+
10+
# When recording the requests, we need to wait for the dataset to be queryable.
11+
if os.environ.get("RECORD_REQUESTS"):
12+
import time
13+
time.sleep(1)
14+
15+
yield ds
16+
17+
llmobs._delete_dataset(dataset_id=ds._id)
18+
19+
20+
def test_dataset_create_delete(llmobs):
21+
dataset = llmobs.create_dataset(name="test-dataset-2", description="A second test dataset")
22+
assert dataset._id is not None
23+
llmobs._delete_dataset(dataset_id=dataset._id)
24+
25+
26+
def test_dataset_pull_non_existent(llmobs):
27+
with pytest.raises(ValueError):
28+
llmobs.pull_dataset(name="test-dataset-non-existent")
29+
30+
31+
def test_dataset_pull(llmobs, test_dataset):
32+
dataset = llmobs.pull_dataset(name=test_dataset.name)
33+
assert dataset._id is not None

0 commit comments

Comments
 (0)