Skip to content

Commit 1134428

Browse files
wochingeclaude
andauthored
feat(datasets): support multimodal dataset items (#1710)
* feat(api): update generated dataset media API * feat(datasets): support media references Adds dataset item media upload and optional get_dataset media hydration. * fix(datasets): address media review feedback * fix(datasets): refine media review fixes * fix(datasets): clean up media review follow-ups * fix(datasets): round-trip resolved media references Resolved LangfuseMediaReference items from get_dataset(resolve_media_ references=True) discarded the original @@@langfuseMedia:...@@@ string, so feeding them back into create_dataset_item or run_experiment serialized them as opaque dicts and orphaned the media. Persist the reference string and emit it from both _process_dataset_item_media and EventSerializer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(datasets): process media inside tuples and sets _process_dataset_item_media only recursed into list/dict, so a LangfuseMedia held in a tuple, set, or frozenset slipped through to fern's encoder and got silently base64-inlined instead of uploaded. Widen the walker to those containers and rebuild them in place. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(datasets): align media reference field with expectedOutput API rename The DatasetItemMediaReferenceField enum value changed from expected_output to expectedOutput. Decouple hydration from the wire value by mapping the enum member to the model attribute, so the rename (and any future one) no longer silently skips expected-output media references. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(datasets): drop tuple media support to avoid namedtuple breakage Rebuilding tuples via type(data)(iterable) breaks namedtuple/NamedTuple subclasses, which take positional field args rather than an iterable. Keep list/set/frozenset handling and leave tuples untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(media): honor per-client httpx config for media reference fetches get_singleton_httpx_client silently returned the first-inserted instance, so a LangfuseMediaReference fetched from one client could go out through another client's transport (proxy/CA/mTLS). Mirror get_client: warn and fall back to default httpx when multiple clients exist, and let fetch_bytes/fetch_base64/fetch_data_uri take an explicit httpx client to deterministically honor per-client settings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(datasets): scope media uploads to their dataset item Dataset item media uploads now send datasetId + datasetItemId + field instead of a trace context. create_dataset_item settles the item id up front (generating one when absent) so media can be linked before the item exists, threads the field and resolves the dataset id for each upload, and reuses the id for the create call. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(datasets): resolve dataset id once per create_dataset_item Resolving the dataset id inside the per-media upload path fired one datasets.get per distinct media and could orphan already-uploaded media if a later lookup failed. Resolve it once up front, before any upload, and thread dataset_id through the media processing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(datasets): url-encode dataset name and fix media upload job fixture URL-encode the dataset name in the create_dataset_item media lookup to match the other datasets.* call sites, and add the new dataset_id / dataset_item_id keys to the _upload_job test fixture so _process_upload_media_job no longer KeyErrors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(datasets): collect media in one pass, resolve dataset id lazily Replace each LangfuseMedia with its reference string and collect the media to upload in a single traversal per field, then resolve the dataset id once and upload the collected set. A plain item no longer does any datasets.get; media items do exactly one, before any upload. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(media): rename url_is_expired to is_url_expired Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(datasets): always resolve dataset item media references Drop the resolve_media_references flag on get_dataset and always hydrate media reference strings to LangfuseMediaReference objects. Regenerate the dataset-items and media API clients from the base branch: the includeMediaReferences query flag is gone, mediaReferences is a non-optional list, and the media upload field is now required. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(datasets): hand-roll media reference JSONPath, drop jsonpath-ng The API only emits jsonpath-plus normalized paths ($, ['key'], [int]) for dataset item media references, so parse exactly that grammar in a small json_path helper module instead of depending on jsonpath-ng (which also breaks under python -OO). Tests are generated from jsonpath-plus, and the e2e exercises multiple media across nested keys and list indices. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(media): use fixed timestamps so is_url_expired ids are xdist-stable Parametrizing with datetime.now() baked per-worker timestamps into the test ids, so xdist workers collected different ids and aborted with a collection error. Use fixed past/future timestamps instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(datasets): namespace json_path import, drop stale -OO comment Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(api): take dataset/session generated code from main The earlier main merge kept the feature branch's generated code; sync it to main's now-released codegen (sessions regenerated, media reference media required again). Drop the dataset hydration's media-None guard, which is dead now that media is non-optional. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fdfa5e6 commit 1134428

15 files changed

Lines changed: 1008 additions & 14 deletions

langfuse/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
LangfuseTool,
3030
)
3131
from ._version import __version__
32+
from .media import LangfuseMedia, LangfuseMediaReference
3233
from .span_filter import (
3334
KNOWN_LLM_INSTRUMENTATION_SCOPE_PREFIXES,
3435
is_default_export_span,
@@ -49,6 +50,8 @@
4950

5051
__all__ = [
5152
"Langfuse",
53+
"LangfuseMedia",
54+
"LangfuseMediaReference",
5255
"get_client",
5356
"observe",
5457
"propagate_attributes",

langfuse/_client/client.py

Lines changed: 177 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import os
99
import re
1010
import urllib.parse
11+
import uuid
1112
import warnings
1213
from datetime import datetime
1314
from hashlib import sha256
@@ -19,6 +20,7 @@
1920
List,
2021
Literal,
2122
Optional,
23+
Tuple,
2224
Type,
2325
Union,
2426
cast,
@@ -84,7 +86,7 @@
8486
LangfuseTool,
8587
)
8688
from langfuse._client.utils import get_sha256_hash_hex, run_async_safely
87-
from langfuse._utils import _get_timestamp
89+
from langfuse._utils import _get_timestamp, json_path
8890
from langfuse._utils.environment import get_common_release_envs
8991
from langfuse._utils.parse_error import handle_fern_exception
9092
from langfuse._utils.prompt_cache import PromptCache
@@ -94,6 +96,7 @@
9496
CreateTextPromptRequest,
9597
Dataset,
9698
DatasetItem,
99+
DatasetItemMediaReferenceField,
97100
DatasetRunWithItems,
98101
DatasetStatus,
99102
DeleteDatasetRunResponse,
@@ -126,7 +129,7 @@
126129
_run_task,
127130
)
128131
from langfuse.logger import langfuse_logger
129-
from langfuse.media import LangfuseMedia
132+
from langfuse.media import LangfuseMedia, LangfuseMediaReference
130133
from langfuse.model import (
131134
ChatMessageDict,
132135
ChatMessageWithPlaceholdersDict,
@@ -2326,9 +2329,9 @@ def get_dataset(
23262329
"""Fetch a dataset by its name.
23272330
23282331
Args:
2329-
name (str): The name of the dataset to fetch.
2330-
fetch_items_page_size (Optional[int]): All items of the dataset will be fetched in chunks of this size. Defaults to 50.
2331-
version (Optional[datetime]): Retrieve dataset items as they existed at this specific point in time (UTC).
2332+
name: The name of the dataset to fetch.
2333+
fetch_items_page_size: All items of the dataset will be fetched in chunks of this size. Defaults to 50.
2334+
version: Retrieve dataset items as they existed at this specific point in time (UTC).
23322335
If provided, returns the state of items at the specified UTC timestamp.
23332336
If not provided, returns the latest version. Must be a timezone-aware datetime object in UTC.
23342337
@@ -2339,7 +2342,7 @@ def get_dataset(
23392342
langfuse_logger.debug(f"Getting datasets {name}")
23402343
dataset = self.api.datasets.get(dataset_name=self._url_encode(name))
23412344

2342-
dataset_items = []
2345+
dataset_items: List[DatasetItem] = []
23432346
page = 1
23442347

23452348
while True:
@@ -2349,7 +2352,10 @@ def get_dataset(
23492352
limit=fetch_items_page_size,
23502353
version=version,
23512354
)
2352-
dataset_items.extend(new_items.data)
2355+
dataset_items.extend(
2356+
self._hydrate_dataset_item_media_references(item)
2357+
for item in new_items.data
2358+
)
23532359

23542360
if new_items.meta.total_pages <= page:
23552361
break
@@ -3355,6 +3361,45 @@ def create_dataset_item(
33553361
try:
33563362
langfuse_logger.debug(f"Creating dataset item for dataset {dataset_name}")
33573363

3364+
# Media uploads must reference the (dataset, item) they belong to, and
3365+
# the item need not exist yet — so settle on the item id up front and
3366+
# reuse it for the create call below.
3367+
item_id = id if id is not None else str(uuid.uuid4())
3368+
3369+
# Single pass per field: swap each LangfuseMedia for its reference
3370+
# string (derived from content, not the upload) and collect the media
3371+
# still to upload, deduped by media id and tagged with its field.
3372+
pending_media: Dict[str, Tuple[LangfuseMedia, str]] = {}
3373+
input = self._process_dataset_item_media(
3374+
data=input,
3375+
pending_media=pending_media,
3376+
field=DatasetItemMediaReferenceField.INPUT.value,
3377+
)
3378+
expected_output = self._process_dataset_item_media(
3379+
data=expected_output,
3380+
pending_media=pending_media,
3381+
field=DatasetItemMediaReferenceField.EXPECTED_OUTPUT.value,
3382+
)
3383+
metadata = self._process_dataset_item_media(
3384+
data=metadata,
3385+
pending_media=pending_media,
3386+
field=DatasetItemMediaReferenceField.METADATA.value,
3387+
)
3388+
3389+
# The upload needs the dataset id, but the create API only takes the
3390+
# name. Resolve it once, and only when there is actually media to
3391+
# upload — a plain item pays no extra datasets.get round-trip.
3392+
if pending_media:
3393+
assert self._resources is not None
3394+
dataset_id = self.api.datasets.get(self._url_encode(dataset_name)).id
3395+
for media, field in pending_media.values():
3396+
self._resources._media_manager._upload_media_sync(
3397+
media=media,
3398+
dataset_id=dataset_id,
3399+
dataset_item_id=item_id,
3400+
field=field,
3401+
)
3402+
33583403
result = self.api.dataset_items.create(
33593404
dataset_name=dataset_name,
33603405
input=input,
@@ -3363,14 +3408,138 @@ def create_dataset_item(
33633408
source_trace_id=source_trace_id,
33643409
source_observation_id=source_observation_id,
33653410
status=status,
3366-
id=id,
3411+
id=item_id,
33673412
)
33683413

33693414
return cast(DatasetItem, result)
33703415
except Error as e:
33713416
handle_fern_exception(e)
33723417
raise e
33733418

3419+
def _process_dataset_item_media(
3420+
self,
3421+
*,
3422+
data: Any,
3423+
pending_media: Dict[str, Tuple[LangfuseMedia, str]],
3424+
field: str,
3425+
) -> Any:
3426+
"""Swap each ``LangfuseMedia`` for its reference string in ``data``.
3427+
3428+
Each replaced media is recorded in ``pending_media`` (keyed by media id,
3429+
so the same media across fields uploads once) for the caller to upload
3430+
after the dataset id has been resolved.
3431+
"""
3432+
if self._resources is None:
3433+
return data
3434+
3435+
max_levels = 10
3436+
3437+
def _process_data_recursively(
3438+
data: Any, level: int, ancestor_container_ids: set[int]
3439+
) -> Any:
3440+
if isinstance(data, LangfuseMedia):
3441+
reference_string = data._reference_string
3442+
media_id = data._media_id
3443+
if reference_string is None or media_id is None:
3444+
raise ValueError(
3445+
"Cannot create dataset item with invalid LangfuseMedia."
3446+
)
3447+
# First field a media appears in wins; later duplicates dedupe.
3448+
pending_media.setdefault(media_id, (data, field))
3449+
return reference_string
3450+
3451+
if isinstance(data, LangfuseMediaReference):
3452+
return data.reference_string if data.reference_string else data
3453+
3454+
# Tuples are intentionally excluded: namedtuple subclasses can't be
3455+
# rebuilt from an iterable, so media inside them is left untouched.
3456+
if not isinstance(data, (list, set, frozenset, dict)):
3457+
return data
3458+
3459+
# Container ids only protect against recursive cycles.
3460+
data_id = id(data)
3461+
if data_id in ancestor_container_ids or level > max_levels:
3462+
return data
3463+
3464+
next_ancestor_container_ids = ancestor_container_ids | {data_id}
3465+
3466+
if isinstance(data, (list, set, frozenset)):
3467+
processed = (
3468+
_process_data_recursively(
3469+
item, level + 1, next_ancestor_container_ids
3470+
)
3471+
for item in data
3472+
)
3473+
return type(data)(processed)
3474+
3475+
return {
3476+
key: _process_data_recursively(
3477+
value, level + 1, next_ancestor_container_ids
3478+
)
3479+
for key, value in data.items()
3480+
}
3481+
3482+
return _process_data_recursively(data, 1, set())
3483+
3484+
def _hydrate_dataset_item_media_references(self, item: DatasetItem) -> DatasetItem:
3485+
media_references = item.media_references or []
3486+
if not media_references:
3487+
return item
3488+
3489+
# Map the API enum member to the snake_case model attribute so this keeps
3490+
# working regardless of the enum's wire value (e.g. "expectedOutput").
3491+
attr_by_field = {
3492+
DatasetItemMediaReferenceField.INPUT: "input",
3493+
DatasetItemMediaReferenceField.EXPECTED_OUTPUT: "expected_output",
3494+
DatasetItemMediaReferenceField.METADATA: "metadata",
3495+
}
3496+
hydrated_fields = {
3497+
"input": item.input,
3498+
"expected_output": item.expected_output,
3499+
"metadata": item.metadata,
3500+
}
3501+
3502+
for media_reference in media_references:
3503+
media = media_reference.media
3504+
field = attr_by_field.get(media_reference.field)
3505+
if field is None:
3506+
continue
3507+
3508+
replacement = LangfuseMediaReference(
3509+
media_id=media.media_id,
3510+
content_type=media.content_type,
3511+
url=media.url,
3512+
url_expiry=media.url_expiry,
3513+
content_length=media.content_length,
3514+
reference_string=media_reference.reference_string,
3515+
)
3516+
hydrated_fields[field] = self._replace_json_path_value(
3517+
value=hydrated_fields[field],
3518+
path=media_reference.json_path,
3519+
replacement=replacement,
3520+
)
3521+
3522+
return item.model_copy(
3523+
update={
3524+
"input": hydrated_fields["input"],
3525+
"expected_output": hydrated_fields["expected_output"],
3526+
"metadata": hydrated_fields["metadata"],
3527+
}
3528+
)
3529+
3530+
def _replace_json_path_value(
3531+
self, *, value: Any, path: str, replacement: LangfuseMediaReference
3532+
) -> Any:
3533+
try:
3534+
return json_path.set_value_at_path(value, path, replacement)
3535+
except Exception as e:
3536+
langfuse_logger.warning(
3537+
f"Failed to hydrate dataset media reference at JSONPath {path}",
3538+
exc_info=e,
3539+
)
3540+
3541+
return value
3542+
33743543
def resolve_media_references(
33753544
self,
33763545
*,

langfuse/_client/resource_manager.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,29 @@ class LangfuseResourceManager:
7979
_instances: Dict[str, "LangfuseResourceManager"] = {}
8080
_lock = threading.RLock()
8181

82+
@classmethod
83+
def get_singleton_httpx_client(cls) -> Optional[httpx.Client]:
84+
with cls._lock:
85+
instances = list(cls._instances.values())
86+
87+
if not instances:
88+
return None
89+
90+
if len(instances) > 1:
91+
# Mirror get_client's safety stance: with multiple clients we
92+
# cannot tell which one produced a given reference, so fall back
93+
# to a default httpx client rather than silently using an
94+
# arbitrary instance's transport config (proxy / CA / mTLS).
95+
langfuse_logger.warning(
96+
"Multiple Langfuse clients are instantiated; falling back to a "
97+
"default httpx client for LangfuseMediaReference fetches. Pass an "
98+
"explicit `client` to fetch_bytes/fetch_base64/fetch_data_uri to "
99+
"honor per-client transport settings."
100+
)
101+
return None
102+
103+
return instances[0].httpx_client
104+
82105
def __new__(
83106
cls,
84107
*,

langfuse/_task_manager/media_manager.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,8 @@ def _process_media(
263263
content_sha256_hash=media._content_sha256_hash,
264264
trace_id=trace_id,
265265
observation_id=observation_id,
266+
dataset_id=None,
267+
dataset_item_id=None,
266268
field=field,
267269
)
268270

@@ -284,6 +286,43 @@ def _process_media(
284286
f"Media processing error: Failed to process media_id={media._media_id} for trace_id={trace_id}. Error: {str(e)}"
285287
)
286288

289+
def _upload_media_sync(
290+
self,
291+
*,
292+
media: LangfuseMedia,
293+
dataset_id: Optional[str] = None,
294+
dataset_item_id: Optional[str] = None,
295+
field: Optional[str] = None,
296+
) -> None:
297+
if not self._enabled:
298+
raise ValueError("Cannot upload LangfuseMedia while media upload is disabled.")
299+
300+
if (
301+
media._content_length is None
302+
or media._content_type is None
303+
or media._content_sha256_hash is None
304+
or media._content_bytes is None
305+
):
306+
raise ValueError("Cannot upload invalid LangfuseMedia.")
307+
308+
if media._media_id is None:
309+
raise ValueError("Cannot upload LangfuseMedia without media ID.")
310+
311+
upload_media_job = UploadMediaJob(
312+
media_id=media._media_id,
313+
content_bytes=media._content_bytes,
314+
content_type=media._content_type,
315+
content_length=media._content_length,
316+
content_sha256_hash=media._content_sha256_hash,
317+
trace_id=None,
318+
observation_id=None,
319+
dataset_id=dataset_id,
320+
dataset_item_id=dataset_item_id,
321+
field=field,
322+
)
323+
324+
self._process_upload_media_job(data=upload_media_job)
325+
287326
def _process_upload_media_job(
288327
self,
289328
*,
@@ -294,9 +333,11 @@ def _process_upload_media_job(
294333
content_length=data["content_length"],
295334
content_type=cast(MediaContentType, data["content_type"]),
296335
sha256hash=data["content_sha256_hash"],
297-
field=data["field"],
298336
trace_id=data["trace_id"],
299337
observation_id=data["observation_id"],
338+
dataset_id=data["dataset_id"],
339+
dataset_item_id=data["dataset_item_id"],
340+
field=data["field"],
300341
)
301342

302343
upload_url = upload_url_response.upload_url

langfuse/_task_manager/media_upload_queue.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ class UploadMediaJob(TypedDict):
77
content_length: int
88
content_bytes: bytes
99
content_sha256_hash: str
10-
trace_id: str
10+
trace_id: Optional[str]
1111
observation_id: Optional[str]
12-
field: str
12+
dataset_id: Optional[str]
13+
dataset_item_id: Optional[str]
14+
field: Optional[str]

0 commit comments

Comments
 (0)