/** * Schema Sanitizer * Cleans and transforms JSON schemas for Gemini/Antigravity API compatibility * * Uses a multi-phase pipeline matching opencode-antigravity-auth approach: * - Phase 1: Convert $refs to description hints * - Phase 2a: Merge allOf schemas * - Phase 2b: Flatten anyOf/oneOf (select best option) * - Phase 2c: Flatten type arrays + update required for nullable * - Phase 3: Remove unsupported keywords * - Phase 4: Final cleanup (required array validation) */ /** * Append a hint to a schema's description field. * Format: "existing (hint)" or just "hint" if no existing description. * * @param {Object} schema - Schema object to modify * @param {string} hint - Hint text to append * @returns {Object} Modified schema with appended description */ function appendDescriptionHint(schema, hint) { if (!schema || typeof schema !== 'object') return schema; const result = { ...schema }; result.description = result.description ? `${result.description} (${hint})` : hint; return result; } /** * Score a schema option for anyOf/oneOf selection. * Higher scores = more preferred schemas. * * @param {Object} schema - Schema option to score * @returns {number} Score (0-3) */ function scoreSchemaOption(schema) { if (!schema || typeof schema !== 'object') return 0; // Score 3: Object types with properties (most informative) if (schema.type === 'object' || schema.properties) return 3; // Score 2: Array types with items if (schema.type === 'array' || schema.items) return 2; // Score 1: Any other non-null type if (schema.type && schema.type !== 'null') return 1; // Score 0: Null or no type return 0; } /** * Convert $ref references to description hints. * Replaces { $ref: "#/$defs/Foo" } with { type: "object", description: "See: Foo" } * * @param {Object} schema - Schema to process * @returns {Object} Schema with refs converted to hints */ function convertRefsToHints(schema) { if (!schema || typeof schema !== 'object') return schema; if (Array.isArray(schema)) return schema.map(convertRefsToHints); const result = { ...schema }; // Handle $ref at this level if (result.$ref && typeof result.$ref === 'string') { // Extract definition name from ref path (e.g., "#/$defs/Foo" -> "Foo") const parts = result.$ref.split('/'); const defName = parts[parts.length - 1] || 'unknown'; const hint = `See: ${defName}`; // Merge with existing description if present const description = result.description ? `${result.description} (${hint})` : hint; // Replace with object type and hint return { type: 'object', description }; } // Recursively process properties if (result.properties && typeof result.properties === 'object') { result.properties = {}; for (const [key, value] of Object.entries(schema.properties)) { result.properties[key] = convertRefsToHints(value); } } // Recursively process items if (result.items) { if (Array.isArray(result.items)) { result.items = result.items.map(convertRefsToHints); } else if (typeof result.items === 'object') { result.items = convertRefsToHints(result.items); } } // Recursively process anyOf/oneOf/allOf for (const key of ['anyOf', 'oneOf', 'allOf']) { if (Array.isArray(result[key])) { result[key] = result[key].map(convertRefsToHints); } } return result; } /** * Merge all schemas in an allOf array into a single schema. * Properties and required arrays are merged; other fields use first occurrence. * * @param {Object} schema - Schema with potential allOf to merge * @returns {Object} Schema with allOf merged */ function mergeAllOf(schema) { if (!schema || typeof schema !== 'object') return schema; if (Array.isArray(schema)) return schema.map(mergeAllOf); let result = { ...schema }; // Process allOf if present if (Array.isArray(result.allOf) && result.allOf.length > 0) { const mergedProperties = {}; const mergedRequired = new Set(); const otherFields = {}; for (const subSchema of result.allOf) { if (!subSchema || typeof subSchema !== 'object') continue; // Merge properties (later overrides earlier) if (subSchema.properties) { for (const [key, value] of Object.entries(subSchema.properties)) { mergedProperties[key] = value; } } // Union required arrays if (Array.isArray(subSchema.required)) { for (const req of subSchema.required) { mergedRequired.add(req); } } // Copy other fields (first occurrence wins) for (const [key, value] of Object.entries(subSchema)) { if (key !== 'properties' && key !== 'required' && !(key in otherFields)) { otherFields[key] = value; } } } // Apply merged content delete result.allOf; // Merge other fields first (parent takes precedence) for (const [key, value] of Object.entries(otherFields)) { if (!(key in result)) { result[key] = value; } } // Merge properties (allOf properties override parent for same keys) if (Object.keys(mergedProperties).length > 0) { result.properties = { ...mergedProperties, ...(result.properties || {}) }; } // Merge required if (mergedRequired.size > 0) { const parentRequired = Array.isArray(result.required) ? result.required : []; result.required = [...new Set([...mergedRequired, ...parentRequired])]; } } // Recursively process properties if (result.properties && typeof result.properties === 'object') { const newProps = {}; for (const [key, value] of Object.entries(result.properties)) { newProps[key] = mergeAllOf(value); } result.properties = newProps; } // Recursively process items if (result.items) { if (Array.isArray(result.items)) { result.items = result.items.map(mergeAllOf); } else if (typeof result.items === 'object') { result.items = mergeAllOf(result.items); } } return result; } /** * Flatten anyOf/oneOf by selecting the best option based on scoring. * Adds type hints to description when multiple types existed. * * @param {Object} schema - Schema with potential anyOf/oneOf * @returns {Object} Flattened schema */ function flattenAnyOfOneOf(schema) { if (!schema || typeof schema !== 'object') return schema; if (Array.isArray(schema)) return schema.map(flattenAnyOfOneOf); let result = { ...schema }; // Handle anyOf or oneOf for (const unionKey of ['anyOf', 'oneOf']) { if (Array.isArray(result[unionKey]) && result[unionKey].length > 0) { const options = result[unionKey]; // Collect type names for hint const typeNames = []; let bestOption = null; let bestScore = -1; for (const option of options) { if (!option || typeof option !== 'object') continue; // Collect type name const typeName = option.type || (option.properties ? 'object' : null); if (typeName && typeName !== 'null') { typeNames.push(typeName); } // Score and track best option const score = scoreSchemaOption(option); if (score > bestScore) { bestScore = score; bestOption = option; } } // Remove the union key delete result[unionKey]; // Merge best option into result if (bestOption) { // Preserve parent description const parentDescription = result.description; // Recursively flatten the best option const flattenedOption = flattenAnyOfOneOf(bestOption); // Merge fields from selected option for (const [key, value] of Object.entries(flattenedOption)) { if (key === 'description') { // Merge descriptions if different if (value && value !== parentDescription) { result.description = parentDescription ? `${parentDescription} (${value})` : value; } } else if (!(key in result) || key === 'type' || key === 'properties' || key === 'items') { result[key] = value; } } // Add type hint if multiple types existed if (typeNames.length > 1) { const uniqueTypes = [...new Set(typeNames)]; result = appendDescriptionHint(result, `Accepts: ${uniqueTypes.join(' | ')}`); } } } } // Recursively process properties if (result.properties && typeof result.properties === 'object') { const newProps = {}; for (const [key, value] of Object.entries(result.properties)) { newProps[key] = flattenAnyOfOneOf(value); } result.properties = newProps; } // Recursively process items if (result.items) { if (Array.isArray(result.items)) { result.items = result.items.map(flattenAnyOfOneOf); } else if (typeof result.items === 'object') { result.items = flattenAnyOfOneOf(result.items); } } return result; } // ============================================================================ // Enhanced Schema Hints (for preserving semantic information) // ============================================================================ /** * Add hints for enum values (if ≤10 values). * This preserves enum information in the description since Gemini * may not fully support enums in all cases. * * @param {Object} schema - Schema to process * @returns {Object} Schema with enum hints added to description */ function addEnumHints(schema) { if (!schema || typeof schema !== 'object') return schema; if (Array.isArray(schema)) return schema.map(addEnumHints); let result = { ...schema }; // Add enum hint if present and reasonable size if (Array.isArray(result.enum) && result.enum.length > 1 && result.enum.length <= 10) { const vals = result.enum.map(v => String(v)).join(', '); result = appendDescriptionHint(result, `Allowed: ${vals}`); } // Recursively process properties if (result.properties && typeof result.properties === 'object') { const newProps = {}; for (const [key, value] of Object.entries(result.properties)) { newProps[key] = addEnumHints(value); } result.properties = newProps; } // Recursively process items if (result.items) { result.items = Array.isArray(result.items) ? result.items.map(addEnumHints) : addEnumHints(result.items); } return result; } /** * Add hints for additionalProperties: false. * This informs the model that extra properties are not allowed. * * @param {Object} schema - Schema to process * @returns {Object} Schema with additionalProperties hints added */ function addAdditionalPropertiesHints(schema) { if (!schema || typeof schema !== 'object') return schema; if (Array.isArray(schema)) return schema.map(addAdditionalPropertiesHints); let result = { ...schema }; if (result.additionalProperties === false) { result = appendDescriptionHint(result, 'No extra properties allowed'); } // Recursively process properties if (result.properties && typeof result.properties === 'object') { const newProps = {}; for (const [key, value] of Object.entries(result.properties)) { newProps[key] = addAdditionalPropertiesHints(value); } result.properties = newProps; } // Recursively process items if (result.items) { result.items = Array.isArray(result.items) ? result.items.map(addAdditionalPropertiesHints) : addAdditionalPropertiesHints(result.items); } return result; } /** * Move unsupported constraints to description hints. * This preserves constraint information that would otherwise be lost * when we strip unsupported keywords. * * @param {Object} schema - Schema to process * @returns {Object} Schema with constraint hints added to description */ function moveConstraintsToDescription(schema) { if (!schema || typeof schema !== 'object') return schema; if (Array.isArray(schema)) return schema.map(moveConstraintsToDescription); const CONSTRAINTS = ['minLength', 'maxLength', 'pattern', 'minimum', 'maximum', 'minItems', 'maxItems', 'format']; let result = { ...schema }; for (const constraint of CONSTRAINTS) { if (result[constraint] !== undefined && typeof result[constraint] !== 'object') { result = appendDescriptionHint(result, `${constraint}: ${result[constraint]}`); } } // Recursively process properties if (result.properties && typeof result.properties === 'object') { const newProps = {}; for (const [key, value] of Object.entries(result.properties)) { newProps[key] = moveConstraintsToDescription(value); } result.properties = newProps; } // Recursively process items if (result.items) { result.items = Array.isArray(result.items) ? result.items.map(moveConstraintsToDescription) : moveConstraintsToDescription(result.items); } return result; } /** * Flatten array type fields and track nullable properties. * Converts { type: ["string", "null"] } to { type: "string" } with nullable hint. * * @param {Object} schema - Schema to process * @param {Set} nullableProps - Set to collect nullable property names (mutated) * @param {string} currentPropName - Current property name (for tracking) * @returns {Object} Flattened schema */ function flattenTypeArrays(schema, nullableProps = null, currentPropName = null) { if (!schema || typeof schema !== 'object') return schema; if (Array.isArray(schema)) return schema.map(s => flattenTypeArrays(s, nullableProps)); let result = { ...schema }; // Handle array type fields if (Array.isArray(result.type)) { const types = result.type; const hasNull = types.includes('null'); const nonNullTypes = types.filter(t => t !== 'null' && t); // Select first non-null type, or 'string' as fallback const firstType = nonNullTypes.length > 0 ? nonNullTypes[0] : 'string'; result.type = firstType; // Add hint for multiple types if (nonNullTypes.length > 1) { result = appendDescriptionHint(result, `Accepts: ${nonNullTypes.join(' | ')}`); } // Track nullable and add hint if (hasNull) { result = appendDescriptionHint(result, 'nullable'); // Track this property as nullable for required array update if (nullableProps && currentPropName) { nullableProps.add(currentPropName); } } } // Recursively process properties, tracking nullable ones if (result.properties && typeof result.properties === 'object') { const childNullableProps = new Set(); const newProps = {}; for (const [key, value] of Object.entries(result.properties)) { newProps[key] = flattenTypeArrays(value, childNullableProps, key); } result.properties = newProps; // Remove nullable properties from required array if (Array.isArray(result.required) && childNullableProps.size > 0) { result.required = result.required.filter(prop => !childNullableProps.has(prop)); if (result.required.length === 0) { delete result.required; } } } // Recursively process items if (result.items) { if (Array.isArray(result.items)) { result.items = result.items.map(item => flattenTypeArrays(item, nullableProps)); } else if (typeof result.items === 'object') { result.items = flattenTypeArrays(result.items, nullableProps); } } return result; } /** * Sanitize JSON Schema for Antigravity API compatibility. * Uses allowlist approach - only permit known-safe JSON Schema features. * Converts "const" to equivalent "enum" for compatibility. * Generates placeholder schema for empty tool schemas. */ export function sanitizeSchema(schema) { if (!schema || typeof schema !== 'object') { // Empty/missing schema - generate placeholder with reason property return { type: 'object', properties: { reason: { type: 'string', description: 'Reason for calling this tool' } }, required: ['reason'] }; } // Allowlist of permitted JSON Schema fields const ALLOWED_FIELDS = new Set([ 'type', 'description', 'properties', 'required', 'items', 'enum', 'title' ]); const sanitized = {}; for (const [key, value] of Object.entries(schema)) { // Convert "const" to "enum" for compatibility if (key === 'const') { sanitized.enum = [value]; continue; } // Skip fields not in allowlist if (!ALLOWED_FIELDS.has(key)) { continue; } if (key === 'properties' && value && typeof value === 'object') { sanitized.properties = {}; for (const [propKey, propValue] of Object.entries(value)) { sanitized.properties[propKey] = sanitizeSchema(propValue); } } else if (key === 'items' && value && typeof value === 'object') { if (Array.isArray(value)) { sanitized.items = value.map(item => sanitizeSchema(item)); } else { sanitized.items = sanitizeSchema(value); } } else if (typeof value === 'object' && value !== null && !Array.isArray(value)) { sanitized[key] = sanitizeSchema(value); } else { sanitized[key] = value; } } // Ensure we have at least a type if (!sanitized.type) { sanitized.type = 'object'; } // If object type with no properties, add placeholder if (sanitized.type === 'object' && (!sanitized.properties || Object.keys(sanitized.properties).length === 0)) { sanitized.properties = { reason: { type: 'string', description: 'Reason for calling this tool' } }; sanitized.required = ['reason']; } return sanitized; } /** * Convert JSON Schema type names to Google's Protobuf-style uppercase type names. * Google's Generative AI API expects uppercase types: STRING, OBJECT, ARRAY, etc. * * @param {string} type - JSON Schema type name (lowercase) * @returns {string} Google-format type name (uppercase) */ function toGoogleType(type) { if (!type || typeof type !== 'string') return type; const typeMap = { 'string': 'STRING', 'number': 'NUMBER', 'integer': 'INTEGER', 'boolean': 'BOOLEAN', 'array': 'ARRAY', 'object': 'OBJECT', 'null': 'STRING' // Fallback for null type }; return typeMap[type.toLowerCase()] || type.toUpperCase(); } /** * Cleans JSON schema for Gemini API compatibility. * Uses a multi-phase pipeline matching opencode-antigravity-auth approach. * * @param {Object} schema - The JSON schema to clean * @returns {Object} Cleaned schema safe for Gemini API */ export function cleanSchema(schema) { if (!schema || typeof schema !== 'object') return schema; if (Array.isArray(schema)) return schema.map(cleanSchema); // Phase 1: Convert $refs to hints let result = convertRefsToHints(schema); // Phase 1b: Add enum hints (preserves enum info in description) result = addEnumHints(result); // Phase 1c: Add additionalProperties hints result = addAdditionalPropertiesHints(result); // Phase 1d: Move constraints to description (before they get stripped) result = moveConstraintsToDescription(result); // Phase 2a: Merge allOf schemas result = mergeAllOf(result); // Phase 2b: Flatten anyOf/oneOf result = flattenAnyOfOneOf(result); // Phase 2c: Flatten type arrays and update required for nullable result = flattenTypeArrays(result); // Phase 3: Remove unsupported keywords const unsupported = [ 'additionalProperties', 'default', '$schema', '$defs', 'definitions', '$ref', '$id', '$comment', 'title', 'minLength', 'maxLength', 'pattern', 'format', 'minItems', 'maxItems', 'examples', 'allOf', 'anyOf', 'oneOf' ]; for (const key of unsupported) { delete result[key]; } // Check for unsupported 'format' in string types if (result.type === 'string' && result.format) { const allowed = ['enum', 'date-time']; if (!allowed.includes(result.format)) { delete result.format; } } // Phase 4: Final cleanup - recursively clean nested schemas and validate required if (result.properties && typeof result.properties === 'object') { const newProps = {}; for (const [key, value] of Object.entries(result.properties)) { newProps[key] = cleanSchema(value); } result.properties = newProps; } if (result.items) { if (Array.isArray(result.items)) { result.items = result.items.map(cleanSchema); } else if (typeof result.items === 'object') { result.items = cleanSchema(result.items); } } // Validate that required array only contains properties that exist if (result.required && Array.isArray(result.required) && result.properties) { const definedProps = new Set(Object.keys(result.properties)); result.required = result.required.filter(prop => definedProps.has(prop)); if (result.required.length === 0) { delete result.required; } } // Phase 5: Convert type to Google's uppercase format (STRING, OBJECT, ARRAY, etc.) // Only convert at current level - nested types already converted by recursive cleanSchema calls if (result.type && typeof result.type === 'string') { result.type = toGoogleType(result.type); } return result; }