Skip to content

Commit

Permalink
patch(js): utf-8 encode multipart parts (#1487)
Browse files Browse the repository at this point in the history
  • Loading branch information
angus-langchain authored Feb 5, 2025
1 parent f56c9bb commit 652fd09
Show file tree
Hide file tree
Showing 12 changed files with 71 additions and 47 deletions.
1 change: 1 addition & 0 deletions js/ls.vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ export default defineConfig({
include: ["**/*.vitesteval.?(c|m)[jt]s"],
reporters: ["./src/vitest/reporter.ts"],
setupFiles: ["dotenv/config"],
hookTimeout: 30000,
},
});
2 changes: 1 addition & 1 deletion js/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "langsmith",
"version": "0.3.4",
"version": "0.3.5",
"description": "Client library to connect to the LangSmith LLM Tracing and Evaluation Platform.",
"packageManager": "[email protected]",
"files": [
Expand Down
30 changes: 15 additions & 15 deletions js/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ import { parsePromptIdentifier } from "./utils/prompts.js";
import { raiseForStatus } from "./utils/error.js";
import { _getFetchImplementation } from "./singletons/fetch.js";

import { stringify as stringifyForTracing } from "./utils/fast-safe-stringify/index.js";
import { serialize as serializePayloadForTracing } from "./utils/fast-safe-stringify/index.js";

export interface ClientConfig {
apiUrl?: string;
Expand Down Expand Up @@ -405,7 +405,7 @@ export class AutoBatchQueue {
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/Promise
itemPromiseResolve = resolve;
});
const size = stringifyForTracing(item.item).length;
const size = serializePayloadForTracing(item.item).length;
this.items.push({
action: item.action,
payload: item.item,
Expand Down Expand Up @@ -943,7 +943,7 @@ export class Client implements LangSmithTracingClientInterface {
{
method: "POST",
headers,
body: stringifyForTracing(mergedRunCreateParam),
body: serializePayloadForTracing(mergedRunCreateParam),
signal: AbortSignal.timeout(this.timeout_ms),
...this.fetchOptions,
}
Expand Down Expand Up @@ -1020,11 +1020,11 @@ export class Client implements LangSmithTracingClientInterface {
}
}
if (batchChunks.post.length > 0 || batchChunks.patch.length > 0) {
await this._postBatchIngestRuns(stringifyForTracing(batchChunks));
await this._postBatchIngestRuns(serializePayloadForTracing(batchChunks));
}
}

private async _postBatchIngestRuns(body: string) {
private async _postBatchIngestRuns(body: Uint8Array) {
const headers = {
...this.headers,
"Content-Type": "application/json",
Expand Down Expand Up @@ -1143,7 +1143,7 @@ export class Client implements LangSmithTracingClientInterface {
originalPayload;
const fields = { inputs, outputs, events };
// encode the main run payload
const stringifiedPayload = stringifyForTracing(payload);
const stringifiedPayload = serializePayloadForTracing(payload);
accumulatedParts.push({
name: `${method}.${payload.id}`,
payload: new Blob([stringifiedPayload], {
Expand All @@ -1155,7 +1155,7 @@ export class Client implements LangSmithTracingClientInterface {
if (value === undefined) {
continue;
}
const stringifiedValue = stringifyForTracing(value);
const stringifiedValue = serializePayloadForTracing(value);
accumulatedParts.push({
name: `${method}.${payload.id}.${key}`,
payload: new Blob([stringifiedValue], {
Expand Down Expand Up @@ -1301,7 +1301,7 @@ export class Client implements LangSmithTracingClientInterface {
{
method: "PATCH",
headers,
body: stringifyForTracing(run),
body: serializePayloadForTracing(run),
signal: AbortSignal.timeout(this.timeout_ms),
...this.fetchOptions,
}
Expand Down Expand Up @@ -4124,15 +4124,15 @@ export class Client implements LangSmithTracingClientInterface {
};

// Add main example data
const stringifiedExample = stringifyForTracing(exampleBody);
const stringifiedExample = serializePayloadForTracing(exampleBody);
const exampleBlob = new Blob([stringifiedExample], {
type: "application/json",
});
formData.append(exampleId, exampleBlob);

// Add inputs
if (example.inputs) {
const stringifiedInputs = stringifyForTracing(example.inputs);
const stringifiedInputs = serializePayloadForTracing(example.inputs);
const inputsBlob = new Blob([stringifiedInputs], {
type: "application/json",
});
Expand All @@ -4141,7 +4141,7 @@ export class Client implements LangSmithTracingClientInterface {

// Add outputs if present
if (example.outputs) {
const stringifiedOutputs = stringifyForTracing(example.outputs);
const stringifiedOutputs = serializePayloadForTracing(example.outputs);
const outputsBlob = new Blob([stringifiedOutputs], {
type: "application/json",
});
Expand All @@ -4168,7 +4168,7 @@ export class Client implements LangSmithTracingClientInterface {
}

if (example.attachments_operations) {
const stringifiedAttachmentsOperations = stringifyForTracing(
const stringifiedAttachmentsOperations = serializePayloadForTracing(
example.attachments_operations
);
const attachmentsOperationsBlob = new Blob(
Expand Down Expand Up @@ -4224,22 +4224,22 @@ export class Client implements LangSmithTracingClientInterface {
};

// Add main example data
const stringifiedExample = stringifyForTracing(exampleBody);
const stringifiedExample = serializePayloadForTracing(exampleBody);
const exampleBlob = new Blob([stringifiedExample], {
type: "application/json",
});
formData.append(exampleId, exampleBlob);

// Add inputs
const stringifiedInputs = stringifyForTracing(example.inputs);
const stringifiedInputs = serializePayloadForTracing(example.inputs);
const inputsBlob = new Blob([stringifiedInputs], {
type: "application/json",
});
formData.append(`${exampleId}.inputs`, inputsBlob);

// Add outputs if present
if (example.outputs) {
const stringifiedOutputs = stringifyForTracing(example.outputs);
const stringifiedOutputs = serializePayloadForTracing(example.outputs);
const outputsBlob = new Blob([stringifiedOutputs], {
type: "application/json",
});
Expand Down
1 change: 0 additions & 1 deletion js/src/evaluation/_runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -877,7 +877,6 @@ export class _ExperimentManager {
}

await this.client.updateProject(experiment.id, {
endTime: new Date().toISOString(),
metadata: projectMetadata,
});
}
Expand Down
2 changes: 1 addition & 1 deletion js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ export { RunTree, type RunTreeConfig } from "./run_trees.js";
export { overrideFetchImplementation } from "./singletons/fetch.js";

// Update using yarn bump-version
export const __version__ = "0.3.4";
export const __version__ = "0.3.5";
19 changes: 13 additions & 6 deletions js/src/tests/batch_client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@ import { convertToDottedOrderFormat } from "../run_trees.js";
import { _getFetchImplementation } from "../singletons/fetch.js";
import { RunCreate } from "../schemas.js";

const parseMockRequestBody = async (body: string | ArrayBuffer) => {
const parseMockRequestBody = async (
body: string | ArrayBuffer | Uint8Array
) => {
if (typeof body === "string") {
return JSON.parse(body);
}

// Typing is missing
const rawMultipart = new TextDecoder().decode(body);

if (rawMultipart.trim().startsWith("{")) {
return JSON.parse(rawMultipart);
}
// Parse the multipart form data boundary from the raw text
const boundary = rawMultipart.split("\r\n")[0].trim();
// Split the multipart body into individual parts
Expand Down Expand Up @@ -143,7 +150,7 @@ describe.each(ENDPOINT_TYPES)(
_getFetchImplementation(),
expectedTraceURL,
expect.objectContaining({
body: expect.any(endpointType === "batch" ? String : ArrayBuffer),
body: expect.any(endpointType === "batch" ? Uint8Array : ArrayBuffer),
})
);
});
Expand Down Expand Up @@ -257,7 +264,7 @@ describe.each(ENDPOINT_TYPES)(
_getFetchImplementation(),
expectedTraceURL,
expect.objectContaining({
body: expect.any(endpointType === "batch" ? String : ArrayBuffer),
body: expect.any(endpointType === "batch" ? Uint8Array : ArrayBuffer),
})
);
});
Expand Down Expand Up @@ -338,7 +345,7 @@ describe.each(ENDPOINT_TYPES)(
_getFetchImplementation(),
expectedTraceURL,
expect.objectContaining({
body: expect.any(endpointType === "batch" ? String : ArrayBuffer),
body: expect.any(endpointType === "batch" ? Uint8Array : ArrayBuffer),
})
);
});
Expand Down Expand Up @@ -935,7 +942,7 @@ describe.each(ENDPOINT_TYPES)(
_getFetchImplementation(),
"https://api.smith.langchain.com/runs/batch",
expect.objectContaining({
body: expect.any(String),
body: expect.any(Uint8Array),
})
);
});
Expand Down Expand Up @@ -1027,7 +1034,7 @@ describe.each(ENDPOINT_TYPES)(
_getFetchImplementation(),
expectedTraceURL,
expect.objectContaining({
body: expect.any(endpointType === "batch" ? String : ArrayBuffer),
body: expect.any(endpointType === "batch" ? Uint8Array : ArrayBuffer),
})
);
});
Expand Down
17 changes: 13 additions & 4 deletions js/src/tests/utils/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,29 @@ export function getAssumedTreeFromCalls(calls: unknown[][]) {
{ method: string; body: string }
];
const req = `${fetchArgs.method} ${new URL(url as string).pathname}`;
const body: Run = JSON.parse(fetchArgs.body);
let body: Run;
if (typeof fetchArgs.body === "string") {
body = JSON.parse(fetchArgs.body);
} else {
const decoded = new TextDecoder().decode(fetchArgs.body);

if (decoded.trim().startsWith("{")) {
body = JSON.parse(decoded);
}
}

if (req === "POST /runs") {
const id = body.id;
const id = body!.id;
upsertId(id);
nodeMap[id] = { ...nodeMap[id], ...body };
nodeMap[id] = { ...nodeMap[id], ...body! };
if (nodeMap[id].parent_run_id) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
edges.push([nodeMap[id].parent_run_id!, nodeMap[id].id]);
}
} else if (req.startsWith("PATCH /runs/")) {
const id = req.substring("PATCH /runs/".length);
upsertId(id);
nodeMap[id] = { ...nodeMap[id], ...body };
nodeMap[id] = { ...nodeMap[id], ...body! };
}
}

Expand Down
17 changes: 12 additions & 5 deletions js/src/tests/wrapped_openai.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ import { z } from "zod";
import { UsageMetadata } from "../schemas.js";
import fs from "fs";

function parseRequestBody(body: any) {
// eslint-disable-next-line no-instanceof/no-instanceof
return body instanceof Uint8Array
? JSON.parse(new TextDecoder().decode(body))
: JSON.parse(body);
}

test("wrapOpenAI should return type compatible with OpenAI", async () => {
let originalClient = new OpenAI();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand Down Expand Up @@ -323,8 +330,8 @@ test.concurrent("chat completions with tool calling", async () => {
for (const call of callSpy.mock.calls) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(["POST", "PATCH"]).toContain((call[2] as any)["method"]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(JSON.parse((call[2] as any).body).extra.metadata).toEqual({
const body = parseRequestBody((call[2] as any).body);
expect(body.extra.metadata).toEqual({
thing1: "thing2",
ls_model_name: "gpt-3.5-turbo",
ls_model_type: "chat",
Expand Down Expand Up @@ -566,8 +573,8 @@ test.concurrent("beta.chat.completions.parse", async () => {
for (const call of callSpy.mock.calls) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(["POST", "PATCH"]).toContain((call[2] as any)["method"]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(JSON.parse((call[2] as any).body).extra.metadata).toEqual({
const body = parseRequestBody((call[2] as any).body);
expect(body.extra.metadata).toEqual({
ls_model_name: "gpt-4o-mini",
ls_model_type: "chat",
ls_provider: "openai",
Expand Down Expand Up @@ -655,7 +662,7 @@ describe("Usage Metadata Tests", () => {
const requestBodies: any = {};
for (const call of callSpy.mock.calls) {
const request = call[2] as any;
const requestBody = JSON.parse(request.body);
const requestBody = parseRequestBody(request.body);
if (request.method === "POST") {
requestBodies["post"] = [requestBody];
}
Expand Down
21 changes: 14 additions & 7 deletions js/src/utils/fast-safe-stringify/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,29 @@ var CIRCULAR_REPLACE_NODE = { result: "[Circular]" };
var arr = [];
var replacerStack = [];

const encoder = new TextEncoder();

function defaultOptions() {
return {
depthLimit: Number.MAX_SAFE_INTEGER,
edgesLimit: Number.MAX_SAFE_INTEGER,
};
}

function encodeString(str: string): Uint8Array {
return encoder.encode(str);
}

// Regular stringify
export function stringify(obj, replacer?, spacer?, options?) {
export function serialize(obj, replacer?, spacer?, options?) {
try {
return JSON.stringify(obj, replacer, spacer);
const str = JSON.stringify(obj, replacer, spacer);
return encodeString(str);
} catch (e: any) {
// Fall back to more complex stringify if circular reference
if (!e.message?.includes("Converting circular structure to JSON")) {
console.warn("[WARNING]: LangSmith received unserializable value.");
return "[Unserializable]";
return encodeString("[Unserializable]");
}
console.warn(
"[WARNING]: LangSmith received circular JSON. This will decrease tracer performance."
Expand All @@ -31,28 +38,28 @@ export function stringify(obj, replacer?, spacer?, options?) {
}

decirc(obj, "", 0, [], undefined, 0, options);
var res;
let res: string;
try {
if (replacerStack.length === 0) {
res = JSON.stringify(obj, replacer, spacer);
} else {
res = JSON.stringify(obj, replaceGetterValues(replacer), spacer);
}
} catch (_) {
return JSON.stringify(
return encodeString(
"[unable to serialize, circular reference is too complex to analyze]"
);
} finally {
while (arr.length !== 0) {
var part = arr.pop();
const part = arr.pop();
if (part.length === 4) {
Object.defineProperty(part[0], part[1], part[3]);
} else {
part[0][part[1]] = part[2];
}
}
}
return res;
return encodeString(res);
}
}

Expand Down
2 changes: 1 addition & 1 deletion python/langsmith/beta/_evals.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ def convert_runs_to_test(
client.create_run(**new_run, project_name=test_project_name)

_ = client.update_project(
project.id, end_time=datetime.datetime.now(tz=datetime.timezone.utc)
project.id,
)
return project

Expand Down
3 changes: 0 additions & 3 deletions python/langsmith/evaluation/_arunner.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import asyncio
import concurrent.futures as cf
import datetime
import io
import logging
import pathlib
Expand Down Expand Up @@ -1137,8 +1136,6 @@ async def _aend(self) -> None:
project_metadata["dataset_splits"] = await self._get_dataset_splits()
self.client.update_project(
experiment.id,
end_time=experiment.end_time
or datetime.datetime.now(datetime.timezone.utc),
metadata={
**experiment.metadata,
**project_metadata,
Expand Down
Loading

0 comments on commit 652fd09

Please sign in to comment.