Skip to content

Commit 047002c

Browse files
committed
fix(langchain): Fix toJsonSchema mutating underlying zod schema
1 parent 26a1ce6 commit 047002c

File tree

4 files changed

+79
-16
lines changed

4 files changed

+79
-16
lines changed

langchain-core/src/utils/json_schema.ts

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,8 @@ import {
55
isZodSchemaV3,
66
isZodSchemaV4,
77
InteropZodType,
8-
interopZodObjectStrict,
9-
isZodObjectV4,
10-
ZodObjectV4,
11-
interopZodTransformInputSchema,
8+
ZodTypeV4,
9+
interopZodSanitizeSchema,
1210
} from "./types/zod.js";
1311

1412
export type JSONSchema = JsonSchema7Type;
@@ -22,16 +20,8 @@ export { deepCompareStrict, Validator } from "@cfworker/json-schema";
2220
*/
2321
export function toJsonSchema(schema: InteropZodType | JSONSchema): JSONSchema {
2422
if (isZodSchemaV4(schema)) {
25-
const inputSchema = interopZodTransformInputSchema(schema, true);
26-
if (isZodObjectV4(inputSchema)) {
27-
const strictSchema = interopZodObjectStrict(
28-
inputSchema,
29-
true
30-
) as ZodObjectV4;
31-
return toJSONSchema(strictSchema);
32-
} else {
33-
return toJSONSchema(schema);
34-
}
23+
const inputSchema = interopZodSanitizeSchema(schema, true);
24+
return toJSONSchema(inputSchema as ZodTypeV4);
3525
}
3626
if (isZodSchemaV3(schema)) {
3727
return zodToJsonSchema(schema);

langchain-core/src/utils/types/tests/zod.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1525,6 +1525,48 @@ describe("Zod utility functions", () => {
15251525
expect(elementShape.name).toBeInstanceOf(z4.ZodString);
15261526
expect(elementShape.age).toBeInstanceOf(z4.ZodNumber);
15271527
});
1528+
1529+
it("should not mutate the original schema when object", () => {
1530+
const inputSchema = z4.object({
1531+
user: z4.object({
1532+
name: z4.string().transform((s) => s.toUpperCase()),
1533+
age: z4.number().transform((n) => n * 2),
1534+
}),
1535+
});
1536+
1537+
const result = interopZodTransformInputSchema(inputSchema, true);
1538+
1539+
expect(result).not.toBe(inputSchema);
1540+
expect(inputSchema.shape.user.shape.name).toBeInstanceOf(z4.ZodPipe);
1541+
expect(inputSchema.shape.user.shape.age).toBeInstanceOf(z4.ZodPipe);
1542+
expect((result as z4.ZodObject).shape.user.shape.name).toBeInstanceOf(
1543+
z4.ZodString
1544+
);
1545+
expect((result as z4.ZodObject).shape.user.shape.age).toBeInstanceOf(
1546+
z4.ZodNumber
1547+
);
1548+
});
1549+
1550+
it("should not mutate the original schema when array", () => {
1551+
const inputSchema = z4.array(
1552+
z4.object({
1553+
name: z4.string().transform((s) => s.toUpperCase()),
1554+
age: z4.number().transform((n) => n * 2),
1555+
})
1556+
);
1557+
1558+
const result = interopZodTransformInputSchema(inputSchema, true);
1559+
1560+
expect(result).not.toBe(inputSchema);
1561+
expect(inputSchema.element.shape.name).toBeInstanceOf(z4.ZodPipe);
1562+
expect(inputSchema.element.shape.age).toBeInstanceOf(z4.ZodPipe);
1563+
expect(
1564+
((result as z4.ZodArray).element as z4.ZodObject).shape.name
1565+
).toBeInstanceOf(z4.ZodString);
1566+
expect(
1567+
((result as z4.ZodArray).element as z4.ZodObject).shape.age
1568+
).toBeInstanceOf(z4.ZodNumber);
1569+
});
15281570
});
15291571

15301572
it("should throw error for non-schema values", () => {

langchain-core/src/utils/types/zod.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export type ZodObjectV3 = z3.ZodObject<any, any, any, any>;
2323

2424
export type ZodObjectV4 = z4.$ZodObject;
2525

26+
export type ZodTypeV4 = z4.$ZodType;
27+
2628
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2729
export type InteropZodType<Output = any, Input = Output> =
2830
| z3.ZodType<Output, z3.ZodTypeDef, Input>
@@ -748,7 +750,9 @@ export function interopZodTransformInputSchema(
748750
if (recursive) {
749751
// Handle nested object schemas
750752
if (isZodObjectV4(outputSchema)) {
751-
const outputShape: Mutable<z4.$ZodShape> = outputSchema._zod.def.shape;
753+
const outputShape: Mutable<z4.$ZodShape> = {
754+
...outputSchema._zod.def.shape,
755+
};
752756
for (const [key, keySchema] of Object.entries(
753757
outputSchema._zod.def.shape
754758
)) {
@@ -781,3 +785,27 @@ export function interopZodTransformInputSchema(
781785

782786
throw new Error("Schema must be an instance of z3.ZodType or z4.$ZodType");
783787
}
788+
789+
/**
790+
* Sanitizes a Zod schema by transforming it to its input schema and then making it strict.
791+
* Supports both Zod v3 and v4 schemas. If `recursive` is true, applies strictness recursively to all nested object schemas and arrays of object schemas.
792+
*
793+
* @param schema - The Zod schema instance (v3 or v4)
794+
* @param {boolean} [recursive=false] - Whether to recursively process nested objects/arrays.
795+
* @returns The sanitized Zod schema.
796+
*/
797+
export function interopZodSanitizeSchema(
798+
schema: InteropZodType,
799+
recursive: boolean = false
800+
): InteropZodType {
801+
const inputSchema = interopZodTransformInputSchema(schema, recursive);
802+
if (isZodObjectV4(inputSchema)) {
803+
const strictSchema = interopZodObjectStrict(
804+
inputSchema,
805+
recursive
806+
) as ZodObjectV4;
807+
return strictSchema;
808+
} else {
809+
return inputSchema;
810+
}
811+
}

libs/langchain-openai/src/chat_models.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import {
7171
getSchemaDescription,
7272
InteropZodType,
7373
isInteropZodSchema,
74+
interopZodSanitizeSchema,
7475
} from "@langchain/core/utils/types";
7576
import { toJsonSchema } from "@langchain/core/utils/json_schema";
7677
import {
@@ -1242,7 +1243,9 @@ export abstract class BaseChatOpenAI<
12421243
const openaiJsonSchemaParams = {
12431244
name: name ?? "extract",
12441245
description: getSchemaDescription(schema),
1245-
schema,
1246+
schema: isInteropZodSchema(schema)
1247+
? interopZodSanitizeSchema(schema, true)
1248+
: schema,
12461249
strict: config?.strict,
12471250
};
12481251
const asJsonSchema = toJsonSchema(openaiJsonSchemaParams.schema);

0 commit comments

Comments
 (0)