From 815aeea3677f4065dd59599ec7778536760f4503 Mon Sep 17 00:00:00 2001 From: jacoblee93 Date: Wed, 16 Oct 2024 16:51:11 -0700 Subject: [PATCH 01/17] Start work on using multipart endpoint --- js/src/client.ts | 153 ++++++++++++++++++++++++++++++++++++++++++++++ js/src/schemas.ts | 6 ++ 2 files changed, 159 insertions(+) diff --git a/js/src/client.ts b/js/src/client.ts index a61e9a84e..8f9bf65bc 100644 --- a/js/src/client.ts +++ b/js/src/client.ts @@ -276,6 +276,11 @@ type AutoBatchQueueItem = { item: RunCreate | RunUpdate; }; +type MultipartPart = { + name: string; + payload: Blob; +}; + async function mergeRuntimeEnvIntoRunCreates(runs: RunCreate[]) { const runtimeEnv = await getRuntimeEnvironment(); const envVars = getLangChainEnvVarsMetadata(); @@ -956,6 +961,154 @@ export class Client { await raiseForStatus(response, "batch create run", true); } + /** + * Batch ingest/upsert multiple runs in the Langsmith system. + * @param runs + */ + public async multipartIngestRuns({ + runCreates, + runUpdates, + }: { + runCreates?: RunCreate[]; + runUpdates?: RunUpdate[]; + }) { + if (runCreates === undefined && runUpdates === undefined) { + return; + } + // transform and convert to dicts + let preparedCreateParams = + runCreates?.map((create) => + this.prepareRunCreateOrUpdateInputs(create) + ) ?? []; + let preparedUpdateParams = + runUpdates?.map((update) => + this.prepareRunCreateOrUpdateInputs(update) + ) ?? []; + + // require trace_id and dotted_order + const invalidRunCreate = preparedCreateParams.find((runCreate) => { + return ( + runCreate.trace_id === undefined || runCreate.dotted_order === undefined + ); + }); + if (invalidRunCreate !== undefined) { + throw new Error( + `Multipart ingest requires "trace_id" and "dotted_order" to be set when creating a run` + ); + } + const invalidRunUpdate = preparedUpdateParams.find((runUpdate) => { + return ( + runUpdate.trace_id === undefined || runUpdate.dotted_order === undefined + ); + }); + if (invalidRunUpdate !== undefined) { + throw new Error( + `Multipart ingest requires "trace_id" and "dotted_order" to be set when updating a run` + ); + } + // combine post and patch dicts where possible + if (preparedCreateParams.length > 0 && preparedUpdateParams.length > 0) { + const createById = preparedCreateParams.reduce( + (params: Record, run) => { + if (!run.id) { + return params; + } + params[run.id] = run; + return params; + }, + {} + ); + const standaloneUpdates = []; + for (const updateParam of preparedUpdateParams) { + if (updateParam.id !== undefined && createById[updateParam.id]) { + createById[updateParam.id] = { + ...createById[updateParam.id], + ...updateParam, + }; + } else { + standaloneUpdates.push(updateParam); + } + } + preparedCreateParams = Object.values(createById); + preparedUpdateParams = standaloneUpdates; + } + if ( + preparedCreateParams.length === 0 && + preparedUpdateParams.length === 0 + ) { + return; + } + // send the runs in multipart requests + const accumulatedContext: string[] = []; + const accumulatedParts: MultipartPart[] = []; + for (const [method, payloads] of [ + ["post", preparedCreateParams] as const, + ["patch", preparedUpdateParams] as const, + ]) { + for (const originalPayload of payloads) { + // collect fields to be sent as separate parts + const { inputs, outputs, events, ...payload } = originalPayload; + const fields = { inputs, outputs, events }; + // encode the main run payload + accumulatedParts.push({ + name: `${method}.${payload.id}`, + payload: new Blob([stringifyForTracing(payload)], { + type: "application/json", + }), + }); + // encode the fields we collected + for (const [key, value] of Object.entries(fields)) { + if (value === undefined) { + continue; + } + accumulatedParts.push({ + name: `${method}.${payload.id}.${key}`, + payload: new Blob([stringifyForTracing(value)], { + type: "application/json", + }), + }); + } + + // compute context + accumulatedContext.push(`trace=${payload.trace_id},id=${payload.id}`); + } + } + await this._sendMultipartRequest( + accumulatedParts, + accumulatedContext.join("; ") + ); + } + + private async _sendMultipartRequest(parts: MultipartPart[], context: string) { + try { + const formData = new FormData(); + for (const part of parts) { + formData.append(part.name, part.payload); + } + await this.batchIngestCaller.call( + _getFetchImplementation(), + `${this.apiUrl}/runs/multipart`, + { + method: "POST", + headers: { + ...this.headers, + }, + body: formData, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + } + ); + } catch (e) { + let errorMessage = "Failed to multipart ingest runs"; + if (e instanceof Error) { + errorMessage += `: ${e.stack || e.message}`; + } else { + errorMessage += `: ${String(e)}`; + } + console.warn(`${errorMessage.trim()}\n\nContext: ${context}`); + } + } + public async updateRun(runId: string, run: RunUpdate): Promise { assertUuid(runId); if (run.inputs) { diff --git a/js/src/schemas.ts b/js/src/schemas.ts index 7dc9562d8..1e899bc2c 100644 --- a/js/src/schemas.ts +++ b/js/src/schemas.ts @@ -126,6 +126,12 @@ export interface BaseRun { * - 20230915T223155647Z1b64098b-4ab7-43f6-afee-992304f198d8.20230914T223155650Zc8d9f4c5-6c5a-4b2d-9b1c-3d9d7a7c5c7c */ dotted_order?: string; + + /** + * Attachments associated with the run. + * Each entry is a tuple of [mime_type, bytes] + */ + attachments?: Record; } type S3URL = { From f5298cfe05a635c7b799f775e45dd1f65fe4b9b5 Mon Sep 17 00:00:00 2001 From: jacoblee93 Date: Thu, 17 Oct 2024 15:16:42 -0700 Subject: [PATCH 02/17] Fix basic multipart support, add test --- js/src/client.ts | 43 +- js/src/tests/batch_client.test.ts | 1322 +++++++++++++++-------------- 2 files changed, 701 insertions(+), 664 deletions(-) diff --git a/js/src/client.ts b/js/src/client.ts index 8f9bf65bc..71c09c96a 100644 --- a/js/src/client.ts +++ b/js/src/client.ts @@ -426,8 +426,6 @@ export class Client { private autoBatchTracing = true; - private batchEndpointSupported?: boolean; - private autoBatchQueue = new Queue(); private pendingAutoBatchedRunLimit = 100; @@ -718,14 +716,26 @@ export class Client { return; } try { - await this.batchIngestRuns({ + if (this.serverInfo === undefined) { + try { + this.serverInfo = await this._getServerInfo(); + } catch (e) { + this.serverInfo = {}; + } + } + const ingestParams = { runCreates: batch .filter((item) => item.action === "create") .map((item) => item.item) as RunCreate[], runUpdates: batch .filter((item) => item.action === "update") .map((item) => item.item) as RunUpdate[], - }); + }; + if (this.serverInfo?.batch_ingest_config?.use_multipart_endpoint) { + await this.multipartIngestRuns(ingestParams); + } else { + await this.batchIngestRuns(ingestParams); + } } finally { done(); } @@ -773,15 +783,6 @@ export class Client { return response.json(); } - protected async batchEndpointIsSupported() { - try { - this.serverInfo = await this._getServerInfo(); - } catch (e) { - return false; - } - return true; - } - protected async _getSettings() { if (!this.settings) { this.settings = this._get("/settings"); @@ -890,10 +891,10 @@ export class Client { preparedCreateParams = await mergeRuntimeEnvIntoRunCreates( preparedCreateParams ); - if (this.batchEndpointSupported === undefined) { - this.batchEndpointSupported = await this.batchEndpointIsSupported(); + if (this.serverInfo === undefined) { + this.serverInfo = await this._getServerInfo(); } - if (!this.batchEndpointSupported) { + if (this.serverInfo?.version === undefined) { this.autoBatchTracing = false; for (const preparedCreateParam of rawBatch.post) { await this.createRun(preparedCreateParam as CreateRunParams); @@ -1050,10 +1051,11 @@ export class Client { const { inputs, outputs, events, ...payload } = originalPayload; const fields = { inputs, outputs, events }; // encode the main run payload + const stringifiedPayload = stringifyForTracing(payload); accumulatedParts.push({ name: `${method}.${payload.id}`, - payload: new Blob([stringifyForTracing(payload)], { - type: "application/json", + payload: new Blob([stringifiedPayload], { + type: `application/json; length=${stringifiedPayload.length}`, // encoding=gzip }), }); // encode the fields we collected @@ -1061,10 +1063,11 @@ export class Client { if (value === undefined) { continue; } + const stringifiedValue = stringifyForTracing(value); accumulatedParts.push({ name: `${method}.${payload.id}.${key}`, - payload: new Blob([stringifyForTracing(value)], { - type: "application/json", + payload: new Blob([stringifiedValue], { + type: `application/json; length=${stringifiedValue.length}`, }), }); } diff --git a/js/src/tests/batch_client.test.ts b/js/src/tests/batch_client.test.ts index c9f66a486..30da9e36b 100644 --- a/js/src/tests/batch_client.test.ts +++ b/js/src/tests/batch_client.test.ts @@ -5,702 +5,736 @@ import { Client } from "../client.js"; import { convertToDottedOrderFormat } from "../run_trees.js"; import { _getFetchImplementation } from "../singletons/fetch.js"; -describe("Batch client tracing", () => { - it("should create a batched run with the given input", async () => { - const client = new Client({ - apiKey: "test-api-key", - autoBatchTracing: true, - }); - const callSpy = jest - .spyOn((client as any).batchIngestCaller, "call") - .mockResolvedValue({ - ok: true, - text: () => "", - }); - jest - .spyOn(client as any, "batchEndpointIsSupported") - .mockResolvedValue(true); - const projectName = "__test_batch"; - - const runId = uuidv4(); - const dottedOrder = convertToDottedOrderFormat( - new Date().getTime() / 1000, - runId - ); - await client.createRun({ - id: runId, - project_name: projectName, - name: "test_run", - run_type: "llm", - inputs: { text: "hello world" }, - trace_id: runId, - dotted_order: dottedOrder, - }); +describe.each([["batch"], ["multipart"]])( + "Batch client tracing with %s endpoint", + (endpointType) => { + const extraBatchIngestConfig = + endpointType === "batch" + ? {} + : { + use_multipart_endpoint: true, + }; + it("should create a batched run with the given input", async () => { + const client = new Client({ + apiKey: "test-api-key", + autoBatchTracing: true, + }); + const callSpy = jest + .spyOn((client as any).batchIngestCaller, "call") + .mockResolvedValue({ + ok: true, + text: () => "", + }); + jest.spyOn(client as any, "_getServerInfo").mockImplementation(() => { + return { + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + }; + }); + const projectName = "__test_batch"; + + const runId = uuidv4(); + const dottedOrder = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); - await new Promise((resolve) => setTimeout(resolve, 300)); + await new Promise((resolve) => setTimeout(resolve, 300)); - const calledRequestParam: any = callSpy.mock.calls[0][2]; - expect(JSON.parse(calledRequestParam?.body)).toEqual({ - post: [ - expect.objectContaining({ - id: runId, - run_type: "llm", - inputs: { - text: "hello world", - }, - trace_id: runId, - dotted_order: dottedOrder, - }), - ], - patch: [], - }); + const calledRequestParam: any = callSpy.mock.calls[0][2]; + expect(JSON.parse(calledRequestParam?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + text: "hello world", + }, + trace_id: runId, + dotted_order: dottedOrder, + }), + ], + patch: [], + }); - expect(callSpy).toHaveBeenCalledWith( - _getFetchImplementation(), - "https://api.smith.langchain.com/runs/batch", - expect.objectContaining({ body: expect.any(String) }) - ); - }); - - it("should not throw an error if fetch fails for batch requests", async () => { - const client = new Client({ - apiKey: "test-api-key", - autoBatchTracing: true, + expect(callSpy).toHaveBeenCalledWith( + _getFetchImplementation(), + "https://api.smith.langchain.com/runs/batch", + expect.objectContaining({ body: expect.any(String) }) + ); }); - jest.spyOn((client as any).caller, "call").mockImplementation(() => { - throw new Error("Totally expected mock error"); - }); - jest - .spyOn((client as any).batchIngestCaller, "call") - .mockImplementation(() => { + + it("should not throw an error if fetch fails for batch requests", async () => { + const client = new Client({ + apiKey: "test-api-key", + autoBatchTracing: true, + }); + jest.spyOn((client as any).caller, "call").mockImplementation(() => { throw new Error("Totally expected mock error"); }); - jest - .spyOn(client as any, "batchEndpointIsSupported") - .mockResolvedValue(true); - const projectName = "__test_batch"; - - const runId = uuidv4(); - const dottedOrder = convertToDottedOrderFormat( - new Date().getTime() / 1000, - runId - ); - - await client.createRun({ - id: runId, - project_name: projectName, - name: "test_run", - run_type: "llm", - inputs: { text: "hello world" }, - trace_id: runId, - dotted_order: dottedOrder, + jest + .spyOn((client as any).batchIngestCaller, "call") + .mockImplementation(() => { + throw new Error("Totally expected mock error"); + }); + jest.spyOn(client as any, "_getServerInfo").mockImplementation(() => { + return { + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + }; + }); + const projectName = "__test_batch"; + + const runId = uuidv4(); + const dottedOrder = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId + ); + + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); + + await new Promise((resolve) => setTimeout(resolve, 300)); }); - await new Promise((resolve) => setTimeout(resolve, 300)); - }); + it("Create + update batching should merge into a single call", async () => { + const client = new Client({ + apiKey: "test-api-key", + autoBatchTracing: true, + }); + const callSpy = jest + .spyOn((client as any).batchIngestCaller, "call") + .mockResolvedValue({ + ok: true, + text: () => "", + }); + jest.spyOn(client as any, "_getServerInfo").mockImplementation(() => { + return { + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + }; + }); + const projectName = "__test_batch"; + + const runId = uuidv4(); + const dottedOrder = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); - it("Create + update batching should merge into a single call", async () => { - const client = new Client({ - apiKey: "test-api-key", - autoBatchTracing: true, - }); - const callSpy = jest - .spyOn((client as any).batchIngestCaller, "call") - .mockResolvedValue({ - ok: true, - text: () => "", - }); - jest - .spyOn(client as any, "batchEndpointIsSupported") - .mockResolvedValue(true); - const projectName = "__test_batch"; - - const runId = uuidv4(); - const dottedOrder = convertToDottedOrderFormat( - new Date().getTime() / 1000, - runId - ); - await client.createRun({ - id: runId, - project_name: projectName, - name: "test_run", - run_type: "llm", - inputs: { text: "hello world" }, - trace_id: runId, - dotted_order: dottedOrder, - }); + const endTime = Math.floor(new Date().getTime() / 1000); - const endTime = Math.floor(new Date().getTime() / 1000); + await client.updateRun(runId, { + outputs: { output: ["Hi"] }, + dotted_order: dottedOrder, + trace_id: runId, + end_time: endTime, + }); - await client.updateRun(runId, { - outputs: { output: ["Hi"] }, - dotted_order: dottedOrder, - trace_id: runId, - end_time: endTime, - }); + await new Promise((resolve) => setTimeout(resolve, 100)); - await new Promise((resolve) => setTimeout(resolve, 100)); + const calledRequestParam: any = callSpy.mock.calls[0][2]; + expect(JSON.parse(calledRequestParam?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + text: "hello world", + }, + outputs: { + output: ["Hi"], + }, + end_time: endTime, + trace_id: runId, + dotted_order: dottedOrder, + }), + ], + patch: [], + }); - const calledRequestParam: any = callSpy.mock.calls[0][2]; - expect(JSON.parse(calledRequestParam?.body)).toEqual({ - post: [ - expect.objectContaining({ - id: runId, - run_type: "llm", - inputs: { - text: "hello world", - }, - outputs: { - output: ["Hi"], - }, - end_time: endTime, - trace_id: runId, - dotted_order: dottedOrder, - }), - ], - patch: [], + expect(callSpy).toHaveBeenCalledWith( + _getFetchImplementation(), + "https://api.smith.langchain.com/runs/batch", + expect.objectContaining({ body: expect.any(String) }) + ); }); - expect(callSpy).toHaveBeenCalledWith( - _getFetchImplementation(), - "https://api.smith.langchain.com/runs/batch", - expect.objectContaining({ body: expect.any(String) }) - ); - }); - - it("should immediately trigger a batch on root run end", async () => { - const client = new Client({ - apiKey: "test-api-key", - autoBatchTracing: true, - }); - const callSpy = jest - .spyOn((client as any).batchIngestCaller, "call") - .mockResolvedValue({ - ok: true, - text: () => "", - }); - jest - .spyOn(client as any, "batchEndpointIsSupported") - .mockResolvedValue(true); - const projectName = "__test_batch"; - - const runId = uuidv4(); - const dottedOrder = convertToDottedOrderFormat( - new Date().getTime() / 1000, - runId - ); - await client.createRun({ - id: runId, - project_name: projectName, - name: "test_run", - run_type: "llm", - inputs: { text: "hello world" }, - trace_id: runId, - dotted_order: dottedOrder, - }); + it("should immediately trigger a batch on root run end", async () => { + const client = new Client({ + apiKey: "test-api-key", + autoBatchTracing: true, + }); + const callSpy = jest + .spyOn((client as any).batchIngestCaller, "call") + .mockResolvedValue({ + ok: true, + text: () => "", + }); + jest.spyOn(client as any, "_getServerInfo").mockImplementation(() => { + return { + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + }; + }); + const projectName = "__test_batch"; + + const runId = uuidv4(); + const dottedOrder = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); - // Wait for first batch to send - await new Promise((resolve) => setTimeout(resolve, 300)); + // Wait for first batch to send + await new Promise((resolve) => setTimeout(resolve, 300)); - const endTime = Math.floor(new Date().getTime() / 1000); + const endTime = Math.floor(new Date().getTime() / 1000); - // A root run finishing triggers the second batch - await client.updateRun(runId, { - outputs: { output: ["Hi"] }, - dotted_order: dottedOrder, - trace_id: runId, - end_time: endTime, - }); + // A root run finishing triggers the second batch + await client.updateRun(runId, { + outputs: { output: ["Hi"] }, + dotted_order: dottedOrder, + trace_id: runId, + end_time: endTime, + }); - const runId2 = uuidv4(); - const dottedOrder2 = convertToDottedOrderFormat( - new Date().getTime() / 1000, - runId2 - ); - - // Will send in a third batch, even though it's triggered around the same time as the update - await client.createRun({ - id: runId2, - project_name: projectName, - name: "test_run", - run_type: "llm", - inputs: { text: "hello world 2" }, - trace_id: runId2, - dotted_order: dottedOrder2, - }); + const runId2 = uuidv4(); + const dottedOrder2 = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId2 + ); + + // Will send in a third batch, even though it's triggered around the same time as the update + await client.createRun({ + id: runId2, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world 2" }, + trace_id: runId2, + dotted_order: dottedOrder2, + }); - await new Promise((resolve) => setTimeout(resolve, 300)); - - const calledRequestParam: any = callSpy.mock.calls[0][2]; - const calledRequestParam2: any = callSpy.mock.calls[1][2]; - const calledRequestParam3: any = callSpy.mock.calls[2][2]; - expect(JSON.parse(calledRequestParam?.body)).toEqual({ - post: [ - expect.objectContaining({ - id: runId, - run_type: "llm", - inputs: { - text: "hello world", - }, - trace_id: runId, - dotted_order: dottedOrder, - }), - ], - patch: [], - }); + await new Promise((resolve) => setTimeout(resolve, 300)); + + const calledRequestParam: any = callSpy.mock.calls[0][2]; + const calledRequestParam2: any = callSpy.mock.calls[1][2]; + const calledRequestParam3: any = callSpy.mock.calls[2][2]; + expect(JSON.parse(calledRequestParam?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + text: "hello world", + }, + trace_id: runId, + dotted_order: dottedOrder, + }), + ], + patch: [], + }); - expect(JSON.parse(calledRequestParam2?.body)).toEqual({ - post: [], - patch: [ - expect.objectContaining({ - id: runId, - dotted_order: dottedOrder, - trace_id: runId, - end_time: endTime, - outputs: { - output: ["Hi"], - }, - }), - ], - }); - expect(JSON.parse(calledRequestParam3?.body)).toEqual({ - post: [ - expect.objectContaining({ - id: runId2, - run_type: "llm", - inputs: { - text: "hello world 2", - }, - trace_id: runId2, - dotted_order: dottedOrder2, - }), - ], - patch: [], + expect(JSON.parse(calledRequestParam2?.body)).toEqual({ + post: [], + patch: [ + expect.objectContaining({ + id: runId, + dotted_order: dottedOrder, + trace_id: runId, + end_time: endTime, + outputs: { + output: ["Hi"], + }, + }), + ], + }); + expect(JSON.parse(calledRequestParam3?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runId2, + run_type: "llm", + inputs: { + text: "hello world 2", + }, + trace_id: runId2, + dotted_order: dottedOrder2, + }), + ], + patch: [], + }); }); - }); - it("should not trigger a batch on root run end and instead batch call with previous batch if blockOnRootRunFinalization is false", async () => { - const client = new Client({ - apiKey: "test-api-key", - autoBatchTracing: true, - blockOnRootRunFinalization: false, - }); - const callSpy = jest - .spyOn((client as any).batchIngestCaller, "call") - .mockResolvedValue({ - ok: true, - text: () => "", - }); - jest - .spyOn(client as any, "batchEndpointIsSupported") - .mockResolvedValue(true); - const projectName = "__test_batch"; - - const runId = uuidv4(); - const dottedOrder = convertToDottedOrderFormat( - new Date().getTime() / 1000, - runId - ); - await client.createRun({ - id: runId, - project_name: projectName, - name: "test_run", - run_type: "llm", - inputs: { text: "hello world" }, - trace_id: runId, - dotted_order: dottedOrder, - }); + it("should not trigger a batch on root run end and instead batch call with previous batch if blockOnRootRunFinalization is false", async () => { + const client = new Client({ + apiKey: "test-api-key", + autoBatchTracing: true, + blockOnRootRunFinalization: false, + }); + const callSpy = jest + .spyOn((client as any).batchIngestCaller, "call") + .mockResolvedValue({ + ok: true, + text: () => "", + }); + jest.spyOn(client as any, "_getServerInfo").mockImplementation(() => { + return { + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + }; + }); + const projectName = "__test_batch"; + + const runId = uuidv4(); + const dottedOrder = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); - expect((client as any).autoBatchQueue.size).toBe(1); - // Wait for first batch to send - await new Promise((resolve) => setTimeout(resolve, 300)); - expect((client as any).autoBatchQueue.size).toBe(0); + expect((client as any).autoBatchQueue.size).toBe(1); + // Wait for first batch to send + await new Promise((resolve) => setTimeout(resolve, 300)); + expect((client as any).autoBatchQueue.size).toBe(0); - const endTime = Math.floor(new Date().getTime() / 1000); + const endTime = Math.floor(new Date().getTime() / 1000); - // Start the the second batch - await client.updateRun(runId, { - outputs: { output: ["Hi"] }, - dotted_order: dottedOrder, - trace_id: runId, - end_time: endTime, - }); + // Start the the second batch + await client.updateRun(runId, { + outputs: { output: ["Hi"] }, + dotted_order: dottedOrder, + trace_id: runId, + end_time: endTime, + }); - const runId2 = uuidv4(); - const dottedOrder2 = convertToDottedOrderFormat( - new Date().getTime() / 1000, - runId2 - ); - - // Should aggregate on the second batch - await client.createRun({ - id: runId2, - project_name: projectName, - name: "test_run", - run_type: "llm", - inputs: { text: "hello world 2" }, - trace_id: runId2, - dotted_order: dottedOrder2, - }); + const runId2 = uuidv4(); + const dottedOrder2 = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId2 + ); + + // Should aggregate on the second batch + await client.createRun({ + id: runId2, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world 2" }, + trace_id: runId2, + dotted_order: dottedOrder2, + }); - // 2 runs in the queue - expect((client as any).autoBatchQueue.size).toBe(2); - await client.awaitPendingTraceBatches(); - expect((client as any).autoBatchQueue.size).toBe(0); - - expect(callSpy.mock.calls.length).toEqual(2); - const calledRequestParam: any = callSpy.mock.calls[0][2]; - const calledRequestParam2: any = callSpy.mock.calls[1][2]; - expect(JSON.parse(calledRequestParam?.body)).toEqual({ - post: [ - expect.objectContaining({ - id: runId, - run_type: "llm", - inputs: { - text: "hello world", - }, - trace_id: runId, - dotted_order: dottedOrder, - }), - ], - patch: [], - }); + // 2 runs in the queue + expect((client as any).autoBatchQueue.size).toBe(2); + await client.awaitPendingTraceBatches(); + expect((client as any).autoBatchQueue.size).toBe(0); + + expect(callSpy.mock.calls.length).toEqual(2); + const calledRequestParam: any = callSpy.mock.calls[0][2]; + const calledRequestParam2: any = callSpy.mock.calls[1][2]; + expect(JSON.parse(calledRequestParam?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + text: "hello world", + }, + trace_id: runId, + dotted_order: dottedOrder, + }), + ], + patch: [], + }); - expect(JSON.parse(calledRequestParam2?.body)).toEqual({ - post: [ - expect.objectContaining({ - id: runId2, - run_type: "llm", - inputs: { - text: "hello world 2", - }, - trace_id: runId2, - dotted_order: dottedOrder2, - }), - ], - patch: [ - expect.objectContaining({ - id: runId, - dotted_order: dottedOrder, - trace_id: runId, - end_time: endTime, - outputs: { - output: ["Hi"], - }, - }), - ], + expect(JSON.parse(calledRequestParam2?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runId2, + run_type: "llm", + inputs: { + text: "hello world 2", + }, + trace_id: runId2, + dotted_order: dottedOrder2, + }), + ], + patch: [ + expect.objectContaining({ + id: runId, + dotted_order: dottedOrder, + trace_id: runId, + end_time: endTime, + outputs: { + output: ["Hi"], + }, + }), + ], + }); }); - }); - it("should send traces above the batch size and see even batches", async () => { - const client = new Client({ - apiKey: "test-api-key", - pendingAutoBatchedRunLimit: 10, - autoBatchTracing: true, - }); - const callSpy = jest - .spyOn((client as any).batchIngestCaller, "call") - .mockResolvedValue({ - ok: true, - text: () => "", - }); - jest - .spyOn(client as any, "batchEndpointIsSupported") - .mockResolvedValue(true); - const projectName = "__test_batch"; - - const runIds = await Promise.all( - [...Array(15)].map(async (_, i) => { - const runId = uuidv4(); - const dottedOrder = convertToDottedOrderFormat( - new Date().getTime() / 1000, - runId - ); - await client.createRun({ - id: runId, - project_name: projectName, - name: "test_run " + i, - run_type: "llm", - inputs: { text: "hello world " + i }, - trace_id: runId, - dotted_order: dottedOrder, + it("should send traces above the batch size and see even batches", async () => { + const client = new Client({ + apiKey: "test-api-key", + pendingAutoBatchedRunLimit: 10, + autoBatchTracing: true, + }); + const callSpy = jest + .spyOn((client as any).batchIngestCaller, "call") + .mockResolvedValue({ + ok: true, + text: () => "", }); - return runId; - }) - ); - - await new Promise((resolve) => setTimeout(resolve, 10)); - - const calledRequestParam: any = callSpy.mock.calls[0][2]; - const calledRequestParam2: any = callSpy.mock.calls[1][2]; - - // Queue should drain as soon as size limit is reached, - // sending both batches - expect(JSON.parse(calledRequestParam?.body)).toEqual({ - post: runIds.slice(0, 10).map((runId, i) => - expect.objectContaining({ - id: runId, - run_type: "llm", - inputs: { - text: "hello world " + i, - }, - trace_id: runId, + jest.spyOn(client as any, "_getServerInfo").mockImplementation(() => { + return { + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + }; + }); + const projectName = "__test_batch"; + + const runIds = await Promise.all( + [...Array(15)].map(async (_, i) => { + const runId = uuidv4(); + const dottedOrder = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run " + i, + run_type: "llm", + inputs: { text: "hello world " + i }, + trace_id: runId, + dotted_order: dottedOrder, + }); + return runId; }) - ), - patch: [], - }); + ); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const calledRequestParam: any = callSpy.mock.calls[0][2]; + const calledRequestParam2: any = callSpy.mock.calls[1][2]; + + // Queue should drain as soon as size limit is reached, + // sending both batches + expect(JSON.parse(calledRequestParam?.body)).toEqual({ + post: runIds.slice(0, 10).map((runId, i) => + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + text: "hello world " + i, + }, + trace_id: runId, + }) + ), + patch: [], + }); - expect(JSON.parse(calledRequestParam2?.body)).toEqual({ - post: runIds.slice(10).map((runId, i) => - expect.objectContaining({ - id: runId, - run_type: "llm", - inputs: { - text: "hello world " + (i + 10), - }, - trace_id: runId, - }) - ), - patch: [], + expect(JSON.parse(calledRequestParam2?.body)).toEqual({ + post: runIds.slice(10).map((runId, i) => + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + text: "hello world " + (i + 10), + }, + trace_id: runId, + }) + ), + patch: [], + }); }); - }); - it("should send traces above the batch size limit in bytes and see even batches", async () => { - const client = new Client({ - apiKey: "test-api-key", - pendingAutoBatchedRunLimit: 10, - autoBatchTracing: true, - }); - const callSpy = jest - .spyOn((client as any).batchIngestCaller, "call") - .mockResolvedValue({ - ok: true, - text: () => "", - }); - jest.spyOn(client as any, "_getServerInfo").mockResolvedValue({ - batch_ingest_config: { - size_limit_bytes: 1, - }, - }); - const projectName = "__test_batch"; - - const runIds = await Promise.all( - [...Array(4)].map(async (_, i) => { - const runId = uuidv4(); - const dottedOrder = convertToDottedOrderFormat( - new Date().getTime() / 1000, - runId - ); - await client.createRun({ - id: runId, - project_name: projectName, - name: "test_run " + i, - run_type: "llm", - inputs: { text: "hello world " + i }, - trace_id: runId, - dotted_order: dottedOrder, + it("should send traces above the batch size limit in bytes and see even batches", async () => { + const client = new Client({ + apiKey: "test-api-key", + pendingAutoBatchedRunLimit: 10, + autoBatchTracing: true, + }); + const callSpy = jest + .spyOn((client as any).batchIngestCaller, "call") + .mockResolvedValue({ + ok: true, + text: () => "", }); - return runId; - }) - ); - - await new Promise((resolve) => setTimeout(resolve, 300)); - - expect(callSpy.mock.calls.length).toEqual(4); - - const calledRequestParam: any = callSpy.mock.calls[0][2]; - const calledRequestParam2: any = callSpy.mock.calls[1][2]; - const calledRequestParam3: any = callSpy.mock.calls[2][2]; - const calledRequestParam4: any = callSpy.mock.calls[3][2]; - - // Queue should drain as soon as byte size limit of 1 is reached, - // sending each call individually - expect(JSON.parse(calledRequestParam?.body)).toEqual({ - post: [ - expect.objectContaining({ - id: runIds[0], - run_type: "llm", - inputs: { - text: "hello world 0", + jest.spyOn(client as any, "_getServerInfo").mockImplementation(() => { + return { + version: "foo", + batch_ingest_config: { + ...extraBatchIngestConfig, + size_limit_bytes: 1, }, - trace_id: runIds[0], - }), - ], - patch: [], - }); + }; + }); + const projectName = "__test_batch"; + + const runIds = await Promise.all( + [...Array(4)].map(async (_, i) => { + const runId = uuidv4(); + const dottedOrder = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run " + i, + run_type: "llm", + inputs: { text: "hello world " + i }, + trace_id: runId, + dotted_order: dottedOrder, + }); + return runId; + }) + ); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(callSpy.mock.calls.length).toEqual(4); + + const calledRequestParam: any = callSpy.mock.calls[0][2]; + const calledRequestParam2: any = callSpy.mock.calls[1][2]; + const calledRequestParam3: any = callSpy.mock.calls[2][2]; + const calledRequestParam4: any = callSpy.mock.calls[3][2]; + + // Queue should drain as soon as byte size limit of 1 is reached, + // sending each call individually + expect(JSON.parse(calledRequestParam?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runIds[0], + run_type: "llm", + inputs: { + text: "hello world 0", + }, + trace_id: runIds[0], + }), + ], + patch: [], + }); - expect(JSON.parse(calledRequestParam2?.body)).toEqual({ - post: [ - expect.objectContaining({ - id: runIds[1], - run_type: "llm", - inputs: { - text: "hello world 1", - }, - trace_id: runIds[1], - }), - ], - patch: [], - }); + expect(JSON.parse(calledRequestParam2?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runIds[1], + run_type: "llm", + inputs: { + text: "hello world 1", + }, + trace_id: runIds[1], + }), + ], + patch: [], + }); - expect(JSON.parse(calledRequestParam3?.body)).toEqual({ - post: [ - expect.objectContaining({ - id: runIds[2], - run_type: "llm", - inputs: { - text: "hello world 2", - }, - trace_id: runIds[2], - }), - ], - patch: [], - }); + expect(JSON.parse(calledRequestParam3?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runIds[2], + run_type: "llm", + inputs: { + text: "hello world 2", + }, + trace_id: runIds[2], + }), + ], + patch: [], + }); - expect(JSON.parse(calledRequestParam4?.body)).toEqual({ - post: [ - expect.objectContaining({ - id: runIds[3], - run_type: "llm", - inputs: { - text: "hello world 3", - }, - trace_id: runIds[3], - }), - ], - patch: [], + expect(JSON.parse(calledRequestParam4?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runIds[3], + run_type: "llm", + inputs: { + text: "hello world 3", + }, + trace_id: runIds[3], + }), + ], + patch: [], + }); }); - }); - it("If batching is unsupported, fall back to old endpoint", async () => { - const client = new Client({ - apiKey: "test-api-key", - autoBatchTracing: true, - }); - const callSpy = jest - .spyOn((client as any).caller, "call") - .mockResolvedValue({ - ok: true, - text: () => "", - }); - jest - .spyOn(client as any, "batchEndpointIsSupported") - .mockResolvedValue(false); - const projectName = "__test_batch"; - - const runId = uuidv4(); - const dottedOrder = convertToDottedOrderFormat( - new Date().getTime() / 1000, - runId - ); - await client.createRun({ - id: runId, - project_name: projectName, - name: "test_run", - run_type: "llm", - inputs: { text: "hello world" }, - trace_id: runId, - dotted_order: dottedOrder, - }); + it("If batching is unsupported, fall back to old endpoint", async () => { + const client = new Client({ + apiKey: "test-api-key", + autoBatchTracing: true, + }); + const callSpy = jest + .spyOn((client as any).caller, "call") + .mockResolvedValue({ + ok: true, + text: () => "", + }); + jest.spyOn(client as any, "_getServerInfo").mockImplementation(() => { + return {}; + }); + const projectName = "__test_batch"; + + const runId = uuidv4(); + const dottedOrder = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); - await new Promise((resolve) => setTimeout(resolve, 300)); - - const calledRequestParam: any = callSpy.mock.calls[0][2]; - expect(JSON.parse(calledRequestParam?.body)).toMatchObject({ - id: runId, - session_name: projectName, - extra: expect.anything(), - start_time: expect.any(Number), - name: "test_run", - run_type: "llm", - inputs: { text: "hello world" }, - trace_id: runId, - dotted_order: dottedOrder, - }); + await new Promise((resolve) => setTimeout(resolve, 300)); + + const calledRequestParam: any = callSpy.mock.calls[0][2]; + expect(JSON.parse(calledRequestParam?.body)).toMatchObject({ + id: runId, + session_name: projectName, + extra: expect.anything(), + start_time: expect.any(Number), + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); - expect(callSpy).toHaveBeenCalledWith( - _getFetchImplementation(), - "https://api.smith.langchain.com/runs", - expect.objectContaining({ body: expect.any(String) }) - ); - }); - - it("Should handle circular values", async () => { - const client = new Client({ - apiKey: "test-api-key", - autoBatchTracing: true, - }); - const callSpy = jest - .spyOn((client as any).batchIngestCaller, "call") - .mockResolvedValue({ - ok: true, - text: () => "", - }); - jest - .spyOn(client as any, "batchEndpointIsSupported") - .mockResolvedValue(true); - const projectName = "__test_batch"; - const a: Record = {}; - const b: Record = {}; - a.b = b; - b.a = a; - - const runId = uuidv4(); - const dottedOrder = convertToDottedOrderFormat( - new Date().getTime() / 1000, - runId - ); - await client.createRun({ - id: runId, - project_name: projectName, - name: "test_run", - run_type: "llm", - inputs: a, - trace_id: runId, - dotted_order: dottedOrder, + expect(callSpy).toHaveBeenCalledWith( + _getFetchImplementation(), + "https://api.smith.langchain.com/runs", + expect.objectContaining({ body: expect.any(String) }) + ); }); - const endTime = Math.floor(new Date().getTime() / 1000); + it("Should handle circular values", async () => { + const client = new Client({ + apiKey: "test-api-key", + autoBatchTracing: true, + }); + const callSpy = jest + .spyOn((client as any).batchIngestCaller, "call") + .mockResolvedValue({ + ok: true, + text: () => "", + }); + jest.spyOn(client as any, "_getServerInfo").mockImplementation(() => { + return { + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + }; + }); + const projectName = "__test_batch"; + const a: Record = {}; + const b: Record = {}; + a.b = b; + b.a = a; + + const runId = uuidv4(); + const dottedOrder = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: a, + trace_id: runId, + dotted_order: dottedOrder, + }); - await client.updateRun(runId, { - outputs: b, - dotted_order: dottedOrder, - trace_id: runId, - end_time: endTime, - }); + const endTime = Math.floor(new Date().getTime() / 1000); - await new Promise((resolve) => setTimeout(resolve, 100)); + await client.updateRun(runId, { + outputs: b, + dotted_order: dottedOrder, + trace_id: runId, + end_time: endTime, + }); - const calledRequestParam: any = callSpy.mock.calls[0][2]; - expect(JSON.parse(calledRequestParam?.body)).toEqual({ - post: [ - expect.objectContaining({ - id: runId, - run_type: "llm", - inputs: { - b: { + await new Promise((resolve) => setTimeout(resolve, 100)); + + const calledRequestParam: any = callSpy.mock.calls[0][2]; + expect(JSON.parse(calledRequestParam?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + b: { + a: { + result: "[Circular]", + }, + }, + }, + outputs: { a: { result: "[Circular]", }, }, - }, - outputs: { - a: { - result: "[Circular]", - }, - }, - end_time: endTime, - trace_id: runId, - dotted_order: dottedOrder, - }), - ], - patch: [], - }); + end_time: endTime, + trace_id: runId, + dotted_order: dottedOrder, + }), + ], + patch: [], + }); - expect(callSpy).toHaveBeenCalledWith( - _getFetchImplementation(), - "https://api.smith.langchain.com/runs/batch", - expect.objectContaining({ body: expect.any(String) }) - ); - }); -}); + expect(callSpy).toHaveBeenCalledWith( + _getFetchImplementation(), + "https://api.smith.langchain.com/runs/batch", + expect.objectContaining({ body: expect.any(String) }) + ); + }); + } +); From 30bed06801ae2b30df21ac7ed09ff4682588f694 Mon Sep 17 00:00:00 2001 From: jacoblee93 Date: Sat, 19 Oct 2024 23:08:54 -0700 Subject: [PATCH 03/17] Add batching by byte size, test --- js/src/client.ts | 90 +++++++++++++++------- js/src/tests/batch_client.test.ts | 119 ++++++++++++++++++++++-------- 2 files changed, 152 insertions(+), 57 deletions(-) diff --git a/js/src/client.ts b/js/src/client.ts index 71c09c96a..b1d36f9a2 100644 --- a/js/src/client.ts +++ b/js/src/client.ts @@ -363,10 +363,17 @@ const handle429 = async (response?: Response) => { }; export class Queue { - items: [T, () => void, Promise][] = []; + items: { + payload: T; + itemPromiseResolve: () => void; + itemPromise: Promise; + size: number; + }[] = []; - get size() { - return this.items.length; + sizeBytes = 0; + + peek() { + return this.items[0]; } push(item: T): Promise { @@ -376,25 +383,48 @@ export class Queue { // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/Promise itemPromiseResolve = resolve; }); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.items.push([item, itemPromiseResolve!, itemPromise]); + const size = stringifyForTracing(item).length; + this.items.push({ + payload: item, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + itemPromiseResolve: itemPromiseResolve!, + itemPromise, + size, + }); + this.sizeBytes += size; return itemPromise; } - pop(upToN: number): [T[], () => void] { - if (upToN < 1) { - throw new Error("Number of items to pop off may not be less than 1."); + pop(upToSizeBytes: number): [T[], () => void] { + if (upToSizeBytes < 1) { + throw new Error("Number of bytes to pop off may not be less than 1."); } const popped: typeof this.items = []; - while (popped.length < upToN && this.items.length) { + let poppedSizeBytes = 0; + // Pop items until we reach or exceed the size limit + while ( + poppedSizeBytes + (this.peek()?.size ?? 0) < upToSizeBytes && + this.items.length > 0 + ) { const item = this.items.shift(); if (item) { popped.push(item); - } else { - break; + poppedSizeBytes += item.size; + this.sizeBytes -= item.size; } } - return [popped.map((it) => it[0]), () => popped.forEach((it) => it[1]())]; + // If there is an item on the queue we were unable to pop, + // just return it as a single batch. + if (popped.length === 0 && this.items.length > 0) { + const item = this.items.shift()!; + popped.push(item); + poppedSizeBytes += item.size; + this.sizeBytes -= item.size; + } + return [ + popped.map((it) => it.payload), + () => popped.forEach((it) => it.itemPromiseResolve()), + ]; } } @@ -633,6 +663,7 @@ export class Client { offset += items.length; } } + private async *_getCursorPaginatedList( path: string, body: RecordStringAny | null = null, @@ -706,23 +737,30 @@ export class Client { } } + private async _getBatchSizeLimitBytes() { + if (this.serverInfo === undefined) { + try { + this.serverInfo = await this._getServerInfo(); + } catch (e) { + this.serverInfo = {}; + } + } + return ( + this.serverInfo?.batch_ingest_config?.size_limit_bytes ?? + DEFAULT_BATCH_SIZE_LIMIT_BYTES + ); + } + private async drainAutoBatchQueue() { - while (this.autoBatchQueue.size >= 0) { + while (this.autoBatchQueue.items.length >= 0) { const [batch, done] = this.autoBatchQueue.pop( - this.pendingAutoBatchedRunLimit + await this._getBatchSizeLimitBytes() ); if (!batch.length) { done(); return; } try { - if (this.serverInfo === undefined) { - try { - this.serverInfo = await this._getServerInfo(); - } catch (e) { - this.serverInfo = {}; - } - } const ingestParams = { runCreates: batch .filter((item) => item.action === "create") @@ -752,11 +790,11 @@ export class Client { const itemPromise = this.autoBatchQueue.push(item); if ( immediatelyTriggerBatch || - this.autoBatchQueue.size > this.pendingAutoBatchedRunLimit + this.autoBatchQueue.items.length > this.pendingAutoBatchedRunLimit ) { await this.drainAutoBatchQueue().catch(console.error); } - if (this.autoBatchQueue.size > 0) { + if (this.autoBatchQueue.items.length > 0) { this.autoBatchTimeout = setTimeout( () => { this.autoBatchTimeout = undefined; @@ -909,9 +947,7 @@ export class Client { } return; } - const sizeLimitBytes = - this.serverInfo?.batch_ingest_config?.size_limit_bytes ?? - DEFAULT_BATCH_SIZE_LIMIT_BYTES; + const sizeLimitBytes = await this._getBatchSizeLimitBytes(); const batchChunks = { post: [] as (typeof rawBatch)["post"], patch: [] as (typeof rawBatch)["patch"], @@ -4078,7 +4114,7 @@ export class Client { */ public awaitPendingTraceBatches() { return Promise.all( - this.autoBatchQueue.items.map(([, , promise]) => promise) + this.autoBatchQueue.items.map(({ itemPromise }) => itemPromise) ); } } diff --git a/js/src/tests/batch_client.test.ts b/js/src/tests/batch_client.test.ts index 30da9e36b..64a63512f 100644 --- a/js/src/tests/batch_client.test.ts +++ b/js/src/tests/batch_client.test.ts @@ -5,7 +5,44 @@ import { Client } from "../client.js"; import { convertToDottedOrderFormat } from "../run_trees.js"; import { _getFetchImplementation } from "../singletons/fetch.js"; -describe.each([["batch"], ["multipart"]])( +const parseMockRequestBody = async (body: string | FormData) => { + if (typeof body === "string") { + return JSON.parse(body); + } + // Typing is missing + const entries: any[] = Array.from((body as any).entries()); + const reconstructedBody: any = { + post: [], + patch: [], + }; + for (const [key, value] of entries) { + const [method, id, type] = key.split("."); + const parsedValue = JSON.parse(await value.text()); + if (!(method in reconstructedBody)) { + throw new Error(`${method} must be "post" or "patch"`); + } + if (!type) { + reconstructedBody[method as keyof typeof reconstructedBody].push( + parsedValue + ); + } else { + for (const item of reconstructedBody[method]) { + if (item.id === id) { + item[type] = parsedValue; + } + } + } + } + return reconstructedBody; +}; + +// prettier-ignore +const ENDPOINT_TYPES = [ + "batch", + "multipart", +]; + +describe.each(ENDPOINT_TYPES)( "Batch client tracing with %s endpoint", (endpointType) => { const extraBatchIngestConfig = @@ -14,6 +51,10 @@ describe.each([["batch"], ["multipart"]])( : { use_multipart_endpoint: true, }; + const expectedTraceURL = + endpointType === "batch" + ? "https://api.smith.langchain.com/runs/batch" + : "https://api.smith.langchain.com/runs/multipart"; it("should create a batched run with the given input", async () => { const client = new Client({ apiKey: "test-api-key", @@ -51,7 +92,7 @@ describe.each([["batch"], ["multipart"]])( await new Promise((resolve) => setTimeout(resolve, 300)); const calledRequestParam: any = callSpy.mock.calls[0][2]; - expect(JSON.parse(calledRequestParam?.body)).toEqual({ + expect(await parseMockRequestBody(calledRequestParam?.body)).toEqual({ post: [ expect.objectContaining({ id: runId, @@ -68,8 +109,10 @@ describe.each([["batch"], ["multipart"]])( expect(callSpy).toHaveBeenCalledWith( _getFetchImplementation(), - "https://api.smith.langchain.com/runs/batch", - expect.objectContaining({ body: expect.any(String) }) + expectedTraceURL, + expect.objectContaining({ + body: expect.any(endpointType === "batch" ? String : FormData), + }) ); }); @@ -159,7 +202,7 @@ describe.each([["batch"], ["multipart"]])( await new Promise((resolve) => setTimeout(resolve, 100)); const calledRequestParam: any = callSpy.mock.calls[0][2]; - expect(JSON.parse(calledRequestParam?.body)).toEqual({ + expect(await parseMockRequestBody(calledRequestParam?.body)).toEqual({ post: [ expect.objectContaining({ id: runId, @@ -180,8 +223,10 @@ describe.each([["batch"], ["multipart"]])( expect(callSpy).toHaveBeenCalledWith( _getFetchImplementation(), - "https://api.smith.langchain.com/runs/batch", - expect.objectContaining({ body: expect.any(String) }) + expectedTraceURL, + expect.objectContaining({ + body: expect.any(endpointType === "batch" ? String : FormData), + }) ); }); @@ -254,7 +299,7 @@ describe.each([["batch"], ["multipart"]])( const calledRequestParam: any = callSpy.mock.calls[0][2]; const calledRequestParam2: any = callSpy.mock.calls[1][2]; const calledRequestParam3: any = callSpy.mock.calls[2][2]; - expect(JSON.parse(calledRequestParam?.body)).toEqual({ + expect(await parseMockRequestBody(calledRequestParam?.body)).toEqual({ post: [ expect.objectContaining({ id: runId, @@ -269,7 +314,7 @@ describe.each([["batch"], ["multipart"]])( patch: [], }); - expect(JSON.parse(calledRequestParam2?.body)).toEqual({ + expect(await parseMockRequestBody(calledRequestParam2?.body)).toEqual({ post: [], patch: [ expect.objectContaining({ @@ -283,7 +328,7 @@ describe.each([["batch"], ["multipart"]])( }), ], }); - expect(JSON.parse(calledRequestParam3?.body)).toEqual({ + expect(await parseMockRequestBody(calledRequestParam3?.body)).toEqual({ post: [ expect.objectContaining({ id: runId2, @@ -334,10 +379,10 @@ describe.each([["batch"], ["multipart"]])( dotted_order: dottedOrder, }); - expect((client as any).autoBatchQueue.size).toBe(1); + expect((client as any).autoBatchQueue.items.length).toBe(1); // Wait for first batch to send await new Promise((resolve) => setTimeout(resolve, 300)); - expect((client as any).autoBatchQueue.size).toBe(0); + expect((client as any).autoBatchQueue.items.length).toBe(0); const endTime = Math.floor(new Date().getTime() / 1000); @@ -367,14 +412,14 @@ describe.each([["batch"], ["multipart"]])( }); // 2 runs in the queue - expect((client as any).autoBatchQueue.size).toBe(2); + expect((client as any).autoBatchQueue.items.length).toBe(2); await client.awaitPendingTraceBatches(); - expect((client as any).autoBatchQueue.size).toBe(0); + expect((client as any).autoBatchQueue.items.length).toBe(0); expect(callSpy.mock.calls.length).toEqual(2); const calledRequestParam: any = callSpy.mock.calls[0][2]; const calledRequestParam2: any = callSpy.mock.calls[1][2]; - expect(JSON.parse(calledRequestParam?.body)).toEqual({ + expect(await parseMockRequestBody(calledRequestParam?.body)).toEqual({ post: [ expect.objectContaining({ id: runId, @@ -389,7 +434,7 @@ describe.each([["batch"], ["multipart"]])( patch: [], }); - expect(JSON.parse(calledRequestParam2?.body)).toEqual({ + expect(await parseMockRequestBody(calledRequestParam2?.body)).toEqual({ post: [ expect.objectContaining({ id: runId2, @@ -462,7 +507,7 @@ describe.each([["batch"], ["multipart"]])( // Queue should drain as soon as size limit is reached, // sending both batches - expect(JSON.parse(calledRequestParam?.body)).toEqual({ + expect(await parseMockRequestBody(calledRequestParam?.body)).toEqual({ post: runIds.slice(0, 10).map((runId, i) => expect.objectContaining({ id: runId, @@ -476,7 +521,7 @@ describe.each([["batch"], ["multipart"]])( patch: [], }); - expect(JSON.parse(calledRequestParam2?.body)).toEqual({ + expect(await parseMockRequestBody(calledRequestParam2?.body)).toEqual({ post: runIds.slice(10).map((runId, i) => expect.objectContaining({ id: runId, @@ -545,7 +590,7 @@ describe.each([["batch"], ["multipart"]])( // Queue should drain as soon as byte size limit of 1 is reached, // sending each call individually - expect(JSON.parse(calledRequestParam?.body)).toEqual({ + expect(await parseMockRequestBody(calledRequestParam?.body)).toEqual({ post: [ expect.objectContaining({ id: runIds[0], @@ -559,7 +604,7 @@ describe.each([["batch"], ["multipart"]])( patch: [], }); - expect(JSON.parse(calledRequestParam2?.body)).toEqual({ + expect(await parseMockRequestBody(calledRequestParam2?.body)).toEqual({ post: [ expect.objectContaining({ id: runIds[1], @@ -573,7 +618,7 @@ describe.each([["batch"], ["multipart"]])( patch: [], }); - expect(JSON.parse(calledRequestParam3?.body)).toEqual({ + expect(await parseMockRequestBody(calledRequestParam3?.body)).toEqual({ post: [ expect.objectContaining({ id: runIds[2], @@ -587,7 +632,7 @@ describe.each([["batch"], ["multipart"]])( patch: [], }); - expect(JSON.parse(calledRequestParam4?.body)).toEqual({ + expect(await parseMockRequestBody(calledRequestParam4?.body)).toEqual({ post: [ expect.objectContaining({ id: runIds[3], @@ -636,7 +681,9 @@ describe.each([["batch"], ["multipart"]])( await new Promise((resolve) => setTimeout(resolve, 300)); const calledRequestParam: any = callSpy.mock.calls[0][2]; - expect(JSON.parse(calledRequestParam?.body)).toMatchObject({ + expect( + await parseMockRequestBody(calledRequestParam?.body) + ).toMatchObject({ id: runId, session_name: projectName, extra: expect.anything(), @@ -651,7 +698,9 @@ describe.each([["batch"], ["multipart"]])( expect(callSpy).toHaveBeenCalledWith( _getFetchImplementation(), "https://api.smith.langchain.com/runs", - expect.objectContaining({ body: expect.any(String) }) + expect.objectContaining({ + body: expect.any(String), + }) ); }); @@ -705,7 +754,7 @@ describe.each([["batch"], ["multipart"]])( await new Promise((resolve) => setTimeout(resolve, 100)); const calledRequestParam: any = callSpy.mock.calls[0][2]; - expect(JSON.parse(calledRequestParam?.body)).toEqual({ + expect(await parseMockRequestBody(calledRequestParam?.body)).toEqual({ post: [ expect.objectContaining({ id: runId, @@ -718,9 +767,17 @@ describe.each([["batch"], ["multipart"]])( }, }, outputs: { - a: { - result: "[Circular]", - }, + a: + // Stringification happens at a different level + endpointType === "batch" + ? { + result: "[Circular]", + } + : { + b: { + result: "[Circular]", + }, + }, }, end_time: endTime, trace_id: runId, @@ -732,8 +789,10 @@ describe.each([["batch"], ["multipart"]])( expect(callSpy).toHaveBeenCalledWith( _getFetchImplementation(), - "https://api.smith.langchain.com/runs/batch", - expect.objectContaining({ body: expect.any(String) }) + expectedTraceURL, + expect.objectContaining({ + body: expect.any(endpointType === "batch" ? String : FormData), + }) ); }); } From 6552a3e55db3bd2e4c06a426e9bb5c7f64e41bf6 Mon Sep 17 00:00:00 2001 From: jacoblee93 Date: Sat, 19 Oct 2024 23:34:50 -0700 Subject: [PATCH 04/17] Adds support for run attachments --- js/src/client.ts | 43 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/js/src/client.ts b/js/src/client.ts index b1d36f9a2..7c1401565 100644 --- a/js/src/client.ts +++ b/js/src/client.ts @@ -1013,14 +1013,22 @@ export class Client { return; } // transform and convert to dicts - let preparedCreateParams = - runCreates?.map((create) => - this.prepareRunCreateOrUpdateInputs(create) - ) ?? []; - let preparedUpdateParams = - runUpdates?.map((update) => - this.prepareRunCreateOrUpdateInputs(update) - ) ?? []; + const allAttachments: Record< + string, + Record + > = {}; + let preparedCreateParams = []; + for (const create of runCreates ?? []) { + preparedCreateParams.push(this.prepareRunCreateOrUpdateInputs(create)); + if (create.id !== undefined && create.attachments !== undefined) { + allAttachments[create.id] = create.attachments; + } + delete create.attachments; + } + let preparedUpdateParams = []; + for (const update of runUpdates ?? []) { + preparedUpdateParams.push(this.prepareRunCreateOrUpdateInputs(update)); + } // require trace_id and dotted_order const invalidRunCreate = preparedCreateParams.find((runCreate) => { @@ -1107,7 +1115,23 @@ export class Client { }), }); } - + // encode the attachments + if (payload.id !== undefined) { + const attachments = allAttachments[payload.id]; + if (attachments) { + delete allAttachments[payload.id]; + for (const [name, [contentType, content]] of Object.entries( + attachments + )) { + accumulatedParts.push({ + name: `attachment.${payload.id}.${name}`, + payload: new Blob([content], { + type: `${contentType}; length=${content.length}`, + }), + }); + } + } + } // compute context accumulatedContext.push(`trace=${payload.trace_id},id=${payload.id}`); } @@ -1139,6 +1163,7 @@ export class Client { ); } catch (e) { let errorMessage = "Failed to multipart ingest runs"; + // eslint-disable-next-line no-instanceof/no-instanceof if (e instanceof Error) { errorMessage += `: ${e.stack || e.message}`; } else { From bab72d30cfe580ea37877292804a0ef99ba7185d Mon Sep 17 00:00:00 2001 From: jacoblee93 Date: Sun, 20 Oct 2024 18:45:41 -0700 Subject: [PATCH 05/17] Remove old item count limit, use byte size limit --- js/src/client.ts | 139 +++++++++++++------------- js/src/tests/batch_client.int.test.ts | 2 +- js/src/tests/batch_client.test.ts | 43 ++++++-- js/src/utils/env.ts | 2 +- 4 files changed, 105 insertions(+), 81 deletions(-) diff --git a/js/src/client.ts b/js/src/client.ts index 7c1401565..7b59e3dd1 100644 --- a/js/src/client.ts +++ b/js/src/client.ts @@ -72,7 +72,7 @@ export interface ClientConfig { hideInputs?: boolean | ((inputs: KVMap) => KVMap); hideOutputs?: boolean | ((outputs: KVMap) => KVMap); autoBatchTracing?: boolean; - pendingAutoBatchedRunLimit?: number; + batchSizeBytesLimit?: number; blockOnRootRunFinalization?: boolean; fetchOptions?: RequestInit; } @@ -238,6 +238,7 @@ interface CreateRunParams { revision_id?: string; trace_id?: string; dotted_order?: string; + attachments?: Record; } interface UpdateRunParams extends RunUpdate { @@ -281,28 +282,26 @@ type MultipartPart = { payload: Blob; }; -async function mergeRuntimeEnvIntoRunCreates(runs: RunCreate[]) { - const runtimeEnv = await getRuntimeEnvironment(); +function mergeRuntimeEnvIntoRunCreate(run: RunCreate) { + const runtimeEnv = getRuntimeEnvironment(); const envVars = getLangChainEnvVarsMetadata(); - return runs.map((run) => { - const extra = run.extra ?? {}; - const metadata = extra.metadata; - run.extra = { - ...extra, - runtime: { - ...runtimeEnv, - ...extra?.runtime, - }, - metadata: { - ...envVars, - ...(envVars.revision_id || run.revision_id - ? { revision_id: run.revision_id ?? envVars.revision_id } - : {}), - ...metadata, - }, - }; - return run; - }); + const extra = run.extra ?? {}; + const metadata = extra.metadata; + run.extra = { + ...extra, + runtime: { + ...runtimeEnv, + ...extra?.runtime, + }, + metadata: { + ...envVars, + ...(envVars.revision_id || run.revision_id + ? { revision_id: run.revision_id ?? envVars.revision_id } + : {}), + ...metadata, + }, + }; + return run; } const getTracingSamplingRate = () => { @@ -362,9 +361,10 @@ const handle429 = async (response?: Response) => { return false; }; -export class Queue { +export class Queue { items: { - payload: T; + action: "create" | "update"; + payload: RunCreate | RunUpdate; itemPromiseResolve: () => void; itemPromise: Promise; size: number; @@ -376,16 +376,17 @@ export class Queue { return this.items[0]; } - push(item: T): Promise { + push(item: AutoBatchQueueItem): Promise { let itemPromiseResolve; const itemPromise = new Promise((resolve) => { // Setting itemPromiseResolve is synchronous with promise creation: // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/Promise itemPromiseResolve = resolve; }); - const size = stringifyForTracing(item).length; + const size = stringifyForTracing(item.item).length; this.items.push({ - payload: item, + action: item.action, + payload: item.item, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion itemPromiseResolve: itemPromiseResolve!, itemPromise, @@ -395,7 +396,7 @@ export class Queue { return itemPromise; } - pop(upToSizeBytes: number): [T[], () => void] { + pop(upToSizeBytes: number): [AutoBatchQueueItem[], () => void] { if (upToSizeBytes < 1) { throw new Error("Number of bytes to pop off may not be less than 1."); } @@ -422,7 +423,7 @@ export class Queue { this.sizeBytes -= item.size; } return [ - popped.map((it) => it.payload), + popped.map((it) => ({ action: it.action, item: it.payload })), () => popped.forEach((it) => it.itemPromiseResolve()), ]; } @@ -456,9 +457,7 @@ export class Client { private autoBatchTracing = true; - private autoBatchQueue = new Queue(); - - private pendingAutoBatchedRunLimit = 100; + private autoBatchQueue = new Queue(); private autoBatchTimeout: ReturnType | undefined; @@ -466,7 +465,7 @@ export class Client { private autoBatchAggregationDelayMs = 50; - private serverInfo: RecordStringAny | undefined; + private batchSizeBytesLimit?: number; private fetchOptions: RequestInit; @@ -474,6 +473,11 @@ export class Client { private blockOnRootRunFinalization = true; + private _serverInfo: RecordStringAny | undefined; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private getServerInfoPromise: Promise>; + constructor(config: ClientConfig = {}) { const defaultConfig = Client.getDefaultClientConfig(); @@ -502,8 +506,7 @@ export class Client { this.autoBatchTracing = config.autoBatchTracing ?? this.autoBatchTracing; this.blockOnRootRunFinalization = config.blockOnRootRunFinalization ?? this.blockOnRootRunFinalization; - this.pendingAutoBatchedRunLimit = - config.pendingAutoBatchedRunLimit ?? this.pendingAutoBatchedRunLimit; + this.batchSizeBytesLimit = config.batchSizeBytesLimit; this.fetchOptions = config.fetchOptions || {}; } @@ -629,6 +632,7 @@ export class Client { const response = await this._getResponse(path, queryParams); return response.json() as T; } + private async *_getPaginated( path: string, queryParams: URLSearchParams = new URLSearchParams(), @@ -738,15 +742,10 @@ export class Client { } private async _getBatchSizeLimitBytes() { - if (this.serverInfo === undefined) { - try { - this.serverInfo = await this._getServerInfo(); - } catch (e) { - this.serverInfo = {}; - } - } + const serverInfo = await this._ensureServerInfo(); return ( - this.serverInfo?.batch_ingest_config?.size_limit_bytes ?? + this.batchSizeBytesLimit ?? + serverInfo.batch_ingest_config?.size_limit_bytes ?? DEFAULT_BATCH_SIZE_LIMIT_BYTES ); } @@ -769,7 +768,8 @@ export class Client { .filter((item) => item.action === "update") .map((item) => item.item) as RunUpdate[], }; - if (this.serverInfo?.batch_ingest_config?.use_multipart_endpoint) { + const serverInfo = await this._ensureServerInfo(); + if (serverInfo?.batch_ingest_config?.use_multipart_endpoint) { await this.multipartIngestRuns(ingestParams); } else { await this.batchIngestRuns(ingestParams); @@ -787,10 +787,14 @@ export class Client { const oldTimeout = this.autoBatchTimeout; clearTimeout(this.autoBatchTimeout); this.autoBatchTimeout = undefined; + if (item.action === "create") { + item.item = mergeRuntimeEnvIntoRunCreate(item.item as RunCreate); + } const itemPromise = this.autoBatchQueue.push(item); + const sizeLimitBytes = await this._getBatchSizeLimitBytes(); if ( immediatelyTriggerBatch || - this.autoBatchQueue.items.length > this.pendingAutoBatchedRunLimit + this.autoBatchQueue.sizeBytes > sizeLimitBytes ) { await this.drainAutoBatchQueue().catch(console.error); } @@ -821,6 +825,22 @@ export class Client { return response.json(); } + protected async _ensureServerInfo() { + if (this.getServerInfoPromise === undefined) { + this.getServerInfoPromise = (async () => { + if (this._serverInfo === undefined) { + try { + this._serverInfo = await this._getServerInfo(); + } catch (e) { + this._serverInfo = {}; + } + } + return this._serverInfo ?? {}; + })(); + } + return this.getServerInfoPromise; + } + protected async _getSettings() { if (!this.settings) { this.settings = this._get("/settings"); @@ -853,9 +873,7 @@ export class Client { }).catch(console.error); return; } - const mergedRunCreateParams = await mergeRuntimeEnvIntoRunCreates([ - runCreate, - ]); + const mergedRunCreateParam = mergeRuntimeEnvIntoRunCreate(runCreate); const response = await this.caller.call( _getFetchImplementation(), @@ -863,7 +881,7 @@ export class Client { { method: "POST", headers, - body: stringifyForTracing(mergedRunCreateParams[0]), + body: stringifyForTracing(mergedRunCreateParam), signal: AbortSignal.timeout(this.timeout_ms), ...this.fetchOptions, } @@ -926,13 +944,8 @@ export class Client { if (!rawBatch.post.length && !rawBatch.patch.length) { return; } - preparedCreateParams = await mergeRuntimeEnvIntoRunCreates( - preparedCreateParams - ); - if (this.serverInfo === undefined) { - this.serverInfo = await this._getServerInfo(); - } - if (this.serverInfo?.version === undefined) { + const serverInfo = await this._ensureServerInfo(); + if (serverInfo.version === undefined) { this.autoBatchTracing = false; for (const preparedCreateParam of rawBatch.post) { await this.createRun(preparedCreateParam as CreateRunParams); @@ -947,28 +960,15 @@ export class Client { } return; } - const sizeLimitBytes = await this._getBatchSizeLimitBytes(); const batchChunks = { post: [] as (typeof rawBatch)["post"], patch: [] as (typeof rawBatch)["patch"], }; - let currentBatchSizeBytes = 0; for (const k of ["post", "patch"]) { const key = k as keyof typeof rawBatch; const batchItems = rawBatch[key].reverse(); let batchItem = batchItems.pop(); while (batchItem !== undefined) { - const stringifiedBatchItem = stringifyForTracing(batchItem); - if ( - currentBatchSizeBytes > 0 && - currentBatchSizeBytes + stringifiedBatchItem.length > sizeLimitBytes - ) { - await this._postBatchIngestRuns(stringifyForTracing(batchChunks)); - currentBatchSizeBytes = 0; - batchChunks.post = []; - batchChunks.patch = []; - } - currentBatchSizeBytes += stringifiedBatchItem.length; batchChunks[key].push(batchItem); batchItem = batchItems.pop(); } @@ -1025,6 +1025,7 @@ export class Client { } delete create.attachments; } + let preparedUpdateParams = []; for (const update of runUpdates ?? []) { preparedUpdateParams.push(this.prepareRunCreateOrUpdateInputs(update)); diff --git a/js/src/tests/batch_client.int.test.ts b/js/src/tests/batch_client.int.test.ts index 4705fae3c..b90bb9fec 100644 --- a/js/src/tests/batch_client.int.test.ts +++ b/js/src/tests/batch_client.int.test.ts @@ -58,7 +58,7 @@ test.concurrent( const langchainClient = new Client({ autoBatchTracing: true, callerOptions: { maxRetries: 2 }, - pendingAutoBatchedRunLimit: 2, + batchSizeBytesLimit: 1, timeout_ms: 30_000, }); const projectName = diff --git a/js/src/tests/batch_client.test.ts b/js/src/tests/batch_client.test.ts index 64a63512f..716354d1d 100644 --- a/js/src/tests/batch_client.test.ts +++ b/js/src/tests/batch_client.test.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable prefer-const */ import { jest } from "@jest/globals"; import { v4 as uuidv4 } from "uuid"; import { Client } from "../client.js"; @@ -16,8 +17,26 @@ const parseMockRequestBody = async (body: string | FormData) => { patch: [], }; for (const [key, value] of entries) { - const [method, id, type] = key.split("."); - const parsedValue = JSON.parse(await value.text()); + let [method, id, type] = key.split("."); + const text = await value.text(); + let parsedValue; + try { + parsedValue = JSON.parse(text); + } catch (e) { + parsedValue = text; + } + // if (method === "attachment") { + // for (const item of reconstructedBody.post) { + // if (item.id === id) { + // if (item.attachments === undefined) { + // item.attachments = []; + // } + + // item[type] = parsedValue; + // } + // } + // return; + // } if (!(method in reconstructedBody)) { throw new Error(`${method} must be "post" or "patch"`); } @@ -463,7 +482,7 @@ describe.each(ENDPOINT_TYPES)( it("should send traces above the batch size and see even batches", async () => { const client = new Client({ apiKey: "test-api-key", - pendingAutoBatchedRunLimit: 10, + batchSizeBytesLimit: 10000, autoBatchTracing: true, }); const callSpy = jest @@ -487,7 +506,7 @@ describe.each(ENDPOINT_TYPES)( new Date().getTime() / 1000, runId ); - await client.createRun({ + const params = { id: runId, project_name: projectName, name: "test_run " + i, @@ -495,7 +514,12 @@ describe.each(ENDPOINT_TYPES)( inputs: { text: "hello world " + i }, trace_id: runId, dotted_order: dottedOrder, - }); + }; + // Allow some extra space for other request properties + const mockRunSize = 850; + const padCount = mockRunSize - JSON.stringify(params).length; + params.inputs.text = params.inputs.text + "x".repeat(padCount); + await client.createRun(params); return runId; }) ); @@ -513,7 +537,7 @@ describe.each(ENDPOINT_TYPES)( id: runId, run_type: "llm", inputs: { - text: "hello world " + i, + text: expect.stringContaining("hello world " + i), }, trace_id: runId, }) @@ -527,7 +551,7 @@ describe.each(ENDPOINT_TYPES)( id: runId, run_type: "llm", inputs: { - text: "hello world " + (i + 10), + text: expect.stringContaining("hello world " + (i + 10)), }, trace_id: runId, }) @@ -536,10 +560,10 @@ describe.each(ENDPOINT_TYPES)( }); }); - it("should send traces above the batch size limit in bytes and see even batches", async () => { + it("a very low batch size limit should be equivalent to single calls", async () => { const client = new Client({ apiKey: "test-api-key", - pendingAutoBatchedRunLimit: 10, + batchSizeBytesLimit: 1, autoBatchTracing: true, }); const callSpy = jest @@ -553,7 +577,6 @@ describe.each(ENDPOINT_TYPES)( version: "foo", batch_ingest_config: { ...extraBatchIngestConfig, - size_limit_bytes: 1, }, }; }); diff --git a/js/src/utils/env.ts b/js/src/utils/env.ts index 535ef2772..e02eae3a8 100644 --- a/js/src/utils/env.ts +++ b/js/src/utils/env.ts @@ -69,7 +69,7 @@ export type RuntimeEnvironment = { let runtimeEnvironment: RuntimeEnvironment | undefined; -export async function getRuntimeEnvironment(): Promise { +export function getRuntimeEnvironment(): RuntimeEnvironment { if (runtimeEnvironment === undefined) { const env = getEnv(); const releaseEnv = getShas(); From 39c8f74b4255776ed9205408fe450fa30c07080a Mon Sep 17 00:00:00 2001 From: jacoblee93 Date: Sun, 20 Oct 2024 20:15:48 -0700 Subject: [PATCH 06/17] Fix test --- js/src/client.ts | 2 +- js/src/tests/batch_client.test.ts | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/js/src/client.ts b/js/src/client.ts index 7b59e3dd1..e9af5350d 100644 --- a/js/src/client.ts +++ b/js/src/client.ts @@ -282,7 +282,7 @@ type MultipartPart = { payload: Blob; }; -function mergeRuntimeEnvIntoRunCreate(run: RunCreate) { +export function mergeRuntimeEnvIntoRunCreate(run: RunCreate) { const runtimeEnv = getRuntimeEnvironment(); const envVars = getLangChainEnvVarsMetadata(); const extra = run.extra ?? {}; diff --git a/js/src/tests/batch_client.test.ts b/js/src/tests/batch_client.test.ts index 716354d1d..d16711ffb 100644 --- a/js/src/tests/batch_client.test.ts +++ b/js/src/tests/batch_client.test.ts @@ -2,9 +2,10 @@ /* eslint-disable prefer-const */ import { jest } from "@jest/globals"; import { v4 as uuidv4 } from "uuid"; -import { Client } from "../client.js"; +import { Client, mergeRuntimeEnvIntoRunCreate } from "../client.js"; import { convertToDottedOrderFormat } from "../run_trees.js"; import { _getFetchImplementation } from "../singletons/fetch.js"; +import { RunCreate } from "../schemas.js"; const parseMockRequestBody = async (body: string | FormData) => { if (typeof body === "string") { @@ -506,7 +507,7 @@ describe.each(ENDPOINT_TYPES)( new Date().getTime() / 1000, runId ); - const params = { + const params = mergeRuntimeEnvIntoRunCreate({ id: runId, project_name: projectName, name: "test_run " + i, @@ -514,9 +515,9 @@ describe.each(ENDPOINT_TYPES)( inputs: { text: "hello world " + i }, trace_id: runId, dotted_order: dottedOrder, - }; + } as RunCreate); // Allow some extra space for other request properties - const mockRunSize = 850; + const mockRunSize = 950; const padCount = mockRunSize - JSON.stringify(params).length; params.inputs.text = params.inputs.text + "x".repeat(padCount); await client.createRun(params); From 2457d6a546f071e15f71df10339bc4a60398d958 Mon Sep 17 00:00:00 2001 From: jacoblee93 Date: Mon, 21 Oct 2024 10:12:04 -0700 Subject: [PATCH 07/17] Adds test for attachments --- js/src/client.ts | 12 +++-- js/src/tests/batch_client.int.test.ts | 58 ++++++++++++++++++++++++- js/src/tests/test_data/parrot-icon.png | Bin 0 -> 35267 bytes 3 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 js/src/tests/test_data/parrot-icon.png diff --git a/js/src/client.ts b/js/src/client.ts index e9af5350d..ab86382f4 100644 --- a/js/src/client.ts +++ b/js/src/client.ts @@ -1019,11 +1019,15 @@ export class Client { > = {}; let preparedCreateParams = []; for (const create of runCreates ?? []) { - preparedCreateParams.push(this.prepareRunCreateOrUpdateInputs(create)); - if (create.id !== undefined && create.attachments !== undefined) { - allAttachments[create.id] = create.attachments; + const preparedCreate = this.prepareRunCreateOrUpdateInputs(create); + if ( + preparedCreate.id !== undefined && + preparedCreate.attachments !== undefined + ) { + allAttachments[preparedCreate.id] = preparedCreate.attachments; } - delete create.attachments; + delete preparedCreate.attachments; + preparedCreateParams.push(preparedCreate); } let preparedUpdateParams = []; diff --git a/js/src/tests/batch_client.int.test.ts b/js/src/tests/batch_client.int.test.ts index b90bb9fec..a91cfc6b0 100644 --- a/js/src/tests/batch_client.int.test.ts +++ b/js/src/tests/batch_client.int.test.ts @@ -1,6 +1,10 @@ +import { v4 as uuidv4 } from "uuid"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + import { Client } from "../client.js"; import { RunTree, convertToDottedOrderFormat } from "../run_trees.js"; -import { v4 as uuidv4 } from "uuid"; import { deleteProject, waitUntilProjectFound, @@ -185,3 +189,55 @@ test.concurrent( }, 180_000 ); + +test.concurrent( + "Test persist run with attachment", + async () => { + const langchainClient = new Client({ + autoBatchTracing: true, + callerOptions: { maxRetries: 2 }, + timeout_ms: 30_000, + }); + const projectName = "__test_create_attachment" + uuidv4().substring(0, 4); + await deleteProject(langchainClient, projectName); + + const runId = uuidv4(); + const dottedOrder = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId + ); + const pathname = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "test_data", + "parrot-icon.png" + ); + await langchainClient.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + attachments: { + testimage: ["image/png", fs.readFileSync(pathname)], + }, + }); + + await langchainClient.updateRun(runId, { + outputs: { output: ["Hi"] }, + dotted_order: dottedOrder, + trace_id: runId, + }); + + await Promise.all([ + waitUntilRunFound(langchainClient, runId, true), + waitUntilProjectFound(langchainClient, projectName), + ]); + + const storedRun = await langchainClient.readRun(runId); + expect(storedRun.id).toEqual(runId); + await langchainClient.deleteProject({ projectName }); + }, + 180_000 +); diff --git a/js/src/tests/test_data/parrot-icon.png b/js/src/tests/test_data/parrot-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..7fd3de1dc7018c6c53165b7272411a7c6f771852 GIT binary patch literal 35267 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 z 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 Date: Mon, 21 Oct 2024 11:49:54 -0700 Subject: [PATCH 08/17] Add retry for server info fetch failures --- js/src/client.ts | 17 +++++-- js/src/run_trees.ts | 2 +- js/src/tests/batch_client.test.ts | 81 +++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 6 deletions(-) diff --git a/js/src/client.ts b/js/src/client.ts index ab86382f4..d0af78424 100644 --- a/js/src/client.ts +++ b/js/src/client.ts @@ -476,7 +476,7 @@ export class Client { private _serverInfo: RecordStringAny | undefined; // eslint-disable-next-line @typescript-eslint/no-explicit-any - private getServerInfoPromise: Promise>; + private _getServerInfoPromise?: Promise>; constructor(config: ClientConfig = {}) { const defaultConfig = Client.getDefaultClientConfig(); @@ -826,19 +826,26 @@ export class Client { } protected async _ensureServerInfo() { - if (this.getServerInfoPromise === undefined) { - this.getServerInfoPromise = (async () => { + if (this._getServerInfoPromise === undefined) { + this._getServerInfoPromise = (async () => { if (this._serverInfo === undefined) { try { this._serverInfo = await this._getServerInfo(); } catch (e) { - this._serverInfo = {}; + console.warn( + `[WARNING]: LangSmith failed to fetch info on supported operations. Falling back to single calls and default limits.` + ); } } return this._serverInfo ?? {}; })(); } - return this.getServerInfoPromise; + return this._getServerInfoPromise.then((serverInfo) => { + if (this._serverInfo === undefined) { + this._getServerInfoPromise = undefined; + } + return serverInfo; + }); } protected async _getSettings() { diff --git a/js/src/run_trees.ts b/js/src/run_trees.ts index c8e8091ab..97a33a19c 100644 --- a/js/src/run_trees.ts +++ b/js/src/run_trees.ts @@ -376,7 +376,7 @@ export class RunTree implements BaseRun { async postRun(excludeChildRuns = true): Promise { try { - const runtimeEnv = await getRuntimeEnvironment(); + const runtimeEnv = getRuntimeEnvironment(); const runCreate = await this._convertToCreate(this, runtimeEnv, true); await this.client.createRun(runCreate); diff --git a/js/src/tests/batch_client.test.ts b/js/src/tests/batch_client.test.ts index d16711ffb..1fea3f778 100644 --- a/js/src/tests/batch_client.test.ts +++ b/js/src/tests/batch_client.test.ts @@ -250,6 +250,87 @@ describe.each(ENDPOINT_TYPES)( ); }); + it("server info fetch should retry even if initial call fails", async () => { + const client = new Client({ + apiKey: "test-api-key", + autoBatchTracing: true, + }); + const callSpy = jest + .spyOn((client as any).batchIngestCaller, "call") + .mockResolvedValue({ + ok: true, + text: () => "", + }); + let serverInfoFailedOnce = false; + jest.spyOn(client as any, "_getServerInfo").mockImplementationOnce(() => { + serverInfoFailedOnce = true; + throw new Error("[MOCK] Connection error."); + }); + jest.spyOn(client as any, "_getServerInfo").mockImplementation(() => { + return { + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + }; + }); + const projectName = "__test_batch"; + + const runId = uuidv4(); + const dottedOrder = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); + + const endTime = Math.floor(new Date().getTime() / 1000); + + await client.updateRun(runId, { + outputs: { output: ["Hi"] }, + dotted_order: dottedOrder, + trace_id: runId, + end_time: endTime, + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(serverInfoFailedOnce).toBe(true); + + const calledRequestParam: any = callSpy.mock.calls[0][2]; + expect(await parseMockRequestBody(calledRequestParam?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + text: "hello world", + }, + outputs: { + output: ["Hi"], + }, + end_time: endTime, + trace_id: runId, + dotted_order: dottedOrder, + }), + ], + patch: [], + }); + + expect(callSpy).toHaveBeenCalledWith( + _getFetchImplementation(), + expectedTraceURL, + expect.objectContaining({ + body: expect.any(endpointType === "batch" ? String : FormData), + }) + ); + }); + it("should immediately trigger a batch on root run end", async () => { const client = new Client({ apiKey: "test-api-key", From f3ee937f918da5e579cb22163779901586a17c86 Mon Sep 17 00:00:00 2001 From: jacoblee93 Date: Mon, 21 Oct 2024 11:52:38 -0700 Subject: [PATCH 09/17] Reduce server info fetch timeout --- js/src/client.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/js/src/client.ts b/js/src/client.ts index d0af78424..9cd7dde17 100644 --- a/js/src/client.ts +++ b/js/src/client.ts @@ -432,6 +432,8 @@ export class Queue { // 20 MB export const DEFAULT_BATCH_SIZE_LIMIT_BYTES = 20_971_520; +const SERVER_INFO_REQUEST_TIMEOUT = 1000; + export class Client { private apiKey?: string; @@ -818,7 +820,7 @@ export class Client { const response = await _getFetchImplementation()(`${this.apiUrl}/info`, { method: "GET", headers: { Accept: "application/json" }, - signal: AbortSignal.timeout(this.timeout_ms), + signal: AbortSignal.timeout(SERVER_INFO_REQUEST_TIMEOUT), ...this.fetchOptions, }); await raiseForStatus(response, "get server info"); From 61ec70e4eecd0e97240d09743bcf92de519b3d58 Mon Sep 17 00:00:00 2001 From: jacoblee93 Date: Tue, 22 Oct 2024 14:30:44 -0700 Subject: [PATCH 10/17] Trigger gzip compression for larger payloads --- js/package.json | 8 ++-- js/src/client.ts | 52 ++++++++++++++++++----- js/src/tests/batch_client.test.ts | 16 ++++++- js/yarn.lock | 69 ++++++++++++++++++++----------- 4 files changed, 104 insertions(+), 41 deletions(-) diff --git a/js/package.json b/js/package.json index fde21f5b0..001a51a4b 100644 --- a/js/package.json +++ b/js/package.json @@ -109,9 +109,9 @@ "@babel/preset-env": "^7.22.4", "@faker-js/faker": "^8.4.1", "@jest/globals": "^29.5.0", - "@langchain/core": "^0.3.1", - "@langchain/langgraph": "^0.2.3", - "@langchain/openai": "^0.3.0", + "@langchain/core": "^0.3.13", + "@langchain/langgraph": "^0.2.16", + "@langchain/openai": "^0.3.11", "@tsconfig/recommended": "^1.0.2", "@types/jest": "^29.5.1", "@typescript-eslint/eslint-plugin": "^5.59.8", @@ -126,7 +126,7 @@ "eslint-plugin-no-instanceof": "^1.0.1", "eslint-plugin-prettier": "^4.2.1", "jest": "^29.5.0", - "langchain": "^0.3.2", + "langchain": "^0.3.3", "openai": "^4.67.3", "prettier": "^2.8.8", "ts-jest": "^29.1.0", diff --git a/js/src/client.ts b/js/src/client.ts index 9cd7dde17..6953aebea 100644 --- a/js/src/client.ts +++ b/js/src/client.ts @@ -361,6 +361,42 @@ const handle429 = async (response?: Response) => { return false; }; +const _compressPayload = async ( + payload: string | Uint8Array, + contentType = "application/json" +) => { + const compressedPayloadStream = new Blob([payload]) + .stream() + .pipeThrough(new CompressionStream("gzip")); + const reader = compressedPayloadStream.getReader(); + const chunks = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + return new Blob(chunks, { + type: `${contentType}; length=${payload.length}; encoding=gzip`, + }); +}; + +const _preparePayload = async ( + payload: any, + contentType = "application/json" +) => { + let finalPayload = payload; + // eslint-disable-next-line no-instanceof/no-instanceof + if (!(payload instanceof Uint8Array)) { + finalPayload = stringifyForTracing(payload); + } + if (finalPayload.length < MAX_UNCOMPRESSED_PAYLOAD_LIMIT) { + return new Blob([finalPayload], { + type: `${contentType}; length=${finalPayload.length}`, + }); + } + return _compressPayload(payload); +}; + export class Queue { items: { action: "create" | "update"; @@ -432,6 +468,8 @@ export class Queue { // 20 MB export const DEFAULT_BATCH_SIZE_LIMIT_BYTES = 20_971_520; +const MAX_UNCOMPRESSED_PAYLOAD_LIMIT = 10 * 1024; + const SERVER_INFO_REQUEST_TIMEOUT = 1000; export class Client { @@ -1109,24 +1147,18 @@ export class Client { const { inputs, outputs, events, ...payload } = originalPayload; const fields = { inputs, outputs, events }; // encode the main run payload - const stringifiedPayload = stringifyForTracing(payload); accumulatedParts.push({ name: `${method}.${payload.id}`, - payload: new Blob([stringifiedPayload], { - type: `application/json; length=${stringifiedPayload.length}`, // encoding=gzip - }), + payload: await _preparePayload(payload), }); // encode the fields we collected for (const [key, value] of Object.entries(fields)) { if (value === undefined) { continue; } - const stringifiedValue = stringifyForTracing(value); accumulatedParts.push({ name: `${method}.${payload.id}.${key}`, - payload: new Blob([stringifiedValue], { - type: `application/json; length=${stringifiedValue.length}`, - }), + payload: await _preparePayload(value), }); } // encode the attachments @@ -1139,9 +1171,7 @@ export class Client { )) { accumulatedParts.push({ name: `attachment.${payload.id}.${name}`, - payload: new Blob([content], { - type: `${contentType}; length=${content.length}`, - }), + payload: await _preparePayload(content, contentType), }); } } diff --git a/js/src/tests/batch_client.test.ts b/js/src/tests/batch_client.test.ts index 1fea3f778..d178e19f9 100644 --- a/js/src/tests/batch_client.test.ts +++ b/js/src/tests/batch_client.test.ts @@ -608,12 +608,24 @@ describe.each(ENDPOINT_TYPES)( await new Promise((resolve) => setTimeout(resolve, 10)); + expect(callSpy.mock.calls.length).toEqual(2); + const calledRequestParam: any = callSpy.mock.calls[0][2]; const calledRequestParam2: any = callSpy.mock.calls[1][2]; + const requestBody1 = await parseMockRequestBody(calledRequestParam?.body); + const requestBody2 = await parseMockRequestBody( + calledRequestParam2?.body + ); + + const largerBatch = + requestBody1.post.length === 10 ? requestBody1 : requestBody2; + const smallerBatch = + requestBody1.post.length === 10 ? requestBody2 : requestBody1; + // Queue should drain as soon as size limit is reached, // sending both batches - expect(await parseMockRequestBody(calledRequestParam?.body)).toEqual({ + expect(largerBatch).toEqual({ post: runIds.slice(0, 10).map((runId, i) => expect.objectContaining({ id: runId, @@ -627,7 +639,7 @@ describe.each(ENDPOINT_TYPES)( patch: [], }); - expect(await parseMockRequestBody(calledRequestParam2?.body)).toEqual({ + expect(smallerBatch).toEqual({ post: runIds.slice(10).map((runId, i) => expect.objectContaining({ id: runId, diff --git a/js/yarn.lock b/js/yarn.lock index 2a3feae33..f8d087c1e 100644 --- a/js/yarn.lock +++ b/js/yarn.lock @@ -1368,16 +1368,16 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" -"@langchain/core@^0.3.1": - version "0.3.1" - resolved "https://registry.yarnpkg.com/@langchain/core/-/core-0.3.1.tgz#f06206809575b2a95eaef609b3273842223c0786" - integrity sha512-xYdTAgS9hYPt+h0/OwpyRcMB5HKR40LXutbSr2jw3hMVIOwD1DnvhnUEnWgBK4lumulVW2jrosNPyBKMhRZAZg== +"@langchain/core@^0.3.13": + version "0.3.13" + resolved "https://registry.yarnpkg.com/@langchain/core/-/core-0.3.13.tgz#0434455272f4a6f12d011ec8788306ed595e8820" + integrity sha512-sHDlwyHhgeaYC+wfORrWO7sXxD6/GDtZZ5mqjY48YMwB58cVv8hTs8goR/9EwXapYt8fQi2uXTGUV87bHzvdZQ== dependencies: ansi-styles "^5.0.0" camelcase "6" decamelize "1.2.0" js-tiktoken "^1.0.12" - langsmith "^0.1.56-rc.1" + langsmith "^0.1.65" mustache "^4.2.0" p-queue "^6.6.2" p-retry "4" @@ -1385,24 +1385,24 @@ zod "^3.22.4" zod-to-json-schema "^3.22.3" -"@langchain/langgraph-checkpoint@~0.0.6": - version "0.0.6" - resolved "https://registry.yarnpkg.com/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-0.0.6.tgz#69f0c5c9aeefd48dcf0fa1ffa0744d8139a9f27d" - integrity sha512-hQsznlUMFKyOCaN9VtqNSSemfKATujNy5ePM6NX7lruk/Mmi2t7R9SsBnf9G2Yts+IaIwv3vJJaAFYEHfqbc5g== +"@langchain/langgraph-checkpoint@~0.0.10": + version "0.0.11" + resolved "https://registry.yarnpkg.com/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-0.0.11.tgz#65c40bc175faca98ed0901df9e76682585710e8d" + integrity sha512-nroHHkAi/UPn9LqqZcgOydfB8qZw5TXuXDFc43MIydnW4lb8m9hVHnQ3lgb2WGSgtbZJnsIx0TzL19oemJBRKg== dependencies: uuid "^10.0.0" -"@langchain/langgraph@^0.2.3": - version "0.2.3" - resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-0.2.3.tgz#34072f68536706a42c7fb978f1ab5373c058e2f5" - integrity sha512-agBa79dgKk08B3gNE9+SSLYLmlhBwMaCPsME5BlIFJjs2j2lDnSgKtUfQ9nE4e3Q51L9AA4DjIxmxJiQtS3GOw== +"@langchain/langgraph@^0.2.16": + version "0.2.16" + resolved "https://registry.yarnpkg.com/@langchain/langgraph/-/langgraph-0.2.16.tgz#e6aa597e8a7c6a1c865be563a2c6208a4a569da7" + integrity sha512-7QipO2o+F1d5ccpai+Yip77cmS+Etvj6Zee47E+Ll2TkQQBP1acl0UnAbYoN11xIQoJwhezySOIx8jkqwLEKfw== dependencies: - "@langchain/langgraph-checkpoint" "~0.0.6" + "@langchain/langgraph-checkpoint" "~0.0.10" double-ended-queue "^2.1.0-0" uuid "^10.0.0" zod "^3.23.8" -"@langchain/openai@>=0.1.0 <0.4.0", "@langchain/openai@^0.3.0": +"@langchain/openai@>=0.1.0 <0.4.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@langchain/openai/-/openai-0.3.0.tgz#89329ab9350187269a471dac2c2f4fca5f1fc5a3" integrity sha512-yXrz5Qn3t9nq3NQAH2l4zZOI4ev2CFdLC5kvmi5SdW4bggRuM40SXTUAY3VRld4I5eocYfk82VbrlA+6dvN5EA== @@ -1412,6 +1412,16 @@ zod "^3.22.4" zod-to-json-schema "^3.22.3" +"@langchain/openai@^0.3.11": + version "0.3.11" + resolved "https://registry.yarnpkg.com/@langchain/openai/-/openai-0.3.11.tgz#c93ee298a87318562a1da6c2915a180fe5155ac4" + integrity sha512-mEFbpJ8w8NPArsquUlCwxvZTKNkXxqwzvTEYzv6Jb7gUoBDOZtwLg6AdcngTJ+w5VFh3wxgPy0g3zb9Aw0Qbpw== + dependencies: + js-tiktoken "^1.0.12" + openai "^4.68.0" + zod "^3.22.4" + zod-to-json-schema "^3.22.3" + "@langchain/textsplitters@>=0.0.0 <0.2.0": version "0.1.0" resolved "https://registry.yarnpkg.com/@langchain/textsplitters/-/textsplitters-0.1.0.tgz#f37620992192df09ecda3dfbd545b36a6bcbae46" @@ -3683,17 +3693,17 @@ kleur@^3.0.3: resolved "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== -langchain@^0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/langchain/-/langchain-0.3.2.tgz#aec3e679d3d6c36f469448380affa475c92fbd86" - integrity sha512-kd2kz1cS/PIVrLEDFlrZsAasQfPLbY1UqCZbRKa3/QcpB33/n6xPDvXSMfBuKhvNj0bjW6MXDR9HZTduXjJBgg== +langchain@^0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/langchain/-/langchain-0.3.3.tgz#4e1157d00ea9973486ba996d106670afd405793f" + integrity sha512-xy63PAh1PUuF2VdjLxacP8SeUQKF++ixvAhMhl/+3GkzloEKce41xlbQC3xNGVToYaqzIsDrueps/JU0zYYXHw== dependencies: "@langchain/openai" ">=0.1.0 <0.4.0" "@langchain/textsplitters" ">=0.0.0 <0.2.0" js-tiktoken "^1.0.12" js-yaml "^4.1.0" jsonpointer "^5.0.1" - langsmith "^0.1.56-rc.1" + langsmith "^0.1.56" openapi-types "^12.1.3" p-retry "4" uuid "^10.0.0" @@ -3701,10 +3711,8 @@ langchain@^0.3.2: zod "^3.22.4" zod-to-json-schema "^3.22.3" -langsmith@^0.1.56-rc.1: - version "0.1.56-rc.1" - resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.1.56-rc.1.tgz#20900ff0dee51baea359c6f16a4acc260f07fbb7" - integrity sha512-XsOxlhBAlTCGR9hNEL2VSREmiz8v6czNuX3CIwec9fH9T0WbNPle8Q/7Jy/h9UCbS9vuzTjfgc4qO5Dc9cu5Ig== +langsmith@^0.1.56, langsmith@^0.1.65, "langsmith@file:./": + version "0.1.67-rc.3" dependencies: "@types/uuid" "^10.0.0" commander "^10.0.1" @@ -3980,6 +3988,19 @@ openai@^4.67.3: formdata-node "^4.3.2" node-fetch "^2.6.7" +openai@^4.68.0: + version "4.68.1" + resolved "https://registry.yarnpkg.com/openai/-/openai-4.68.1.tgz#a2bd0df5d6c1c0f53b296b690acce88b44c56624" + integrity sha512-C9XmYRHgra1U1G4GGFNqRHQEjxhoOWbQYR85IibfJ0jpHUhOm4/lARiKaC/h3zThvikwH9Dx/XOKWPNVygIS3g== + dependencies: + "@types/node" "^18.11.18" + "@types/node-fetch" "^2.6.4" + abort-controller "^3.0.0" + agentkeepalive "^4.2.1" + form-data-encoder "1.7.2" + formdata-node "^4.3.2" + node-fetch "^2.6.7" + openapi-types@^12.1.3: version "12.1.3" resolved "https://registry.yarnpkg.com/openapi-types/-/openapi-types-12.1.3.tgz#471995eb26c4b97b7bd356aacf7b91b73e777dd3" From 0e05a5d96c31bda2aa389309e83bab1f10e89815 Mon Sep 17 00:00:00 2001 From: jacoblee93 Date: Tue, 22 Oct 2024 15:50:33 -0700 Subject: [PATCH 11/17] Compress payloads above a limit --- js/src/client.ts | 55 ++++++++++++++++++--------- js/src/tests/batch_client.int.test.ts | 51 ++++++++++++++++++++++++- js/yarn.lock | 6 ++- 3 files changed, 90 insertions(+), 22 deletions(-) diff --git a/js/src/client.ts b/js/src/client.ts index 6953aebea..1af78fb3b 100644 --- a/js/src/client.ts +++ b/js/src/client.ts @@ -73,6 +73,7 @@ export interface ClientConfig { hideOutputs?: boolean | ((outputs: KVMap) => KVMap); autoBatchTracing?: boolean; batchSizeBytesLimit?: number; + tracePayloadByteCompressionLimit?: number; blockOnRootRunFinalization?: boolean; fetchOptions?: RequestInit; } @@ -363,38 +364,41 @@ const handle429 = async (response?: Response) => { const _compressPayload = async ( payload: string | Uint8Array, - contentType = "application/json" + contentType: string ) => { const compressedPayloadStream = new Blob([payload]) .stream() .pipeThrough(new CompressionStream("gzip")); const reader = compressedPayloadStream.getReader(); const chunks = []; + let totalLength = 0; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); + totalLength += value.length; } return new Blob(chunks, { - type: `${contentType}; length=${payload.length}; encoding=gzip`, + type: `${contentType}; length=${totalLength}; encoding=gzip`, }); }; const _preparePayload = async ( payload: any, - contentType = "application/json" + contentType: string, + compressionThreshold: number ) => { let finalPayload = payload; // eslint-disable-next-line no-instanceof/no-instanceof if (!(payload instanceof Uint8Array)) { finalPayload = stringifyForTracing(payload); } - if (finalPayload.length < MAX_UNCOMPRESSED_PAYLOAD_LIMIT) { + if (finalPayload.length < compressionThreshold) { return new Blob([finalPayload], { type: `${contentType}; length=${finalPayload.length}`, }); } - return _compressPayload(payload); + return _compressPayload(finalPayload, contentType); }; export class Queue { @@ -468,7 +472,7 @@ export class Queue { // 20 MB export const DEFAULT_BATCH_SIZE_LIMIT_BYTES = 20_971_520; -const MAX_UNCOMPRESSED_PAYLOAD_LIMIT = 10 * 1024; +const DEFAULT_MAX_UNCOMPRESSED_PAYLOAD_LIMIT = 10 * 1024; const SERVER_INFO_REQUEST_TIMEOUT = 1000; @@ -505,6 +509,9 @@ export class Client { private autoBatchAggregationDelayMs = 50; + private tracePayloadByteCompressionLimit = + DEFAULT_MAX_UNCOMPRESSED_PAYLOAD_LIMIT; + private batchSizeBytesLimit?: number; private fetchOptions: RequestInit; @@ -547,6 +554,9 @@ export class Client { this.blockOnRootRunFinalization = config.blockOnRootRunFinalization ?? this.blockOnRootRunFinalization; this.batchSizeBytesLimit = config.batchSizeBytesLimit; + this.tracePayloadByteCompressionLimit = + config.tracePayloadByteCompressionLimit ?? + this.tracePayloadByteCompressionLimit; this.fetchOptions = config.fetchOptions || {}; } @@ -1149,7 +1159,11 @@ export class Client { // encode the main run payload accumulatedParts.push({ name: `${method}.${payload.id}`, - payload: await _preparePayload(payload), + payload: await _preparePayload( + payload, + "application/json", + this.tracePayloadByteCompressionLimit + ), }); // encode the fields we collected for (const [key, value] of Object.entries(fields)) { @@ -1158,7 +1172,11 @@ export class Client { } accumulatedParts.push({ name: `${method}.${payload.id}.${key}`, - payload: await _preparePayload(value), + payload: await _preparePayload( + value, + "application/json", + this.tracePayloadByteCompressionLimit + ), }); } // encode the attachments @@ -1171,7 +1189,11 @@ export class Client { )) { accumulatedParts.push({ name: `attachment.${payload.id}.${name}`, - payload: await _preparePayload(content, contentType), + payload: await _preparePayload( + content, + contentType, + this.tracePayloadByteCompressionLimit + ), }); } } @@ -1192,7 +1214,7 @@ export class Client { for (const part of parts) { formData.append(part.name, part.payload); } - await this.batchIngestCaller.call( + const res = await this.batchIngestCaller.call( _getFetchImplementation(), `${this.apiUrl}/runs/multipart`, { @@ -1205,15 +1227,10 @@ export class Client { ...this.fetchOptions, } ); - } catch (e) { - let errorMessage = "Failed to multipart ingest runs"; - // eslint-disable-next-line no-instanceof/no-instanceof - if (e instanceof Error) { - errorMessage += `: ${e.stack || e.message}`; - } else { - errorMessage += `: ${String(e)}`; - } - console.warn(`${errorMessage.trim()}\n\nContext: ${context}`); + await raiseForStatus(res, "ingest multipart runs", true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (e: any) { + console.warn(`${e.message.trim()}\n\nContext: ${context}`); } } diff --git a/js/src/tests/batch_client.int.test.ts b/js/src/tests/batch_client.int.test.ts index a91cfc6b0..c22fc3fef 100644 --- a/js/src/tests/batch_client.int.test.ts +++ b/js/src/tests/batch_client.int.test.ts @@ -220,7 +220,7 @@ test.concurrent( trace_id: runId, dotted_order: dottedOrder, attachments: { - testimage: ["image/png", fs.readFileSync(pathname)], + testimage: ["image/png", new Uint8Array(fs.readFileSync(pathname))], }, }); @@ -241,3 +241,52 @@ test.concurrent( }, 180_000 ); + +test.only("Test persist run with attachments and compression", async () => { + const langchainClient = new Client({ + autoBatchTracing: true, + callerOptions: { maxRetries: 2 }, + timeout_ms: 30_000, + tracePayloadByteCompressionLimit: 1, + }); + const projectName = "__test_create_attachment" + uuidv4().substring(0, 4); + await deleteProject(langchainClient, projectName); + + const runId = uuidv4(); + const dottedOrder = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId + ); + const pathname = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "test_data", + "parrot-icon.png" + ); + await langchainClient.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + attachments: { + testimage: ["image/png", new Uint8Array(fs.readFileSync(pathname))], + }, + }); + + await langchainClient.updateRun(runId, { + outputs: { output: ["Hi"] }, + dotted_order: dottedOrder, + trace_id: runId, + }); + + await Promise.all([ + waitUntilRunFound(langchainClient, runId, true), + waitUntilProjectFound(langchainClient, projectName), + ]); + + const storedRun = await langchainClient.readRun(runId); + expect(storedRun.id).toEqual(runId); + await langchainClient.deleteProject({ projectName }); +}, 180_000); diff --git a/js/yarn.lock b/js/yarn.lock index f8d087c1e..2a8441bd2 100644 --- a/js/yarn.lock +++ b/js/yarn.lock @@ -3711,8 +3711,10 @@ langchain@^0.3.3: zod "^3.22.4" zod-to-json-schema "^3.22.3" -langsmith@^0.1.56, langsmith@^0.1.65, "langsmith@file:./": - version "0.1.67-rc.3" +langsmith@^0.1.56, langsmith@^0.1.65: + version "0.1.66" + resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.1.66.tgz#f5a4424c65b8d028b42e90a2e375c862748b78c7" + integrity sha512-ZhZ9g8t/qjj0oUWpvKLtUe3qxDL/N0wG0m+Ctkxf0keopYJkcMJg4/71jl6ZYyiSU8xlC27aixXOT0uvLhqcFA== dependencies: "@types/uuid" "^10.0.0" commander "^10.0.1" From 1c3b85ec1de305210f6d0be059191ed80d60ea1f Mon Sep 17 00:00:00 2001 From: jacoblee93 Date: Wed, 23 Oct 2024 11:38:49 -0700 Subject: [PATCH 12/17] Fix lint --- js/src/client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/js/src/client.ts b/js/src/client.ts index 1af78fb3b..d0ced21c2 100644 --- a/js/src/client.ts +++ b/js/src/client.ts @@ -372,6 +372,7 @@ const _compressPayload = async ( const reader = compressedPayloadStream.getReader(); const chunks = []; let totalLength = 0; + // eslint-disable-next-line no-constant-condition while (true) { const { done, value } = await reader.read(); if (done) break; From 08c3537e38cd2dae3ce52e49b48027c16929ed3e Mon Sep 17 00:00:00 2001 From: jacoblee93 Date: Wed, 23 Oct 2024 14:51:28 -0700 Subject: [PATCH 13/17] Remove focused test --- js/src/tests/batch_client.int.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/src/tests/batch_client.int.test.ts b/js/src/tests/batch_client.int.test.ts index c22fc3fef..c8565dc78 100644 --- a/js/src/tests/batch_client.int.test.ts +++ b/js/src/tests/batch_client.int.test.ts @@ -242,7 +242,7 @@ test.concurrent( 180_000 ); -test.only("Test persist run with attachments and compression", async () => { +test("Test persist run with all items compressed", async () => { const langchainClient = new Client({ autoBatchTracing: true, callerOptions: { maxRetries: 2 }, From 1b2731230d328488897c42b1724f12e3df62120b Mon Sep 17 00:00:00 2001 From: jacoblee93 Date: Thu, 7 Nov 2024 13:56:07 -0800 Subject: [PATCH 14/17] Fix flaky test --- js/src/client.ts | 3 ++- js/src/tests/batch_client.test.ts | 20 ++++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/js/src/client.ts b/js/src/client.ts index de2d36524..adce16b15 100644 --- a/js/src/client.ts +++ b/js/src/client.ts @@ -1155,7 +1155,8 @@ export class Client { ]) { for (const originalPayload of payloads) { // collect fields to be sent as separate parts - const { inputs, outputs, events, ...payload } = originalPayload; + const { inputs, outputs, events, attachments, ...payload } = + originalPayload; const fields = { inputs, outputs, events }; // encode the main run payload accumulatedParts.push({ diff --git a/js/src/tests/batch_client.test.ts b/js/src/tests/batch_client.test.ts index 43c85c15d..fe8dd2ce3 100644 --- a/js/src/tests/batch_client.test.ts +++ b/js/src/tests/batch_client.test.ts @@ -609,12 +609,20 @@ describe.each(ENDPOINT_TYPES)( await new Promise((resolve) => setTimeout(resolve, 10)); - const calledRequestParam: any = callSpy.mock.calls[0][2]; - const calledRequestParam2: any = callSpy.mock.calls[1][2]; + const calledRequestBody = await parseMockRequestBody( + (callSpy.mock.calls[0][2] as any).body + ); + const calledRequestBody2: any = await parseMockRequestBody( + (callSpy.mock.calls[1][2] as any).body + ); // Queue should drain as soon as size limit is reached, // sending both batches - expect(await parseMockRequestBody(calledRequestParam?.body)).toEqual({ + expect( + calledRequestBody.post.length === 10 + ? calledRequestBody + : calledRequestBody2 + ).toEqual({ post: runIds.slice(0, 10).map((runId, i) => expect.objectContaining({ id: runId, @@ -628,7 +636,11 @@ describe.each(ENDPOINT_TYPES)( patch: [], }); - expect(await parseMockRequestBody(calledRequestParam2?.body)).toEqual({ + expect( + calledRequestBody.post.length === 5 + ? calledRequestBody + : calledRequestBody2 + ).toEqual({ post: runIds.slice(10).map((runId, i) => expect.objectContaining({ id: runId, From 45ba14c6c43a8a3bef1c35bfaff27653221dac82 Mon Sep 17 00:00:00 2001 From: jacoblee93 Date: Thu, 7 Nov 2024 14:24:01 -0800 Subject: [PATCH 15/17] Update test --- js/src/tests/batch_client.int.test.ts | 5 +- js/src/tests/batch_client.test.ts | 93 ++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/js/src/tests/batch_client.int.test.ts b/js/src/tests/batch_client.int.test.ts index 003915d20..7254523f6 100644 --- a/js/src/tests/batch_client.int.test.ts +++ b/js/src/tests/batch_client.int.test.ts @@ -243,14 +243,14 @@ test.concurrent( 180_000 ); -test("Test persist run with all items compressed", async () => { +test.only("Test persist run with all items compressed", async () => { const langchainClient = new Client({ autoBatchTracing: true, callerOptions: { maxRetries: 2 }, timeout_ms: 30_000, tracePayloadByteCompressionLimit: 1, }); - const projectName = "__test_create_attachment" + uuidv4().substring(0, 4); + const projectName = "__test_compression" + uuidv4().substring(0, 4); await deleteProject(langchainClient, projectName); const runId = uuidv4(); @@ -289,6 +289,7 @@ test("Test persist run with all items compressed", async () => { const storedRun = await langchainClient.readRun(runId); expect(storedRun.id).toEqual(runId); + // await langchainClient.deleteProject({ projectName }); }, 180_000); test.skip("very large runs", async () => { diff --git a/js/src/tests/batch_client.test.ts b/js/src/tests/batch_client.test.ts index fe8dd2ce3..61ccc83ec 100644 --- a/js/src/tests/batch_client.test.ts +++ b/js/src/tests/batch_client.test.ts @@ -2,6 +2,7 @@ /* eslint-disable prefer-const */ import { jest } from "@jest/globals"; import { v4 as uuidv4 } from "uuid"; +import * as zlib from "node:zlib"; import { Client, mergeRuntimeEnvIntoRunCreate } from "../client.js"; import { convertToDottedOrderFormat } from "../run_trees.js"; import { _getFetchImplementation } from "../singletons/fetch.js"; @@ -24,7 +25,16 @@ const parseMockRequestBody = async (body: string | FormData) => { try { parsedValue = JSON.parse(text); } catch (e) { - parsedValue = text; + try { + // Try decompression + const buffer = Buffer.from(text); + const decompressed = zlib.gunzipSync(buffer).toString(); + parsedValue = JSON.parse(decompressed); + } catch (e) { + console.log(e); + // Give up + parsedValue = text; + } } // if (method === "attachment") { // for (const item of reconstructedBody.post) { @@ -796,7 +806,7 @@ describe.each(ENDPOINT_TYPES)( dotted_order: dottedOrder, }); - await new Promise((resolve) => setTimeout(resolve, 300)); + await client.awaitPendingTraceBatches(); const calledRequestParam: any = callSpy.mock.calls[0][2]; expect( @@ -915,3 +925,82 @@ describe.each(ENDPOINT_TYPES)( }); } ); + +it.only("should compress fields above the compression limit", async () => { + const client = new Client({ + apiKey: "test-api-key", + tracePayloadByteCompressionLimit: 1000, + autoBatchTracing: true, + }); + const callSpy = jest + .spyOn((client as any).batchIngestCaller, "call") + .mockResolvedValue({ + ok: true, + text: () => "", + }); + jest.spyOn(client as any, "_getServerInfo").mockImplementation(() => { + return { + version: "foo", + batch_ingest_config: { use_multipart_endpoint: true }, + }; + }); + + const projectName = "__test_compression"; + + const runId = uuidv4(); + const dottedOrder = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId + ); + + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world!" }, + trace_id: runId, + dotted_order: dottedOrder, + }); + + const runId2 = uuidv4(); + const dottedOrder2 = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId + ); + + await client.createRun({ + id: runId2, + project_name: projectName, + name: "test_run2", + run_type: "llm", + inputs: { text: `hello world!${"x".repeat(1000)}` }, + trace_id: runId, + dotted_order: dottedOrder2, + }); + + await client.awaitPendingTraceBatches(); + + const calledRequestParam: any = callSpy.mock.calls[0][2]; + expect(await parseMockRequestBody(calledRequestParam?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + text: "hello world!", + }, + trace_id: runId, + }), + expect.objectContaining({ + id: runId2, + run_type: "llm", + inputs: { + text: "foo", + }, + trace_id: runId2, + }), + ], + patch: [], + }); +}); From 10452457b7b2072fcf86254a39c3383199e6df75 Mon Sep 17 00:00:00 2001 From: jacoblee93 Date: Thu, 7 Nov 2024 14:34:09 -0800 Subject: [PATCH 16/17] Fix tests --- js/src/client.ts | 4 +++- js/src/tests/batch_client.int.test.ts | 6 +++++- js/src/tests/batch_client.test.ts | 11 ++++++----- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/js/src/client.ts b/js/src/client.ts index adce16b15..5f2a958bb 100644 --- a/js/src/client.ts +++ b/js/src/client.ts @@ -365,13 +365,15 @@ const handle429 = async (response?: Response) => { return false; }; +const COMPRESSION_STREAM = new CompressionStream("gzip"); + const _compressPayload = async ( payload: string | Uint8Array, contentType: string ) => { const compressedPayloadStream = new Blob([payload]) .stream() - .pipeThrough(new CompressionStream("gzip")); + .pipeThrough(COMPRESSION_STREAM); const reader = compressedPayloadStream.getReader(); const chunks = []; let totalLength = 0; diff --git a/js/src/tests/batch_client.int.test.ts b/js/src/tests/batch_client.int.test.ts index 7254523f6..a9fe4cc24 100644 --- a/js/src/tests/batch_client.int.test.ts +++ b/js/src/tests/batch_client.int.test.ts @@ -243,7 +243,7 @@ test.concurrent( 180_000 ); -test.only("Test persist run with all items compressed", async () => { +test("Test persist run with all items compressed", async () => { const langchainClient = new Client({ autoBatchTracing: true, callerOptions: { maxRetries: 2 }, @@ -276,10 +276,13 @@ test.only("Test persist run with all items compressed", async () => { }, }); + await new Promise((resolve) => setTimeout(resolve, 1000)); + await langchainClient.updateRun(runId, { outputs: { output: ["Hi"] }, dotted_order: dottedOrder, trace_id: runId, + end_time: Math.floor(new Date().getTime() / 1000), }); await Promise.all([ @@ -289,6 +292,7 @@ test.only("Test persist run with all items compressed", async () => { const storedRun = await langchainClient.readRun(runId); expect(storedRun.id).toEqual(runId); + expect(storedRun.status).toEqual("success"); // await langchainClient.deleteProject({ projectName }); }, 180_000); diff --git a/js/src/tests/batch_client.test.ts b/js/src/tests/batch_client.test.ts index 61ccc83ec..5f7cda7df 100644 --- a/js/src/tests/batch_client.test.ts +++ b/js/src/tests/batch_client.test.ts @@ -27,8 +27,9 @@ const parseMockRequestBody = async (body: string | FormData) => { } catch (e) { try { // Try decompression - const buffer = Buffer.from(text); - const decompressed = zlib.gunzipSync(buffer).toString(); + const decompressed = zlib + .gunzipSync(Buffer.from(await value.arrayBuffer())) + .toString(); parsedValue = JSON.parse(decompressed); } catch (e) { console.log(e); @@ -926,7 +927,7 @@ describe.each(ENDPOINT_TYPES)( } ); -it.only("should compress fields above the compression limit", async () => { +it("should compress fields above the compression limit", async () => { const client = new Client({ apiKey: "test-api-key", tracePayloadByteCompressionLimit: 1000, @@ -975,7 +976,7 @@ it.only("should compress fields above the compression limit", async () => { name: "test_run2", run_type: "llm", inputs: { text: `hello world!${"x".repeat(1000)}` }, - trace_id: runId, + trace_id: runId2, dotted_order: dottedOrder2, }); @@ -996,7 +997,7 @@ it.only("should compress fields above the compression limit", async () => { id: runId2, run_type: "llm", inputs: { - text: "foo", + text: `hello world!${"x".repeat(1000)}`, }, trace_id: runId2, }), From be06b8f1c135a3962aed63ded251825c67481144 Mon Sep 17 00:00:00 2001 From: jacoblee93 Date: Thu, 7 Nov 2024 14:38:20 -0800 Subject: [PATCH 17/17] Fix stupid mistake --- js/src/client.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/js/src/client.ts b/js/src/client.ts index 5f2a958bb..adce16b15 100644 --- a/js/src/client.ts +++ b/js/src/client.ts @@ -365,15 +365,13 @@ const handle429 = async (response?: Response) => { return false; }; -const COMPRESSION_STREAM = new CompressionStream("gzip"); - const _compressPayload = async ( payload: string | Uint8Array, contentType: string ) => { const compressedPayloadStream = new Blob([payload]) .stream() - .pipeThrough(COMPRESSION_STREAM); + .pipeThrough(new CompressionStream("gzip")); const reader = compressedPayloadStream.getReader(); const chunks = []; let totalLength = 0;