🎛️ fix: Google JSON Schema Normalization/Resolution Logic (#11804)

- Updated `resolveJsonSchemaRefs` to prevent `` and `definitions` from appearing in the resolved output, ensuring compatibility with LLM APIs.
- Improved `normalizeJsonSchema` to strip vendor extension fields (e.g., `x-*` prefixed keys) and leftover ``/`definitions` blocks, enhancing schema normalization for Google/Gemini API.
- Added comprehensive tests to validate the stripping of ``, vendor extensions, and proper normalization across various schema structures.
This commit is contained in:
Danny Avila 2026-02-15 21:31:16 -05:00 committed by GitHub
parent 12f45c76ee
commit 2ea72a0f87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 190 additions and 7 deletions

View file

@ -1604,6 +1604,34 @@ describe('convertJsonSchemaToZod', () => {
expect(() => zodSchema?.parse(testData)).not.toThrow(); expect(() => zodSchema?.parse(testData)).not.toThrow();
}); });
it('should strip $defs from the resolved output', () => {
const schemaWithDefs = {
type: 'object' as const,
properties: {
item: { $ref: '#/$defs/Item' },
},
$defs: {
Item: {
type: 'object' as const,
properties: {
name: { type: 'string' as const },
},
},
},
};
const resolved = resolveJsonSchemaRefs(schemaWithDefs);
// $defs should NOT be in the output — it was only used for resolution
expect(resolved).not.toHaveProperty('$defs');
// The $ref should be resolved inline
expect(resolved.properties?.item).toEqual({
type: 'object',
properties: {
name: { type: 'string' },
},
});
});
it('should handle various edge cases safely', () => { it('should handle various edge cases safely', () => {
// Test with null/undefined // Test with null/undefined
expect(resolveJsonSchemaRefs(null as any)).toBeNull(); expect(resolveJsonSchemaRefs(null as any)).toBeNull();
@ -2192,4 +2220,144 @@ describe('normalizeJsonSchema', () => {
{ type: 'number', enum: [1] }, { type: 'number', enum: [1] },
]); ]);
}); });
it('should strip vendor extension fields (x-* prefixed keys)', () => {
const schema = {
type: 'object',
properties: {
travelMode: {
type: 'string',
enum: ['DRIVE', 'BICYCLE', 'TRANSIT', 'WALK'],
'x-google-enum-descriptions': ['By car', 'By bicycle', 'By public transit', 'By walking'],
description: 'Mode of travel',
},
},
} as any;
const result = normalizeJsonSchema(schema);
expect(result.properties.travelMode).toEqual({
type: 'string',
enum: ['DRIVE', 'BICYCLE', 'TRANSIT', 'WALK'],
description: 'Mode of travel',
});
expect(result.properties.travelMode).not.toHaveProperty('x-google-enum-descriptions');
});
it('should strip x-* fields at all nesting levels', () => {
const schema = {
type: 'object',
'x-custom-root': true,
properties: {
outer: {
type: 'object',
'x-custom-outer': 'value',
properties: {
inner: {
type: 'string',
'x-custom-inner': 42,
},
},
},
arr: {
type: 'array',
items: {
type: 'string',
'x-item-meta': 'something',
},
},
},
} as any;
const result = normalizeJsonSchema(schema);
expect(result).not.toHaveProperty('x-custom-root');
expect(result.properties.outer).not.toHaveProperty('x-custom-outer');
expect(result.properties.outer.properties.inner).not.toHaveProperty('x-custom-inner');
expect(result.properties.arr.items).not.toHaveProperty('x-item-meta');
// Standard fields should be preserved
expect(result.type).toBe('object');
expect(result.properties.outer.type).toBe('object');
expect(result.properties.outer.properties.inner.type).toBe('string');
expect(result.properties.arr.items.type).toBe('string');
});
it('should strip $defs and definitions as a safety net', () => {
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
},
$defs: {
SomeType: { type: 'string' },
},
} as any;
const result = normalizeJsonSchema(schema);
expect(result).not.toHaveProperty('$defs');
expect(result.type).toBe('object');
expect(result.properties.name).toEqual({ type: 'string' });
});
it('should strip x-* fields inside oneOf/anyOf/allOf', () => {
const schema = {
type: 'object',
oneOf: [
{ type: 'string', 'x-meta': 'a' },
{ type: 'number', 'x-meta': 'b' },
],
} as any;
const result = normalizeJsonSchema(schema);
expect(result.oneOf[0]).toEqual({ type: 'string' });
expect(result.oneOf[1]).toEqual({ type: 'number' });
});
it('should handle a Google Maps MCP-like schema with $defs and x-google-enum-descriptions', () => {
const schema = {
type: 'object',
properties: {
origin: { type: 'string', description: 'Starting address' },
destination: { type: 'string', description: 'Ending address' },
travelMode: {
type: 'string',
enum: ['DRIVE', 'BICYCLE', 'TRANSIT', 'WALK'],
'x-google-enum-descriptions': ['By car', 'By bicycle', 'By public transit', 'By walking'],
},
waypoints: {
type: 'array',
items: { $ref: '#/$defs/Waypoint' },
},
},
required: ['origin', 'destination'],
$defs: {
Waypoint: {
type: 'object',
properties: {
location: { type: 'string' },
stopover: { type: 'boolean' },
},
},
},
} as any;
// First resolve refs, then normalize
const resolved = resolveJsonSchemaRefs(schema);
const result = normalizeJsonSchema(resolved);
// $defs should be stripped (by both resolveJsonSchemaRefs and normalizeJsonSchema)
expect(result).not.toHaveProperty('$defs');
// x-google-enum-descriptions should be stripped
expect(result.properties.travelMode).not.toHaveProperty('x-google-enum-descriptions');
// $ref should be resolved inline
expect(result.properties.waypoints.items).not.toHaveProperty('$ref');
expect(result.properties.waypoints.items).toEqual({
type: 'object',
properties: {
location: { type: 'string' },
stopover: { type: 'boolean' },
},
});
// Standard fields preserved
expect(result.properties.travelMode.enum).toEqual(['DRIVE', 'BICYCLE', 'TRANSIT', 'WALK']);
expect(result.properties.origin).toEqual({ type: 'string', description: 'Starting address' });
});
}); });

View file

@ -203,9 +203,9 @@ export function resolveJsonSchemaRefs<T extends Record<string, unknown>>(
const result: Record<string, unknown> = {}; const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(schema)) { for (const [key, value] of Object.entries(schema)) {
// Skip $defs/definitions at root level to avoid infinite recursion // Skip $defs/definitions — they are only used for resolving $ref and
if ((key === '$defs' || key === 'definitions') && !visited.size) { // should not appear in the resolved output (e.g. Google/Gemini API rejects them).
result[key] = value; if (key === '$defs' || key === 'definitions') {
continue; continue;
} }
@ -249,12 +249,15 @@ export function resolveJsonSchemaRefs<T extends Record<string, unknown>>(
} }
/** /**
* Recursively normalizes a JSON schema by converting `const` values to `enum` arrays. * Recursively normalizes a JSON schema for LLM API compatibility.
* Gemini/Vertex AI does not support the `const` keyword in function declarations, *
* but `const: X` is semantically equivalent to `enum: [X]` per the JSON Schema spec. * Transformations applied:
* - Converts `const` values to `enum` arrays (Gemini/Vertex AI rejects `const`)
* - Strips vendor extension fields (`x-*` prefixed keys, e.g. `x-google-enum-descriptions`)
* - Strips leftover `$defs`/`definitions` blocks that may survive ref resolution
* *
* @param schema - The JSON schema to normalize * @param schema - The JSON schema to normalize
* @returns The normalized schema with `const` converted to `enum` * @returns The normalized schema
*/ */
export function normalizeJsonSchema<T extends Record<string, unknown>>(schema: T): T { export function normalizeJsonSchema<T extends Record<string, unknown>>(schema: T): T {
if (!schema || typeof schema !== 'object') { if (!schema || typeof schema !== 'object') {
@ -270,6 +273,18 @@ export function normalizeJsonSchema<T extends Record<string, unknown>>(schema: T
const result: Record<string, unknown> = {}; const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(schema)) { for (const [key, value] of Object.entries(schema)) {
// Strip vendor extension fields (e.g. x-google-enum-descriptions) —
// these are valid in JSON Schema but rejected by Google/Gemini API.
if (key.startsWith('x-')) {
continue;
}
// Strip leftover $defs/definitions (should already be resolved by resolveJsonSchemaRefs,
// but strip as a safety net for schemas that bypass ref resolution).
if (key === '$defs' || key === 'definitions') {
continue;
}
if (key === 'const' && !('enum' in schema)) { if (key === 'const' && !('enum' in schema)) {
result['enum'] = [value]; result['enum'] = [value];
continue; continue;