diff --git a/js/src/client.ts b/js/src/client.ts index 802e26ce2..2eec43188 100644 --- a/js/src/client.ts +++ b/js/src/client.ts @@ -67,6 +67,7 @@ import { raiseForStatus } from "./utils/error.js"; import { _getFetchImplementation } from "./singletons/fetch.js"; import { stringify as stringifyForTracing } from "./utils/fast-safe-stringify/index.js"; +import { v4 as uuid4 } from "uuid"; export interface ClientConfig { apiUrl?: string; @@ -1148,12 +1149,20 @@ export class Client implements LangSmithTracingClientInterface { ); continue; } - accumulatedParts.push({ - name: `attachment.${payload.id}.${name}`, - payload: new Blob([content], { - type: `${contentType}; length=${content.byteLength}`, - }), - }); + // eslint-disable-next-line no-instanceof/no-instanceof + if (content instanceof Blob) { + accumulatedParts.push({ + name: `attachment.${payload.id}.${name}`, + payload: content, + }); + } else { + accumulatedParts.push({ + name: `attachment.${payload.id}.${name}`, + payload: new Blob([content], { + type: `${contentType}; length=${content.byteLength}`, + }), + }); + } } } } @@ -3918,77 +3927,7 @@ export class Client implements LangSmithTracingClientInterface { "Your LangSmith version does not allow using the multipart examples endpoint, please update to the latest version." ); } - const formData = new FormData(); - - for (const example of updates) { - const exampleId = example.id; - - // Prepare the main example body - const exampleBody = { - ...(example.metadata && { metadata: example.metadata }), - ...(example.split && { split: example.split }), - }; - - // Add main example data - const stringifiedExample = stringifyForTracing(exampleBody); - const exampleBlob = new Blob([stringifiedExample], { - type: "application/json", - }); - formData.append(exampleId, exampleBlob); - - // Add inputs - if (example.inputs) { - const stringifiedInputs = stringifyForTracing(example.inputs); - const inputsBlob = new Blob([stringifiedInputs], { - type: "application/json", - }); - formData.append(`${exampleId}.inputs`, inputsBlob); - } - - // Add outputs if present - if (example.outputs) { - const stringifiedOutputs = stringifyForTracing(example.outputs); - const outputsBlob = new Blob([stringifiedOutputs], { - type: "application/json", - }); - formData.append(`${exampleId}.outputs`, outputsBlob); - } - - // Add attachments if present - if (example.attachments) { - for (const [name, attachment] of Object.entries(example.attachments)) { - let mimeType: string; - let data: AttachmentData; - - if (Array.isArray(attachment)) { - [mimeType, data] = attachment; - } else { - mimeType = attachment.mimeType; - data = attachment.data; - } - const attachmentBlob = new Blob([data], { - type: `${mimeType}; length=${data.byteLength}`, - }); - formData.append(`${exampleId}.attachment.${name}`, attachmentBlob); - } - } - - if (example.attachments_operations) { - const stringifiedAttachmentsOperations = stringifyForTracing( - example.attachments_operations - ); - const attachmentsOperationsBlob = new Blob( - [stringifiedAttachmentsOperations], - { - type: "application/json", - } - ); - formData.append( - `${exampleId}.attachments_operations`, - attachmentsOperationsBlob - ); - } - } + const formData = _prepareMultiPartData(updates); const response = await this.caller.call( _getFetchImplementation(), @@ -4017,60 +3956,7 @@ export class Client implements LangSmithTracingClientInterface { "Your LangSmith version does not allow using the multipart examples endpoint, please update to the latest version." ); } - const formData = new FormData(); - - for (const example of uploads) { - const exampleId = (example.id ?? uuid.v4()).toString(); - - // Prepare the main example body - const exampleBody = { - created_at: example.created_at, - ...(example.metadata && { metadata: example.metadata }), - ...(example.split && { split: example.split }), - }; - - // Add main example data - const stringifiedExample = stringifyForTracing(exampleBody); - const exampleBlob = new Blob([stringifiedExample], { - type: "application/json", - }); - formData.append(exampleId, exampleBlob); - - // Add inputs - const stringifiedInputs = stringifyForTracing(example.inputs); - const inputsBlob = new Blob([stringifiedInputs], { - type: "application/json", - }); - formData.append(`${exampleId}.inputs`, inputsBlob); - - // Add outputs if present - if (example.outputs) { - const stringifiedOutputs = stringifyForTracing(example.outputs); - const outputsBlob = new Blob([stringifiedOutputs], { - type: "application/json", - }); - formData.append(`${exampleId}.outputs`, outputsBlob); - } - - // Add attachments if present - if (example.attachments) { - for (const [name, attachment] of Object.entries(example.attachments)) { - let mimeType: string; - let data: AttachmentData; - - if (Array.isArray(attachment)) { - [mimeType, data] = attachment; - } else { - mimeType = attachment.mimeType; - data = attachment.data; - } - const attachmentBlob = new Blob([data], { - type: `${mimeType}; length=${data.byteLength}`, - }); - formData.append(`${exampleId}.attachment.${name}`, attachmentBlob); - } - } - } + const formData = _prepareMultiPartData(uploads); const response = await this.caller.call( _getFetchImplementation(), @@ -4393,3 +4279,92 @@ export interface LangSmithTracingClientInterface { updateRun: (runId: string, run: RunUpdate) => Promise; } + +function isExampleUpdateWithAttachments( + obj: ExampleUpdateWithAttachments | ExampleUploadWithAttachments +): obj is ExampleUpdateWithAttachments { + return ( + (obj as ExampleUpdateWithAttachments).attachments_operations !== undefined + ); +} + +function _prepareMultiPartData( + examples: ExampleUpdateWithAttachments[] | ExampleUploadWithAttachments[] +): FormData { + const formData = new FormData(); + + for (const example of examples) { + const exampleId = example.id ?? uuid4(); + + // Prepare the main example body + const exampleBody = { + ...(example.metadata && { metadata: example.metadata }), + ...(example.split && { split: example.split }), + }; + + // Add main example data + const stringifiedExample = stringifyForTracing(exampleBody); + const exampleBlob = new Blob([stringifiedExample], { + type: "application/json", + }); + formData.append(exampleId, exampleBlob); + + // Add inputs + if (example.inputs) { + const stringifiedInputs = stringifyForTracing(example.inputs); + const inputsBlob = new Blob([stringifiedInputs], { + type: "application/json", + }); + formData.append(`${exampleId}.inputs`, inputsBlob); + } + + // Add outputs if present + if (example.outputs) { + const stringifiedOutputs = stringifyForTracing(example.outputs); + const outputsBlob = new Blob([stringifiedOutputs], { + type: "application/json", + }); + formData.append(`${exampleId}.outputs`, outputsBlob); + } + + // Add attachments if present + if (example.attachments) { + for (const [name, [mimeType, data]] of Object.entries( + example.attachments + )) { + // eslint-disable-next-line no-instanceof/no-instanceof + if (data instanceof Blob) { + formData.append(`${exampleId}.attachment.${name}`, data); + } else { + formData.append( + `${exampleId}.attachment.${name}`, + new Blob([data], { + type: `${mimeType}; length=${data.byteLength}`, + }) + ); + } + } + } + + if ( + isExampleUpdateWithAttachments(example) && + example.attachments_operations + ) { + const stringifiedAttachmentsOperations = stringifyForTracing( + example.attachments_operations + ); + const attachmentsOperationsBlob = new Blob( + [stringifiedAttachmentsOperations], + { + type: "application/json", + } + ); + formData.append( + `${exampleId}.attachments_operations`, + attachmentsOperationsBlob + ); + } + } + + return formData; +} diff --git a/js/src/schemas.ts b/js/src/schemas.ts index 9fe4e8e16..d7f2f6904 100644 --- a/js/src/schemas.ts +++ b/js/src/schemas.ts @@ -67,7 +67,8 @@ export interface AttachmentInfo { presigned_url: string; } -export type AttachmentData = Uint8Array | ArrayBuffer; + +export type AttachmentData = ArrayBuffer | Uint8Array | Blob; export type AttachmentDescription = { mimeType: string; diff --git a/js/src/tests/client.int.test.ts b/js/src/tests/client.int.test.ts index 137c98c79..a311815fd 100644 --- a/js/src/tests/client.int.test.ts +++ b/js/src/tests/client.int.test.ts @@ -1289,7 +1289,12 @@ test("upload examples multipart", async () => { inputs: { text: "foo bar" }, outputs: { response: "baz" }, attachments: { - my_file: ["image/png", fs.readFileSync(pathname)], + my_file: [ + "image/png", + new Blob([fs.readFileSync(pathname)], { + type: `image/png; length=${fs.readFileSync(pathname).byteLength}`, + }), + ], }, }; @@ -1303,12 +1308,14 @@ test("upload examples multipart", async () => { const createdExample1 = await client.readExample(exampleId); expect(createdExample1.inputs["text"]).toBe("hello world"); + expect(createdExample1.attachments?.["test_file"]).toBeDefined(); const createdExample2 = await client.readExample( createdExamples.example_ids.find((id) => id !== exampleId)! ); expect(createdExample2.inputs["text"]).toBe("foo bar"); expect(createdExample2.outputs?.["response"]).toBe("baz"); + expect(createdExample2.attachments?.["my_file"]).toBeDefined(); // Test examples were sent to correct dataset const allExamplesInDataset = []; diff --git a/js/src/tests/traceable.int.test.ts b/js/src/tests/traceable.int.test.ts index 80d6b5830..79815c5dd 100644 --- a/js/src/tests/traceable.int.test.ts +++ b/js/src/tests/traceable.int.test.ts @@ -671,6 +671,10 @@ test.concurrent( const testAttachment2 = new Uint8Array([5, 6, 7, 8]); const testAttachment3 = new ArrayBuffer(4); new Uint8Array(testAttachment3).set([13, 14, 15, 16]); + const testAttachment4Content = new Blob(["Hello world!"]); + const testAttachment4 = new Blob([testAttachment4Content], { + type: `text/plain; length=${testAttachment4Content.size}`, + }); const traceableWithAttachmentsAndInputs = traceable( ( @@ -696,6 +700,7 @@ test.concurrent( { test1bin: ["application/octet-stream", testAttachment1], test2bin: ["application/octet-stream", testAttachment2], + test3bin: ["application/octet-stream", testAttachment4], inputbin: ["application/octet-stream", attachment], input2bin: [ "application/octet-stream", @@ -749,6 +754,10 @@ test.concurrent( "application/octet-stream", testAttachment2, ]); + expect(runCreate?.attachments?.["test3bin"]).toEqual([ + "application/octet-stream", + testAttachment4, + ]); expect(runCreate?.attachments?.["inputbin"]).toEqual([ "application/octet-stream", new Uint8Array([9, 10, 11, 12]),