mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-16 23:48:09 +01:00
🎛️ 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:
parent
12f45c76ee
commit
2ea72a0f87
2 changed files with 190 additions and 7 deletions
|
|
@ -1604,6 +1604,34 @@ describe('convertJsonSchemaToZod', () => {
|
|||
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', () => {
|
||||
// Test with null/undefined
|
||||
expect(resolveJsonSchemaRefs(null as any)).toBeNull();
|
||||
|
|
@ -2192,4 +2220,144 @@ describe('normalizeJsonSchema', () => {
|
|||
{ 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' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -203,9 +203,9 @@ export function resolveJsonSchemaRefs<T extends Record<string, unknown>>(
|
|||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(schema)) {
|
||||
// Skip $defs/definitions at root level to avoid infinite recursion
|
||||
if ((key === '$defs' || key === 'definitions') && !visited.size) {
|
||||
result[key] = value;
|
||||
// Skip $defs/definitions — they are only used for resolving $ref and
|
||||
// should not appear in the resolved output (e.g. Google/Gemini API rejects them).
|
||||
if (key === '$defs' || key === 'definitions') {
|
||||
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.
|
||||
* 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.
|
||||
* Recursively normalizes a JSON schema for LLM API compatibility.
|
||||
*
|
||||
* 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
|
||||
* @returns The normalized schema with `const` converted to `enum`
|
||||
* @returns The normalized schema
|
||||
*/
|
||||
export function normalizeJsonSchema<T extends Record<string, unknown>>(schema: T): T {
|
||||
if (!schema || typeof schema !== 'object') {
|
||||
|
|
@ -270,6 +273,18 @@ export function normalizeJsonSchema<T extends Record<string, unknown>>(schema: T
|
|||
const result: Record<string, unknown> = {};
|
||||
|
||||
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)) {
|
||||
result['enum'] = [value];
|
||||
continue;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue