@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
737 lines (736 loc) • 32.7 kB
JavaScript
import { createRequire } from "node:module";
import * as zodModule from "zod";
import { z } from "zod";
import { logger } from "./logger.js";
/**
* zod-to-json-schema and json-schema-to-zod are loaded synchronously via
* require() on first actual use, not via a top-level import, so merely
* importing this module no longer pulls in either package for the many
* callers who never convert a schema. Both ship a CJS `require` export
* condition (checked against their package.json), so require() works on
* any Node >=22.0.0 — no experimental flag, unlike require(esm) of a local
* ES module (which only works unflagged from Node >=22.12). Zod itself
* stays a normal static import: its runtime schemas (`z`, `zodModule`) are
* public exports used well beyond schema conversion. Follows the pattern in
* src/lib/proxy/otelLogSink.ts — construction stays one synchronous call,
* exactly as before; only the *import* is deferred.
*
* A require() made through createRequire() is invisible to bundlers, so a
* consumer that bundles the SDK without these packages next to the output can
* no longer resolve them, and listing them as external changes nothing for the
* same reason: the bundler never sees the call. That must not pass for a
* schema that simply had no properties: the failure is reported once per
* package at error level (the only level shown without NEUROLINK_DEBUG),
* remembered so later calls neither retry the lookup nor repeat the report,
* and the caller degrades exactly as it does when a conversion throws. The
* browser artifact is the one bundle we build ourselves;
* scripts/build-browser.mjs bundles both packages into it and serves them from
* its `module` stub.
*/
const require = createRequire(import.meta.url);
function lazyPackageFunction(packageName, exportName) {
let attempted = false;
let resolved;
return () => {
if (attempted) {
return resolved;
}
attempted = true;
let failure;
try {
const loadedModule = require(packageName);
const candidate = typeof loadedModule === "object" && loadedModule !== null
? Reflect.get(loadedModule, exportName)
: undefined;
if (typeof candidate === "function") {
resolved = candidate;
}
else {
failure = `it resolved without a "${exportName}" function`;
}
}
catch (error) {
failure = (error instanceof Error ? error.message : String(error)).split("\n")[0];
}
if (failure !== undefined) {
logger.error(`[SCHEMA-CONVERSION] Cannot load "${packageName}" (${failure}). Any schema ` +
`that needs it is replaced with an empty object schema, so the model ` +
`sees no parameters for it. The SDK loads this package at run time ` +
`and a bundler cannot see that load, so bundling the SDK does not ` +
`provide it: install "${packageName}" where @juspay/neurolink can ` +
`resolve it, next to your bundle if you bundle the SDK.`);
}
return resolved;
};
}
const loadZodToJsonSchema = lazyPackageFunction("zod-to-json-schema", "zodToJsonSchema");
const loadJsonSchemaToZod = lazyPackageFunction("json-schema-to-zod", "jsonSchemaToZod");
// Zod 4 ships a built-in `z.toJSONSchema(...)`. Zod 3 does not — it returned
// nothing of the sort and we relied entirely on `zod-to-json-schema`. The
// `zod-to-json-schema` package only understands Zod 3's internal `_def`
// shape, so feeding it a Zod 4 schema yields an empty `{}` (it silently
// produces `definitions: { ToolParameters: {} }`). When this happens
// downstream callers send an empty `responseSchema` to the model and get
// back arbitrary JSON, which is exactly how the Vertex Structured-Output
// regressions surfaced. Detect the Zod 4 helper at module load and prefer
// it for actual Zod schemas.
// Zod 4 spells the OpenAPI target as "openapi-3.0" (with a dot) while the
// third-party zod-to-json-schema package uses "openApi3". Internally we use
// the latter for backwards compatibility with existing call sites; this map
// translates to the dialect Zod 4 actually accepts. The Zod4Native* types
// live in src/lib/types/aliases.ts per project rule 2.
const zodToJsonSchemaV4 = typeof zodModule.toJSONSchema === "function"
? zodModule.toJSONSchema
: undefined;
/**
* Resolve a deep JSON pointer path within a schema.
* Handles paths like "#/definitions/ToolParameters/properties/foo/properties/bar"
*
* Implements RFC 6901 token decoding so property names containing the literal
* characters "/" or "~" can still be resolved (their escaped forms are "~1"
* and "~0" respectively). Because "#/..." is the URI-fragment form of a JSON
* Pointer (RFC 6901 §6), each segment may also be percent-encoded; we decode
* that first.
*
* Order matters: percent-decode → "~1" → "/" → "~0" → "~". Reversing the
* tilde steps would let "~01" round-trip to "/" instead of the intended "~1".
*/
function resolveDeepRef(rootSchema, refPath) {
// Strip the leading "#/" then split + decode each segment per RFC 6901
const pathParts = refPath
.replace(/^#\//, "")
.split("/")
.map((seg) => safePercentDecode(seg).replace(/~1/g, "/").replace(/~0/g, "~"));
let current = rootSchema;
for (const part of pathParts) {
if (current && typeof current === "object" && part in current) {
current = current[part];
}
else {
return undefined;
}
}
if (current && typeof current === "object") {
return current;
}
return undefined;
}
/**
* Percent-decode a JSON Pointer segment defensively. Falls back to the raw
* segment if the input contains a malformed escape sequence (decodeURIComponent
* throws URIError on those) — better to attempt a literal match than fail the
* whole resolution.
*/
function safePercentDecode(segment) {
try {
return decodeURIComponent(segment);
}
catch {
return segment;
}
}
/**
* Inline a JSON Schema by recursively resolving all $ref references.
* zodToJsonSchema with 'name' option produces schemas with $ref pointing to definitions.
* Some SDKs (like @google/genai) expect flat schemas without $ref.
*
* This function handles:
* - Top-level $ref resolution
* - Nested $ref within properties, items, additionalProperties
* - $ref within allOf, anyOf, oneOf arrays
* - Deep $ref paths like "#/definitions/Foo/properties/bar"
* - Circular reference detection to prevent infinite loops
*/
export function inlineJsonSchema(schema, definitions, visited = new Set(), rootSchema) {
// Use definitions from schema if not provided. Modern MCP servers and
// zod v4's native toJSONSchema emit the 2020-12 `$defs` keyword; older
// emitters use draft-07 `definitions`. Support both.
const defs = definitions ||
(schema.definitions ?? schema.$defs);
// Keep track of the root schema for deep ref resolution
const root = rootSchema || schema;
// Handle $ref at current level
if (typeof schema.$ref === "string" && schema.$ref.startsWith("#/")) {
const refPath = schema.$ref;
// Prevent circular reference infinite loops
if (visited.has(refPath)) {
logger.debug(`[SCHEMA-INLINE] Circular reference detected for: ${refPath}`);
// Return a simple object placeholder for circular refs
return { type: "object" };
}
// Try simple definition lookup first (#/definitions/SomeName or the
// 2020-12 #/$defs/SomeName form)
if (refPath.startsWith("#/definitions/") ||
refPath.startsWith("#/$defs/")) {
const defName = refPath
.replace("#/definitions/", "")
.replace("#/$defs/", "");
// Check if it's a simple definition name (no slashes after the prefix)
if (!defName.includes("/") && defs && defs[defName]) {
visited.add(refPath);
const resolved = inlineJsonSchema({ ...defs[defName] }, defs, visited, root);
visited.delete(refPath);
return resolved;
}
}
// Try deep path resolution for complex paths like
// #/definitions/ToolParameters/properties/accountPerformance/properties/roas
const resolved = resolveDeepRef(root, refPath);
if (resolved) {
visited.add(refPath);
const inlined = inlineJsonSchema({ ...resolved }, defs, visited, root);
visited.delete(refPath);
return inlined;
}
// Unresolved $ref: warn and preserve the original node verbatim. Falling
// through to the copy loop below would strip the $ref key and silently
// turn a ref-only node into an empty {}, which broadens validation
// instead of failing closed.
logger.warn(`[SCHEMA-INLINE] Could not resolve $ref: ${refPath}`);
return { ...schema };
}
// Create result without $ref and definitions
const result = {};
for (const [key, value] of Object.entries(schema)) {
// Skip $ref and definition-container keys (both draft-07 and 2020-12)
if (key === "$ref" || key === "definitions" || key === "$defs") {
continue;
}
// Recursively process nested schemas
if (key === "properties" && value && typeof value === "object") {
const properties = {};
for (const [propName, propSchema] of Object.entries(value)) {
if (propSchema && typeof propSchema === "object") {
properties[propName] = inlineJsonSchema(propSchema, defs, visited, root);
}
else {
properties[propName] = propSchema;
}
}
result[key] = properties;
}
else if (key === "items" && value && typeof value === "object") {
// Handle array items schema
if (Array.isArray(value)) {
result[key] = value.map((item) => item && typeof item === "object"
? inlineJsonSchema(item, defs, visited, root)
: item);
}
else {
result[key] = inlineJsonSchema(value, defs, visited, root);
}
}
else if (key === "additionalProperties" &&
value &&
typeof value === "object") {
result[key] = inlineJsonSchema(value, defs, visited, root);
}
else if ((key === "allOf" || key === "anyOf" || key === "oneOf") &&
Array.isArray(value)) {
// Handle composition schemas
result[key] = value.map((item) => item && typeof item === "object"
? inlineJsonSchema(item, defs, visited, root)
: item);
}
else if (key === "not" && value && typeof value === "object") {
result[key] = inlineJsonSchema(value, defs, visited, root);
}
else if ((key === "if" || key === "then" || key === "else") &&
value &&
typeof value === "object") {
result[key] = inlineJsonSchema(value, defs, visited, root);
}
else {
result[key] = value;
}
}
return result;
}
/**
* Recursively ensure all nested schemas have a type field.
* Google Vertex AI requires ALL schema objects (including nested properties) to have a type field.
* This function walks through the schema tree and adds type:"object" to any object-like schema
* that's missing its type field.
*/
export function ensureNestedSchemaTypes(schema) {
if (!schema || typeof schema !== "object") {
return {};
}
let result = { ...schema };
// CRITICAL FIX: Flatten single-item allOf for Google Vertex AI compatibility
// When we have { allOf: [{ type: "object", ... }], nullable: true }, flatten it to:
// { type: "object", ..., nullable: true }
if (result.allOf &&
Array.isArray(result.allOf) &&
result.allOf.length === 1 &&
result.allOf[0] &&
typeof result.allOf[0] === "object") {
const innerSchema = result.allOf[0];
// Only flatten if inner schema has meaningful content (type, properties, items, etc.)
if (innerSchema.type ||
innerSchema.properties ||
innerSchema.items ||
innerSchema.enum) {
logger.debug(`[SCHEMA-TYPE-FIX] Flattening single-item allOf with type: ${innerSchema.type}`);
// Merge: inner schema properties take precedence, except for wrapper's metadata
const { allOf: _ignored, ...wrapperProps } = result;
result = {
...innerSchema,
...wrapperProps, // Keep wrapper's nullable, description, etc.
};
// If inner schema had its own nullable/description, restore them
if (innerSchema.description && !wrapperProps.description) {
result.description = innerSchema.description;
}
}
}
// Infer type from structure if missing
if (!result.type) {
// If it has properties, it's an object
if (result.properties) {
result.type = "object";
logger.debug(`[SCHEMA-TYPE-FIX] Added type:"object" to schema with properties`);
}
// If it has items, it's an array
else if (result.items) {
result.type = "array";
logger.debug(`[SCHEMA-TYPE-FIX] Added type:"array" to schema with items`);
}
// If it has enum, infer from enum values — but only when ALL elements
// share the same primitive type. A mixed enum like [1, "x"] would
// otherwise be silently narrowed to whatever the first element is.
else if (result.enum &&
Array.isArray(result.enum) &&
result.enum.length > 0) {
if (result.enum.every((v) => typeof v === "string")) {
result.type = "string";
logger.debug(`[SCHEMA-TYPE-FIX] Added type:"string" to schema with enum`);
}
else if (result.enum.every((v) => typeof v === "number")) {
result.type = "number";
logger.debug(`[SCHEMA-TYPE-FIX] Added type:"number" to schema with enum`);
}
// Mixed-type enum: leave result.type unset rather than narrow it.
}
// If it has allOf with typed schemas, infer from first item
else if (result.allOf &&
Array.isArray(result.allOf) &&
result.allOf.length > 0) {
const firstItem = result.allOf[0];
if (firstItem && firstItem.type) {
result.type = firstItem.type;
logger.debug(`[SCHEMA-TYPE-FIX] Inferred type from allOf: ${result.type}`);
}
}
}
// Recursively process properties
if (result.properties && typeof result.properties === "object") {
const properties = {};
for (const [propName, propSchema] of Object.entries(result.properties)) {
if (propSchema && typeof propSchema === "object") {
properties[propName] = ensureNestedSchemaTypes(propSchema);
}
else {
properties[propName] = propSchema;
}
}
result.properties = properties;
}
// Recursively process items (for arrays)
if (result.items && typeof result.items === "object") {
if (Array.isArray(result.items)) {
result.items = result.items.map((item) => item && typeof item === "object"
? ensureNestedSchemaTypes(item)
: item);
}
else {
result.items = ensureNestedSchemaTypes(result.items);
}
}
// Recursively process additionalProperties
if (result.additionalProperties &&
typeof result.additionalProperties === "object") {
result.additionalProperties = ensureNestedSchemaTypes(result.additionalProperties);
}
// Recursively process allOf, anyOf, oneOf
for (const key of ["allOf", "anyOf", "oneOf"]) {
if (result[key] && Array.isArray(result[key])) {
result[key] = result[key].map((item) => item && typeof item === "object"
? ensureNestedSchemaTypes(item)
: item);
}
}
return result;
}
/**
* Convert Zod schema to JSON Schema format for provider APIs.
*
* Handles three input types:
* 1. Zod schemas (have `_def.typeName`) -- converted via zod-to-json-schema
* 2. AI SDK `jsonSchema()` wrappers (have `.jsonSchema` property) -- extracted directly
* 3. Plain JSON Schema objects (have `type`/`properties` but no `_def`) -- returned as-is
*/
export function convertZodToJsonSchema(
// The union mirrors the three documented input shapes: Zod schemas plus
// plain-object forms (AI SDK `jsonSchema()` wrappers and raw JSON Schema).
zodSchema,
// Default to JSON Schema draft-07 so non-Vertex consumers (Bedrock, MCP
// tool registration, etc.) keep their pre-migration dialect. Vertex/Gemini
// callers opt into "openApi3" explicitly to get `nullable: true` instead
// of `anyOf: [..., {type: "null"}]`.
target = "jsonSchema7") {
const schema = zodSchema;
if (!schema || typeof schema !== "object") {
return { type: "object", properties: {} };
}
// AI SDK jsonSchema() wrapper — extract the inner JSON Schema directly
if ("jsonSchema" in schema &&
schema.jsonSchema !== null &&
typeof schema.jsonSchema === "object") {
const extracted = schema.jsonSchema;
return ensureNestedSchemaTypes(ensureTypeField(extracted));
}
// Plain JSON Schema object (from external MCP tools) — no Zod internals
if (!isZodSchema(schema)) {
return ensureNestedSchemaTypes(ensureTypeField(schema));
}
// Actual Zod schema — prefer Zod 4's native `z.toJSONSchema` when
// available (the runtime version of `zod` here is Zod 4), then fall
// back to `zod-to-json-schema` for Zod 3 schemas that external callers
// might still pass in.
//
// Translate our `target` to Zod 4's native dialect identifier so the
// openApi3 path emits the OpenAPI 3 schema shape Vertex/Gemini expect
// (and not the default draft-07 anyOf/null union).
if (zodToJsonSchemaV4 && isZod4Schema(zodSchema)) {
const nativeTarget = target === "openApi3" ? "openapi-3.0" : "draft-07";
try {
const native = zodToJsonSchemaV4(zodSchema, {
target: nativeTarget,
});
// Drop the $schema metadata Vertex/Gemini doesn't need, then walk to
// backfill any missing nested types (Zod 4's output is already flat
// — no $defs/$ref by default — but the helper is cheap and matches
// the Zod 3 path's contract).
const flat = { ...native };
delete flat.$schema;
const inlined = inlineJsonSchema(flat);
return ensureNestedSchemaTypes(ensureTypeField(inlined));
}
catch (error) {
logger.warn("Native z.toJSONSchema failed; falling back to zod-to-json-schema", { error: error instanceof Error ? error.message : String(error) });
}
}
// Zod 3 fallback path
const convertWithZodToJsonSchema = loadZodToJsonSchema();
if (!convertWithZodToJsonSchema) {
return { type: "object", properties: {} };
}
try {
// Zod 4→3 boundary: zodToJsonSchema types reference Zod 3's ZodSchema via zod/v3.
// Runtime compatible — cast through unknown at this third-party boundary only.
// eslint-disable-next-line no-restricted-syntax -- genuine third-party type-system boundary: zod-to-json-schema's input type is zod/v3's ZodType, structurally incompatible with the Zod 4 schema held here (no overlap, single assertion cannot compile); runtime-compatible fallback contract documented above.
const zodV3Schema = zodSchema;
const jsonSchema = convertWithZodToJsonSchema(zodV3Schema, {
name: "ToolParameters",
target,
errorMessages: true,
});
// zodToJsonSchema with 'name' produces { $ref: "#/definitions/ToolParameters", definitions: {...} }
// Inline the $ref to produce a flat schema, ensure the root has a type
// field, then walk the tree so nested objects/arrays/additionalProperties
// also pick up an inferred type (Vertex/Gemini require it everywhere).
const inlined = inlineJsonSchema(jsonSchema);
return ensureNestedSchemaTypes(ensureTypeField(inlined));
}
catch (error) {
logger.warn("Failed to convert Zod schema to JSON Schema", {
error: error instanceof Error ? error.message : String(error),
});
return { type: "object", properties: {} };
}
}
export function normalizeJsonSchemaObject(schema) {
return ensureTypeField(inlineJsonSchema(schema ? { ...schema } : { type: "object", properties: {} }));
}
/**
* Ensure a JSON Schema object has a `type` field (required by Vertex/Gemini).
*/
function ensureTypeField(schema) {
if (!schema.type) {
// Schemas using composition keywords (anyOf/oneOf/allOf) deliberately omit type
if (schema.anyOf || schema.oneOf || schema.allOf) {
return schema;
}
const hadProperties = !!schema.properties;
const result = {
...schema,
type: "object",
};
if (!result.properties) {
result.properties = {};
}
logger.debug("[SCHEMA-TYPE-FIX] Added missing type field to JSON Schema", {
fixedType: "object",
addedProperties: !hadProperties,
});
return result;
}
return schema;
}
/** JSON Schema keys whose values are themselves schemas (single). */
const SCHEMA_VALUE_KEYS = new Set([
"items",
"additionalProperties",
"not",
"if",
"then",
"else",
"contains",
"propertyNames",
]);
/** JSON Schema keys whose values are arrays of schemas. */
const SCHEMA_ARRAY_KEYS = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]);
/** JSON Schema keys whose values are name → schema maps. */
const SCHEMA_MAP_KEYS = new Set([
"properties",
"patternProperties",
"$defs",
"definitions",
]);
/**
* Annotation keys that carry no validation semantics for a model and that
* generic OpenAI-compatible backends (vllm, GLM/Qwen chat templates, local
* servers) may render into the prompt verbatim, confusing argument
* generation. Removed only at SCHEMA positions — never inside data values
* like `default`, `const`, `enum` or `examples`, where `$id` etc. are
* legitimate payload.
*/
const SCHEMA_ANNOTATION_KEYS = new Set(["$schema", "$id", "$comment"]);
/** Walk schema positions only, dropping annotation-only keys. */
function stripSchemaAnnotations(schema) {
const result = {};
for (const [key, value] of Object.entries(schema)) {
if (SCHEMA_ANNOTATION_KEYS.has(key)) {
continue;
}
if (SCHEMA_VALUE_KEYS.has(key) &&
value &&
typeof value === "object" &&
!Array.isArray(value)) {
result[key] = stripSchemaAnnotations(value);
}
else if (SCHEMA_ARRAY_KEYS.has(key) && Array.isArray(value)) {
result[key] = value.map((item) => item && typeof item === "object" && !Array.isArray(item)
? stripSchemaAnnotations(item)
: item);
}
else if (SCHEMA_MAP_KEYS.has(key) &&
value &&
typeof value === "object" &&
!Array.isArray(value)) {
const map = {};
for (const [name, sub] of Object.entries(value)) {
map[name] =
sub && typeof sub === "object" && !Array.isArray(sub)
? stripSchemaAnnotations(sub)
: sub;
}
result[key] = map;
}
else {
result[key] = value;
}
}
return result;
}
/**
* Collapse the zod-emitted nullable pattern `anyOf/oneOf: [T, {type:"null"}]`
* into `{...T, type: [t, "null"]}`. Weaker OSS models handle a plain type
* array far better than composition keywords when choosing tool arguments.
* Conservative: only fires for exactly-two variants where the non-null
* variant has a primitive `type` string — everything else passes through.
*/
function collapseNullableVariants(schema) {
const result = {};
for (const [key, value] of Object.entries(schema)) {
if (SCHEMA_VALUE_KEYS.has(key) &&
value &&
typeof value === "object" &&
!Array.isArray(value)) {
result[key] = collapseNullableVariants(value);
}
else if (SCHEMA_ARRAY_KEYS.has(key) && Array.isArray(value)) {
result[key] = value.map((item) => item && typeof item === "object" && !Array.isArray(item)
? collapseNullableVariants(item)
: item);
}
else if (SCHEMA_MAP_KEYS.has(key) &&
value &&
typeof value === "object" &&
!Array.isArray(value)) {
const map = {};
for (const [name, sub] of Object.entries(value)) {
map[name] =
sub && typeof sub === "object" && !Array.isArray(sub)
? collapseNullableVariants(sub)
: sub;
}
result[key] = map;
}
else {
result[key] = value;
}
}
for (const compositionKey of ["anyOf", "oneOf"]) {
const variants = result[compositionKey];
if (!Array.isArray(variants) || variants.length !== 2) {
continue;
}
const isNullVariant = (v) => !!v &&
typeof v === "object" &&
v.type === "null";
const nullIndex = variants.findIndex(isNullVariant);
if (nullIndex === -1) {
continue;
}
const other = variants[1 - nullIndex];
if (!other ||
typeof other !== "object" ||
Array.isArray(other) ||
typeof other.type !== "string" ||
other.type === "null") {
continue;
}
// Merge: variant content + outer annotations (description etc.) win.
const { [compositionKey]: _dropped, ...outer } = result;
return { ...other, ...outer, type: [other.type, "null"] };
}
return result;
}
/**
* Normalize a JSON Schema for the OpenAI chat-completions `tools` wire
* block. Generic proxied backends (LiteLLM → vllm/GLM/Qwen, local servers)
* render tool schemas into chat templates more or less verbatim, so `$ref`
* indirection, `$defs` containers, `$schema` annotations, and zod's
* nullable `anyOf` pattern all measurably degrade argument generation —
* this is the parity gap behind "the model can't find the right arguments"
* on litellm while the same tools work on Gemini/Claude native paths
* (which sanitize schemas before the wire).
*
* Pipeline: strip annotation keys → inline local `$ref`/`$defs`/
* `definitions` → collapse nullable variants → guarantee a top-level object
* shape. Identity for already-clean schemas; `description`, `required`,
* `enum`, and `default` — what models actually read — are always preserved.
*/
export function normalizeWireToolSchema(schema) {
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
return { type: "object", properties: {} };
}
return ensureTypeField(collapseNullableVariants(inlineJsonSchema(stripSchemaAnnotations(schema))));
}
/**
* Check if a value is a Zod schema
*/
export function isZodSchema(value) {
return !!(value &&
typeof value === "object" &&
"_def" in value &&
typeof value.parse === "function");
}
/**
* Whether a schema was built by Zod 4 specifically.
*
* Zod 4 hangs its internals off `_zod` (and `z.toJSONSchema` reads
* `schema._zod.def`); Zod 3 has only `_def`. The two can coexist in one install
* — a host on Zod 3 still resolves NeuroLink's own Zod 4 — so the presence of
* `z.toJSONSchema` says nothing about the schema actually being handed to it.
*/
export function isZod4Schema(value) {
return !!(value && typeof value === "object" && "_zod" in value);
}
/**
* Convert JSON Schema to Zod schema format using official json-schema-to-zod library
* This ensures complete preservation of all schema structure and validation rules
*/
export function convertJsonSchemaToZod(jsonSchema) {
const startTime = Date.now();
try {
// Handle empty or invalid schemas
if (!jsonSchema || typeof jsonSchema !== "object") {
logger.debug("🔍 [SCHEMA-CONVERSION] Invalid or empty JSON schema, using fallback");
return z.object({}).passthrough();
}
// Log detailed input schema for debugging
logger.debug("🔍 [SCHEMA-CONVERSION] ===== STARTING OFFICIAL LIBRARY CONVERSION =====");
logger.debug("🔍 [SCHEMA-CONVERSION] Input JSON Schema:", {
type: jsonSchema.type,
hasProperties: !!jsonSchema.properties,
propertiesCount: jsonSchema.properties
? Object.keys(jsonSchema.properties).length
: 0,
requiredCount: Array.isArray(jsonSchema.required)
? jsonSchema.required.length
: 0,
required: jsonSchema.required,
sampleProperties: jsonSchema.properties
? Object.keys(jsonSchema.properties).slice(0, 5)
: [],
});
// Use official library to convert JSON Schema to Zod code
const convertToZodCode = loadJsonSchemaToZod();
if (!convertToZodCode) {
return z.object({}).passthrough();
}
const zodCodeResult = convertToZodCode(jsonSchema, {
module: "esm",
name: "schema",
});
logger.debug("🔍 [SCHEMA-CONVERSION] Generated Zod code:", {
codeLength: zodCodeResult.length,
codePreview: zodCodeResult.substring(0, 200) + "...",
});
// Extract the actual Zod schema expression from the generated code
// Generated code looks like: "import { z } from "zod"\n\nexport const schema = z.object({...})\n"
const schemaMatch = zodCodeResult.match(/export const schema = (z\..+?)(?:\n|$)/s);
if (!schemaMatch) {
throw new Error("Could not extract Zod schema from generated code");
}
const schemaExpression = schemaMatch[1].trim();
logger.debug("🔍 [SCHEMA-CONVERSION] Extracted schema expression:", {
expression: schemaExpression.substring(0, 300) + "...",
});
// Use Function constructor instead of eval for better scope control
const createZodSchema = new Function("z", `return ${schemaExpression}`);
const zodSchema = createZodSchema(z);
const conversionTime = Date.now() - startTime;
logger.debug("🔍 [SCHEMA-CONVERSION] ===== CONVERSION SUCCESSFUL =====", {
inputType: jsonSchema.type,
propertiesCount: jsonSchema.properties
? Object.keys(jsonSchema.properties).length
: 0,
requiredCount: Array.isArray(jsonSchema.required)
? jsonSchema.required.length
: 0,
conversionSuccess: true,
conversionTimeMs: conversionTime,
libraryUsed: "json-schema-to-zod-official",
zodSchemaType: zodSchema?.constructor?.name || "unknown",
});
return zodSchema;
}
catch (error) {
const conversionTime = Date.now() - startTime;
logger.warn("🚨 [SCHEMA-CONVERSION] Official library conversion failed, using passthrough fallback", {
error: error instanceof Error ? error.message : String(error),
errorType: error instanceof Error ? error.constructor.name : typeof error,
inputSchemaType: jsonSchema?.type,
inputSchemaKeys: jsonSchema && typeof jsonSchema === "object"
? Object.keys(jsonSchema)
: [],
conversionTimeMs: conversionTime,
libraryUsed: "json-schema-to-zod-official-FAILED",
});
return z.object({}).passthrough();
}
}