UNPKG

@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
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(); } }