mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
♊ fix: Strip remaining unsupported JSON Schema keywords for Gemini MCP tools (#13850)
* ♊ fix: Strip remaining unsupported JSON Schema keywords for Gemini MCP tools Gemini's FunctionDeclaration.parameters schema rejects more JSON Schema keywords than sanitizeGeminiSchema previously stripped. MCP tools shipping examples/readOnly/multipleOf/uniqueItems/prefixItems/etc. still 400 with `Unknown name "<key>"`, the same class as #13623 (exclusiveMinimum). Verified live against gemini-2.5-flash and gemini-3.5-flash: each added keyword is rejected through `parameters`, and @langchain/google-genai only removes additionalProperties/$schema, so they must be stripped here. * ♊ refactor: Make Gemini strip-list fully live-verified; preserve `default` Probed every candidate keyword against both the live Gemini API (gemini-2.5-flash, gemini-3.5-flash) and Vertex AI. Confirmed the inferred siblings (dependencies/dependentSchemas/contentSchema) are rejected, so they stay. Dropped `default`: it is part of Gemini's Schema and is accepted by both the Gemini API and Vertex (no documented reason for its removal in #13623), so it is now preserved instead of stripped. * ♊ fix: Preserve `default` data and synthesize array `items` (Codex P2s) Addresses two Codex findings on the strip-list rework: - `default` is now copied verbatim instead of recursed, so object/array default values (e.g. `{ id: 'abc', readOnly: true }`) keep ordinary data keys that the schema-recursion would otherwise strip. - `prefixItems` is dropped but its first member is synthesized into `items`, since Gemini's API requires `items` on every array (live: itemless array => 400; the synthesized `{type:array, items:{...}}` => 200 on Gemini 2.5/3.5 and Vertex). Third finding (patternProperties -> empty object) not actioned: live probing shows `{type:'object'}` with no properties is accepted by both the Gemini API and Vertex. * ♊ fix: Treat boolean/tuple array `items` as missing (Codex P2) The Draft 2020 tuple form `prefixItems: [...], items: false` slipped through: the `'items' in collapsed` check treated boolean `false` as a real item schema, so no fallback was synthesized and `items: false` was emitted — which Gemini rejects (live: `items: false`/`true` => 400 "Invalid value"). Now `items` is only kept when it is a schema object; boolean and tuple-array (`items: [...]`) forms are dropped, a `prefixItems` member is synthesized when present, and any array still missing `items` falls back to `{}` (verified accepted by the Gemini API and Vertex). Adds an `isObjectSchema` guard + tests.
This commit is contained in:
parent
36ae268620
commit
8969034ad1
2 changed files with 279 additions and 5 deletions
|
|
@ -2636,7 +2636,7 @@ describe('sanitizeGeminiSchema', () => {
|
|||
expect(sanitizeGeminiSchema(schema)).toEqual({ type: 'string', enum: ['a', 'b'] });
|
||||
});
|
||||
|
||||
it('strips unsupported keywords (additionalProperties, default, $schema)', () => {
|
||||
it('strips unsupported keywords (additionalProperties, $schema) but keeps Gemini-supported default', () => {
|
||||
const schema = {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
type: 'object',
|
||||
|
|
@ -2649,7 +2649,7 @@ describe('sanitizeGeminiSchema', () => {
|
|||
const result = sanitizeGeminiSchema(schema);
|
||||
expect(result).not.toHaveProperty('$schema');
|
||||
expect(result).not.toHaveProperty('additionalProperties');
|
||||
expect(result.properties.name).toEqual({ type: 'string' });
|
||||
expect(result.properties.name).toEqual({ type: 'string', default: 'anon' });
|
||||
});
|
||||
|
||||
it('folds exclusive bounds into inclusive minimum/maximum', () => {
|
||||
|
|
@ -2710,4 +2710,201 @@ describe('sanitizeGeminiSchema', () => {
|
|||
const schema = { type: 'integer', enum: [1, 2, 3] } as any;
|
||||
expect(sanitizeGeminiSchema(schema)).toEqual({ type: 'integer' });
|
||||
});
|
||||
|
||||
it('strips annotation keywords Gemini rejects (examples, readOnly, writeOnly, deprecated, $comment)', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
token: {
|
||||
type: 'string',
|
||||
examples: ['abc'],
|
||||
readOnly: true,
|
||||
writeOnly: false,
|
||||
deprecated: true,
|
||||
$comment: 'internal',
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
expect(sanitizeGeminiSchema(schema)).toEqual({
|
||||
type: 'object',
|
||||
properties: { token: { type: 'string' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('strips numeric/array validators Gemini rejects (multipleOf, uniqueItems)', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: { type: 'integer', multipleOf: 2 },
|
||||
tags: { type: 'array', items: { type: 'string' }, uniqueItems: true },
|
||||
},
|
||||
} as any;
|
||||
expect(sanitizeGeminiSchema(schema)).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: { type: 'integer' },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('strips object-composition keywords Gemini rejects (patternProperties, propertyNames, dependentRequired)', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: { a: { type: 'string' } },
|
||||
patternProperties: { '^x': { type: 'string' } },
|
||||
propertyNames: { pattern: '^[a-z]+$' },
|
||||
dependentRequired: { a: ['b'] },
|
||||
} as any;
|
||||
expect(sanitizeGeminiSchema(schema)).toEqual({
|
||||
type: 'object',
|
||||
properties: { a: { type: 'string' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('strips tuple/array-extension keywords Gemini rejects (prefixItems, additionalItems)', () => {
|
||||
const schema = {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
prefixItems: [{ type: 'string' }, { type: 'number' }],
|
||||
additionalItems: false,
|
||||
} as any;
|
||||
expect(sanitizeGeminiSchema(schema)).toEqual({
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
});
|
||||
});
|
||||
|
||||
it('synthesizes `items` from `prefixItems` when a tuple array has none (Gemini requires items)', () => {
|
||||
const schema = {
|
||||
type: 'array',
|
||||
prefixItems: [{ type: 'string', readOnly: true }, { type: 'number' }],
|
||||
} as any;
|
||||
expect(sanitizeGeminiSchema(schema)).toEqual({
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves an existing `items` over a `prefixItems`-derived one', () => {
|
||||
const schema = {
|
||||
type: 'array',
|
||||
items: { type: 'boolean' },
|
||||
prefixItems: [{ type: 'string' }],
|
||||
} as any;
|
||||
expect(sanitizeGeminiSchema(schema)).toEqual({
|
||||
type: 'array',
|
||||
items: { type: 'boolean' },
|
||||
});
|
||||
});
|
||||
|
||||
it('synthesizes `items` from `prefixItems` when `items` is boolean false (Draft 2020 tuple)', () => {
|
||||
const schema = {
|
||||
type: 'array',
|
||||
prefixItems: [{ type: 'number' }],
|
||||
items: false,
|
||||
} as any;
|
||||
expect(sanitizeGeminiSchema(schema)).toEqual({
|
||||
type: 'array',
|
||||
items: { type: 'number' },
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to an empty `items` schema for a boolean `items` with no prefixItems', () => {
|
||||
expect(sanitizeGeminiSchema({ type: 'array', items: false } as any)).toEqual({
|
||||
type: 'array',
|
||||
items: {},
|
||||
});
|
||||
expect(sanitizeGeminiSchema({ type: 'array', items: true } as any)).toEqual({
|
||||
type: 'array',
|
||||
items: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to an empty `items` schema for an array missing items entirely', () => {
|
||||
expect(sanitizeGeminiSchema({ type: 'array' } as any)).toEqual({
|
||||
type: 'array',
|
||||
items: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a tuple-array `items: [...]` form and falls back rather than emit an array items', () => {
|
||||
const schema = { type: 'array', items: [{ type: 'string' }, { type: 'number' }] } as any;
|
||||
expect(sanitizeGeminiSchema(schema)).toEqual({ type: 'array', items: {} });
|
||||
});
|
||||
|
||||
it('preserves an object `default` verbatim without sanitizing its data keys', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
cfg: {
|
||||
type: 'object',
|
||||
default: { id: 'abc', readOnly: true, deprecated: false, nested: { id: 'x' } },
|
||||
properties: { id: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const result = sanitizeGeminiSchema(schema);
|
||||
expect(result.properties.cfg.default).toEqual({
|
||||
id: 'abc',
|
||||
readOnly: true,
|
||||
deprecated: false,
|
||||
nested: { id: 'x' },
|
||||
});
|
||||
expect(result.properties.cfg.properties).toEqual({ id: { type: 'string' } });
|
||||
});
|
||||
|
||||
it('preserves an array `default` verbatim', () => {
|
||||
const schema = {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
default: ['id', 'readOnly'],
|
||||
} as any;
|
||||
expect(sanitizeGeminiSchema(schema)).toEqual({
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
default: ['id', 'readOnly'],
|
||||
});
|
||||
});
|
||||
|
||||
it('strips content keywords and the bare `id` alias Gemini rejects', () => {
|
||||
const schema = {
|
||||
id: 'urn:example',
|
||||
type: 'object',
|
||||
properties: {
|
||||
blob: { type: 'string', contentEncoding: 'base64', contentMediaType: 'image/png' },
|
||||
},
|
||||
} as any;
|
||||
expect(sanitizeGeminiSchema(schema)).toEqual({
|
||||
type: 'object',
|
||||
properties: { blob: { type: 'string' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('strips deeply nested unsupported keywords through arrays, items, and properties', () => {
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
ranges: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { score: { type: 'number', exclusiveMinimum: 0, multipleOf: 0.5 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
expect(sanitizeGeminiSchema(schema)).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
ranges: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: { score: { type: 'number', minimum: 0 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -352,9 +352,40 @@ function mergeRequired(a: unknown, b: unknown): string[] | undefined {
|
|||
|
||||
/**
|
||||
* JSON Schema keywords absent from Gemini's function-calling Schema subset
|
||||
* (https://ai.google.dev/api/caching#Schema); they trigger 400s and are stripped.
|
||||
* (https://ai.google.dev/api/caching#Schema); they trigger `Unknown name "<key>"`
|
||||
* 400s and are stripped. Every entry below was verified to be rejected through
|
||||
* `FunctionDeclaration.parameters` against the live Gemini API (`gemini-2.5-flash`,
|
||||
* `gemini-3.5-flash`) and/or Vertex AI — e.g. `additionalProperties` is rejected
|
||||
* only by the Gemini API but accepted by Vertex, so the union is stripped for both.
|
||||
* `@langchain/google-genai` only removes `additionalProperties`/`$schema`, so the
|
||||
* rest must be stripped here.
|
||||
*
|
||||
* Not listed (handled elsewhere in `sanitizeGeminiSchema`): `default` is preserved
|
||||
* (part of Gemini's Schema, accepted live by both endpoints); `prefixItems` is
|
||||
* dropped but synthesized into `items` so the array keeps a required element schema.
|
||||
*/
|
||||
const GEMINI_UNSUPPORTED_KEYS = new Set(['additionalProperties', 'default', '$schema', '$id']);
|
||||
const GEMINI_UNSUPPORTED_KEYS = new Set([
|
||||
'additionalProperties',
|
||||
'$schema',
|
||||
'$id',
|
||||
'id',
|
||||
'$comment',
|
||||
'examples',
|
||||
'readOnly',
|
||||
'writeOnly',
|
||||
'deprecated',
|
||||
'multipleOf',
|
||||
'uniqueItems',
|
||||
'additionalItems',
|
||||
'propertyNames',
|
||||
'patternProperties',
|
||||
'dependencies',
|
||||
'dependentRequired',
|
||||
'dependentSchemas',
|
||||
'contentEncoding',
|
||||
'contentMediaType',
|
||||
'contentSchema',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Merges the members of an `allOf` (schema intersection) into the parent: combines
|
||||
|
|
@ -441,6 +472,11 @@ function collapseSchemaUnion(schema: Record<string, unknown>): Record<string, un
|
|||
return current;
|
||||
}
|
||||
|
||||
/** True when the value is a usable JSON Schema object (not a boolean or array). */
|
||||
function isObjectSchema(value: unknown): value is Record<string, unknown> {
|
||||
return value != null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses a multi-entry `type` array (e.g. `['string', 'null']`) into a single
|
||||
* type, reporting whether a `null` entry made the field nullable.
|
||||
|
|
@ -470,7 +506,9 @@ function collapseTypeArray(types: unknown[]): { type?: string; nullable: boolean
|
|||
* drops the keyword entirely for non-string types (e.g. a boolean `const`
|
||||
* normalized to `enum: [true]`).
|
||||
* - Folds `exclusiveMinimum`/`exclusiveMaximum` into `minimum`/`maximum`.
|
||||
* - Strips unsupported keywords (`additionalProperties`, `default`, `const`, `$schema`, `$id`).
|
||||
* - Strips `const` (after enum conversion) and every keyword in `GEMINI_UNSUPPORTED_KEYS`
|
||||
* (`additionalProperties`, `examples`, `readOnly`, `multipleOf`, `uniqueItems`,
|
||||
* `patternProperties`, `prefixItems`, etc.) that the Gemini schema validator rejects.
|
||||
*
|
||||
* @param schema - The JSON schema to sanitize
|
||||
* @returns The Gemini-compatible schema
|
||||
|
|
@ -517,6 +555,38 @@ export function sanitizeGeminiSchema<T extends Record<string, unknown>>(schema:
|
|||
continue;
|
||||
}
|
||||
|
||||
// `default` holds a literal data value (Gemini-supported), not a subschema —
|
||||
// copy it verbatim so object/array defaults aren't recursively sanitized
|
||||
// (which would strip ordinary data keys like `id`/`readOnly`).
|
||||
if (key === 'default') {
|
||||
result['default'] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gemini has no tuple validation, so drop `prefixItems`; but Gemini requires
|
||||
// `items` to be a schema object on every array, so synthesize one from the
|
||||
// first tuple member unless a real object `items` is already present (a
|
||||
// boolean `items: false` does not count).
|
||||
if (key === 'prefixItems') {
|
||||
if (!isObjectSchema(collapsed.items) && Array.isArray(value)) {
|
||||
const first = value.find((member) => member && typeof member === 'object');
|
||||
if (first) {
|
||||
result['items'] = sanitizeGeminiSchema(first as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gemini requires `items` to be a schema object; drop the boolean
|
||||
// (`items: false`) and tuple-array (`items: [...]`) forms — a
|
||||
// `prefixItems`-derived or empty fallback is emitted instead.
|
||||
if (key === 'items') {
|
||||
if (isObjectSchema(value)) {
|
||||
result['items'] = sanitizeGeminiSchema(value as Record<string, unknown>);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gemini has no `const`; a string const becomes a single-value (string) enum,
|
||||
// a non-string const is dropped (Gemini enum is string-only).
|
||||
if (key === 'const') {
|
||||
|
|
@ -577,6 +647,13 @@ export function sanitizeGeminiSchema<T extends Record<string, unknown>>(schema:
|
|||
result['type'] = 'string';
|
||||
}
|
||||
|
||||
// Gemini rejects an array whose `items` is missing or not a schema object; fall
|
||||
// back to a permissive empty schema (verified accepted by the API) so tuple/
|
||||
// itemless arrays don't 400 after their unsupported item forms are dropped.
|
||||
if (result['type'] === 'array' && !isObjectSchema(result['items'])) {
|
||||
result['items'] = {};
|
||||
}
|
||||
|
||||
if (nullable) {
|
||||
result['nullable'] = true;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue