Skip to content

Commit 6a490e1

Browse files
wochingeclaude
andcommitted
feat(datasets): support multimodal dataset items in the SDK
Mirror langfuse/langfuse-python#1710 to add media support to datasets: - LangfuseMediaReference (core): a signed-URL media handle returned when resolving dataset media, with urlIsExpired / fetchBytes / fetchBase64 / fetchDataUri helpers for feeding media to LLM providers - uploadMedia (core): reusable upload routine that works without a trace context; MediaService now delegates to it instead of duplicating the upload + backoff logic - DatasetManager.createItem: uploads any LangfuseMedia found in input, expectedOutput, or metadata (deduped) and replaces it with a reference string before creating the item; createDatasetItem now routes here - DatasetManager.get(resolveMediaReferences): requests includeMediaReferences and hydrates reference strings into LangfuseMediaReference objects using jsonpath-plus (eval-free, so it is safe under strict CSP / edge runtimes) - re-export LangfuseMedia and LangfuseMediaReference from @langfuse/client Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f422b5e commit 6a490e1

12 files changed

Lines changed: 971 additions & 130 deletions

File tree

packages/client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"dependencies": {
3636
"@langfuse/core": "workspace:^",
3737
"@langfuse/tracing": "workspace:^",
38+
"jsonpath-plus": "^10.3.0",
3839
"mustache": "^4.2.0"
3940
},
4041
"peerDependencies": {

packages/client/src/LangfuseClient.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,9 @@ export class LangfuseClient {
219219
*/
220220
public getDatasetItem: typeof LangfuseAPIClient.prototype.datasetItems.get;
221221
/**
222-
* @deprecated Use api.datasetItems.create instead
222+
* @deprecated Use dataset.createItem instead
223223
*/
224-
public createDatasetItem: typeof LangfuseAPIClient.prototype.datasetItems.create;
224+
public createDatasetItem: typeof DatasetManager.prototype.createItem;
225225
/**
226226
* @deprecated Use api.media.get instead
227227
*/
@@ -312,7 +312,7 @@ export class LangfuseClient {
312312
this.getDatasetRuns = this.api.datasets.getRuns;
313313
this.createDataset = this.api.datasets.create;
314314
this.getDatasetItem = this.api.datasetItems.get;
315-
this.createDatasetItem = this.api.datasetItems.create;
315+
this.createDatasetItem = this.dataset.createItem.bind(this.dataset);
316316
this.fetchMedia = this.api.media.get;
317317
this.resolveMediaReferences = this.media.resolveReferences;
318318
}
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { LangfuseMedia, LangfuseMediaReference } from "@langfuse/core";
2+
import { describe, expect, it, vi } from "vitest";
3+
4+
import { DatasetManager } from "./index.js";
5+
6+
function makeMedia(bytes: number[] = [1, 2, 3, 4]): LangfuseMedia {
7+
return new LangfuseMedia({
8+
source: "bytes",
9+
contentBytes: new Uint8Array(bytes),
10+
contentType: "image/png",
11+
});
12+
}
13+
14+
function makeItem(overrides: Record<string, unknown> = {}) {
15+
return {
16+
id: "item-1",
17+
status: "ACTIVE",
18+
input: null,
19+
expectedOutput: null,
20+
metadata: null,
21+
sourceTraceId: null,
22+
sourceObservationId: null,
23+
datasetId: "ds-id",
24+
datasetName: "ds",
25+
createdAt: "2026-06-16T12:00:00.000Z",
26+
updatedAt: "2026-06-16T12:00:00.000Z",
27+
...overrides,
28+
};
29+
}
30+
31+
describe("DatasetManager.createItem media processing", () => {
32+
it("uploads media and replaces it with a reference string", async () => {
33+
const media = makeMedia();
34+
const referenceString = await media.getTag();
35+
36+
const create = vi.fn().mockResolvedValue({ id: "created" });
37+
const uploadMedia = vi.fn().mockResolvedValue(undefined);
38+
const manager = new DatasetManager({
39+
langfuseClient: {
40+
api: { datasetItems: { create } },
41+
media: { uploadMedia },
42+
} as never,
43+
});
44+
45+
const input = { image: media, question: "q" };
46+
const result = await manager.createItem({ datasetName: "ds", input });
47+
48+
expect(result).toEqual({ id: "created" });
49+
expect(uploadMedia).toHaveBeenCalledTimes(1);
50+
expect(uploadMedia).toHaveBeenCalledWith(media);
51+
expect(create).toHaveBeenCalledWith({
52+
datasetName: "ds",
53+
input: { image: referenceString, question: "q" },
54+
expectedOutput: undefined,
55+
metadata: undefined,
56+
});
57+
// original input is not mutated
58+
expect(input.image).toBe(media);
59+
});
60+
61+
it("processes media in expectedOutput and metadata, deduping uploads", async () => {
62+
const media = makeMedia();
63+
const referenceString = await media.getTag();
64+
65+
const create = vi.fn().mockResolvedValue({ id: "created" });
66+
const uploadMedia = vi.fn().mockResolvedValue(undefined);
67+
const manager = new DatasetManager({
68+
langfuseClient: {
69+
api: { datasetItems: { create } },
70+
media: { uploadMedia },
71+
} as never,
72+
});
73+
74+
await manager.createItem({
75+
datasetName: "ds",
76+
input: { a: media },
77+
expectedOutput: media,
78+
metadata: { nested: [media] },
79+
});
80+
81+
// same media id -> uploaded once
82+
expect(uploadMedia).toHaveBeenCalledTimes(1);
83+
expect(create).toHaveBeenCalledWith({
84+
datasetName: "ds",
85+
input: { a: referenceString },
86+
expectedOutput: referenceString,
87+
metadata: { nested: [referenceString] },
88+
});
89+
});
90+
91+
it("throws when media is invalid", async () => {
92+
const invalid = new LangfuseMedia({
93+
source: "base64_data_uri",
94+
base64DataUri: "not-a-data-uri",
95+
});
96+
97+
const manager = new DatasetManager({
98+
langfuseClient: {
99+
api: { datasetItems: { create: vi.fn() } },
100+
media: { uploadMedia: vi.fn() },
101+
} as never,
102+
});
103+
104+
await expect(
105+
manager.createItem({ datasetName: "ds", input: { image: invalid } }),
106+
).rejects.toThrow(/invalid LangfuseMedia/);
107+
});
108+
});
109+
110+
describe("DatasetManager.get resolveMediaReferences", () => {
111+
function managerReturning(item: Record<string, unknown>) {
112+
const list = vi
113+
.fn()
114+
.mockResolvedValue({ data: [item], meta: { totalPages: 1 } });
115+
const datasetsGet = vi.fn().mockResolvedValue({ id: "ds-id", name: "ds" });
116+
const manager = new DatasetManager({
117+
langfuseClient: {
118+
api: {
119+
datasets: { get: datasetsGet },
120+
datasetItems: { list },
121+
},
122+
experiment: { run: vi.fn() },
123+
} as never,
124+
});
125+
return { manager, list };
126+
}
127+
128+
const mediaPayload = {
129+
mediaId: "med-1",
130+
contentType: "image/png",
131+
contentLength: 4,
132+
url: "https://example.com/med.png",
133+
urlExpiry: "2026-06-16T13:00:00.000Z",
134+
};
135+
136+
it("hydrates a bracket-notation json path into a LangfuseMediaReference", async () => {
137+
const item = makeItem({
138+
input: { image: "@@@langfuseMedia:...@@@", question: "q" },
139+
mediaReferences: [
140+
{
141+
field: "input",
142+
referenceString: "@@@langfuseMedia:...@@@",
143+
jsonPath: "$['image']",
144+
media: mediaPayload,
145+
},
146+
],
147+
});
148+
const { manager, list } = managerReturning(item);
149+
150+
const dataset = await manager.get("ds", { resolveMediaReferences: true });
151+
152+
expect(list).toHaveBeenCalledWith(
153+
expect.objectContaining({ includeMediaReferences: true }),
154+
);
155+
const input = dataset.items[0].input as Record<string, unknown>;
156+
expect(input.image).toBeInstanceOf(LangfuseMediaReference);
157+
expect((input.image as LangfuseMediaReference).mediaId).toBe("med-1");
158+
expect(input.question).toBe("q");
159+
});
160+
161+
it("hydrates expected_output field and nested json paths", async () => {
162+
const item = makeItem({
163+
expectedOutput: { a: { b: "@@@langfuseMedia:...@@@" } },
164+
mediaReferences: [
165+
{
166+
field: "expected_output",
167+
referenceString: "@@@langfuseMedia:...@@@",
168+
jsonPath: "$['a']['b']",
169+
media: mediaPayload,
170+
},
171+
],
172+
});
173+
const { manager } = managerReturning(item);
174+
175+
const dataset = await manager.get("ds", { resolveMediaReferences: true });
176+
177+
const expectedOutput = dataset.items[0].expectedOutput as {
178+
a: { b: unknown };
179+
};
180+
expect(expectedOutput.a.b).toBeInstanceOf(LangfuseMediaReference);
181+
});
182+
183+
it("does not request or hydrate references when option is off", async () => {
184+
const item = makeItem({
185+
input: { image: "@@@langfuseMedia:...@@@" },
186+
mediaReferences: [
187+
{
188+
field: "input",
189+
referenceString: "@@@langfuseMedia:...@@@",
190+
jsonPath: "$['image']",
191+
media: mediaPayload,
192+
},
193+
],
194+
});
195+
const { manager, list } = managerReturning(item);
196+
197+
const dataset = await manager.get("ds");
198+
199+
expect(list).toHaveBeenCalledWith(
200+
expect.not.objectContaining({ includeMediaReferences: true }),
201+
);
202+
const input = dataset.items[0].input as Record<string, unknown>;
203+
expect(input.image).toBe("@@@langfuseMedia:...@@@");
204+
});
205+
206+
it("leaves the field unchanged when media is null", async () => {
207+
const item = makeItem({
208+
input: { image: "@@@langfuseMedia:...@@@" },
209+
mediaReferences: [
210+
{
211+
field: "input",
212+
referenceString: "@@@langfuseMedia:...@@@",
213+
jsonPath: "$['image']",
214+
media: null,
215+
},
216+
],
217+
});
218+
const { manager } = managerReturning(item);
219+
220+
const dataset = await manager.get("ds", { resolveMediaReferences: true });
221+
222+
const input = dataset.items[0].input as Record<string, unknown>;
223+
expect(input.image).toBe("@@@langfuseMedia:...@@@");
224+
});
225+
});

0 commit comments

Comments
 (0)