@juspay/neurolink
Version:
Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio
495 lines (494 loc) • 21.9 kB
JavaScript
import { zodToJsonSchema } from "zod-to-json-schema";
import { jsonSchemaToZod } from "json-schema-to-zod";
import * as zodModule from "zod";
import { z } from "zod";
import { logger } from "./logger.js";
// 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
const defs = definitions ||
schema.definitions;
// 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 (for #/definitions/SomeName)
if (refPath.startsWith("#/definitions/")) {
const defName = refPath.replace("#/definitions/", "");
// Check if it's a simple definition name (no slashes after definitions/)
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 definitions keys
if (key === "$ref" || key === "definitions") {
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(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) {
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
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.
const zodV3Schema = zodSchema;
const jsonSchema = zodToJsonSchema(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;
}
/**
* Check if a value is a Zod schema
*/
export function isZodSchema(value) {
return !!(value &&
typeof value === "object" &&
"_def" in value &&
typeof value.parse === "function");
}
/**
* 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 zodCodeResult = jsonSchemaToZod(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();
}
}