From b6a3ab11e0d85bdacecf704bbb4a3881aab63f30 Mon Sep 17 00:00:00 2001 From: kefuxin Date: Thu, 11 Dec 2025 14:23:27 +0800 Subject: [PATCH] fix: improve Gemini compatibility by adding configurable schema converter This commit addresses issue #1186 by introducing a configurable schema compliance mechanism for tool definitions sent to LLMs. Key changes: 1. **New Configuration**: Added `model.generationConfig.schemaCompliance` setting (defaults to 'auto', optional 'openapi_30'). 2. **Schema Converter**: Implemented `toOpenAPI30` converter in `packages/core` to strictly downgrade modern JSON Schema to OpenAPI 3.0.3 (required for Gemini API), handling: - Nullable types (`["string", "null"]` -> `nullable: true`) - Numeric exclusive limits - Const to Enum conversion - Removal of tuples and invalid keywords (``, `dependencies`, etc.) 3. **Tests**: Added comprehensive unit tests for the schema converter and updated pipeline tests. Fixes #1186 --- packages/cli/src/config/settingsSchema.ts | 16 +++ packages/core/src/core/contentGenerator.ts | 2 + .../core/openaiContentGenerator/converter.ts | 12 +- .../openaiContentGenerator/pipeline.test.ts | 5 +- .../core/openaiContentGenerator/pipeline.ts | 1 + .../core/src/utils/schemaConverter.test.ts | 118 +++++++++++++++ packages/core/src/utils/schemaConverter.ts | 135 ++++++++++++++++++ 7 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/utils/schemaConverter.test.ts create mode 100644 packages/core/src/utils/schemaConverter.ts diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index d95f4dbb..340cab77 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -628,6 +628,22 @@ const SETTINGS_SCHEMA = { childKey: 'disableCacheControl', showInDialog: true, }, + schemaCompliance: { + type: 'enum', + label: 'Tool Schema Compliance', + category: 'Generation Configuration', + requiresRestart: false, + default: 'auto', + description: + 'The compliance mode for tool schemas sent to the model. Use "openapi_30" for strict OpenAPI 3.0 compatibility (e.g., for Gemini).', + parentKey: 'generationConfig', + childKey: 'schemaCompliance', + showInDialog: true, + options: [ + { value: 'auto', label: 'Auto (Default)' }, + { value: 'openapi_30', label: 'OpenAPI 3.0 Strict' }, + ], + }, }, }, }, diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 6b480d42..75614ae6 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -76,6 +76,8 @@ export type ContentGeneratorConfig = { }; proxy?: string | undefined; userAgent?: string; + // Schema compliance mode for tool definitions + schemaCompliance?: 'auto' | 'openapi_30'; }; export function createContentGeneratorConfig( diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index b22eb963..c046ec33 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -22,6 +22,10 @@ import { GenerateContentResponse, FinishReason } from '@google/genai'; import type OpenAI from 'openai'; import { safeJsonParse } from '../../utils/safeJsonParse.js'; import { StreamingToolCallParser } from './streamingToolCallParser.js'; +import { + convertSchema, + type SchemaComplianceMode, +} from '../../utils/schemaConverter.js'; /** * Extended usage type that supports both OpenAI standard format and alternative formats @@ -80,11 +84,13 @@ interface ParsedParts { */ export class OpenAIContentConverter { private model: string; + private schemaCompliance: SchemaComplianceMode; private streamingToolCallParser: StreamingToolCallParser = new StreamingToolCallParser(); - constructor(model: string) { + constructor(model: string, schemaCompliance: SchemaComplianceMode = 'auto') { this.model = model; + this.schemaCompliance = schemaCompliance; } /** @@ -207,6 +213,10 @@ export class OpenAIContentConverter { ); } + if (parameters) { + parameters = convertSchema(parameters, this.schemaCompliance); + } + openAITools.push({ type: 'function', function: { diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts index c92e7a79..6ffb1623 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts @@ -108,7 +108,10 @@ describe('ContentGenerationPipeline', () => { describe('constructor', () => { it('should initialize with correct configuration', () => { expect(mockProvider.buildClient).toHaveBeenCalled(); - expect(OpenAIContentConverter).toHaveBeenCalledWith('test-model'); + expect(OpenAIContentConverter).toHaveBeenCalledWith( + 'test-model', + undefined, + ); }); }); diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index c24d247f..9b40d903 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -34,6 +34,7 @@ export class ContentGenerationPipeline { this.client = this.config.provider.buildClient(); this.converter = new OpenAIContentConverter( this.contentGeneratorConfig.model, + this.contentGeneratorConfig.schemaCompliance, ); } diff --git a/packages/core/src/utils/schemaConverter.test.ts b/packages/core/src/utils/schemaConverter.test.ts new file mode 100644 index 00000000..3d36ce4d --- /dev/null +++ b/packages/core/src/utils/schemaConverter.test.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { convertSchema } from './schemaConverter.js'; + +describe('convertSchema', () => { + describe('mode: auto (default)', () => { + it('should preserve type arrays', () => { + const input = { type: ['string', 'null'] }; + expect(convertSchema(input, 'auto')).toEqual(input); + }); + + it('should preserve items array (tuples)', () => { + const input = { + type: 'array', + items: [{ type: 'string' }, { type: 'number' }], + }; + expect(convertSchema(input, 'auto')).toEqual(input); + }); + + it('should preserve mixed enums', () => { + const input = { enum: [1, 2, '3'] }; + expect(convertSchema(input, 'auto')).toEqual(input); + }); + + it('should preserve unsupported keywords', () => { + const input = { + $schema: 'http://json-schema.org/draft-07/schema#', + exclusiveMinimum: 10, + type: 'number', + }; + expect(convertSchema(input, 'auto')).toEqual(input); + }); + }); + + describe('mode: openapi_30 (strict)', () => { + it('should convert type arrays to nullable', () => { + const input = { type: ['string', 'null'] }; + const expected = { type: 'string', nullable: true }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + + it('should fallback to first type for non-nullable arrays', () => { + const input = { type: ['string', 'number'] }; + const expected = { type: 'string' }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + + it('should convert const to enum', () => { + const input = { const: 'foo' }; + const expected = { enum: ['foo'] }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + + it('should convert exclusiveMinimum number to boolean', () => { + const input = { type: 'number', exclusiveMinimum: 10 }; + const expected = { + type: 'number', + minimum: 10, + exclusiveMinimum: true, + }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + + it('should convert nested objects recursively', () => { + const input = { + type: 'object', + properties: { + prop1: { type: ['integer', 'null'], exclusiveMaximum: 5 }, + }, + }; + const expected = { + type: 'object', + properties: { + prop1: { + type: 'integer', + nullable: true, + maximum: 5, + exclusiveMaximum: true, + }, + }, + }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + + it('should stringify enums', () => { + const input = { enum: [1, 2, '3'] }; + const expected = { enum: ['1', '2', '3'] }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + + it('should remove tuple items (array of schemas)', () => { + const input = { + type: 'array', + items: [{ type: 'string' }, { type: 'number' }], + }; + const expected = { type: 'array' }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + + it('should remove unsupported keywords', () => { + const input = { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: '#foo', + type: 'string', + default: 'bar', + dependencies: { foo: ['bar'] }, + patternProperties: { '^foo': { type: 'string' } }, + }; + const expected = { type: 'string' }; + expect(convertSchema(input, 'openapi_30')).toEqual(expected); + }); + }); +}); diff --git a/packages/core/src/utils/schemaConverter.ts b/packages/core/src/utils/schemaConverter.ts new file mode 100644 index 00000000..9d8a45a7 --- /dev/null +++ b/packages/core/src/utils/schemaConverter.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Utility for converting JSON Schemas to be compatible with different LLM providers. + * Specifically focuses on downgrading modern JSON Schema (Draft 7/2020-12) to + * OpenAPI 3.0 compatible Schema Objects, which is required for Google Gemini API. + */ + +export type SchemaComplianceMode = 'auto' | 'openapi_30'; + +/** + * Converts a JSON Schema to be compatible with the specified compliance mode. + */ +export function convertSchema( + schema: Record, + mode: SchemaComplianceMode = 'auto', +): Record { + if (mode === 'openapi_30') { + return toOpenAPI30(schema); + } + + // Default ('auto') mode now does nothing. + return schema; +} + +/** + * Converts Modern JSON Schema to OpenAPI 3.0 Schema Object. + * Attempts to preserve semantics where possible through transformations. + */ +function toOpenAPI30(schema: Record): Record { + const convert = (obj: unknown): unknown => { + if (typeof obj !== 'object' || obj === null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(convert); + } + + const source = obj as Record; + const target: Record = {}; + + // 1. Type Handling + if (Array.isArray(source['type'])) { + const types = source['type'] as string[]; + // Handle ["string", "null"] pattern common in modern schemas + if (types.length === 2 && types.includes('null')) { + target['type'] = types.find((t) => t !== 'null'); + target['nullable'] = true; + } else { + // Fallback for other unions: take the first non-null type + // OpenAPI 3.0 doesn't support type arrays. + // Ideal fix would be anyOf, but simple fallback is safer for now. + target['type'] = types[0]; + } + } else if (source['type'] !== undefined) { + target['type'] = source['type']; + } + + // 2. Const Handling (Draft 6+) -> Enum (OpenAPI 3.0) + if (source['const'] !== undefined) { + target['enum'] = [source['const']]; + delete target['const']; + } + + // 3. Exclusive Limits (Draft 6+ number) -> (Draft 4 boolean) + // exclusiveMinimum: 10 -> minimum: 10, exclusiveMinimum: true + if (typeof source['exclusiveMinimum'] === 'number') { + target['minimum'] = source['exclusiveMinimum']; + target['exclusiveMinimum'] = true; + } + if (typeof source['exclusiveMaximum'] === 'number') { + target['maximum'] = source['exclusiveMaximum']; + target['exclusiveMaximum'] = true; + } + + // 4. Array Items (Tuple -> Single Schema) + // OpenAPI 3.0 items must be a schema object, not an array of schemas + if (Array.isArray(source['items'])) { + // Tuple support is tricky. + // Best effort: Use the first item's schema as a generic array type + // or convert to an empty object (any type) if mixed. + // For now, we'll strip it to allow validation to pass (accepts any items) + // This matches the legacy behavior but is explicit. + // Ideally, we could use `oneOf` on the items if we wanted to be stricter. + delete target['items']; + } else if ( + typeof source['items'] === 'object' && + source['items'] !== null + ) { + target['items'] = convert(source['items']); + } + + // 5. Enum Stringification + // Gemini strictly requires enums to be strings + if (Array.isArray(source['enum'])) { + target['enum'] = source['enum'].map(String); + } + + // 6. Recursively process other properties + for (const [key, value] of Object.entries(source)) { + // Skip fields we've already handled or want to remove + if ( + key === 'type' || + key === 'const' || + key === 'exclusiveMinimum' || + key === 'exclusiveMaximum' || + key === 'items' || + key === 'enum' || + key === '$schema' || + key === '$id' || + key === 'default' || // Optional: Gemini sometimes complains about defaults conflicting with types + key === 'dependencies' || + key === 'patternProperties' + ) { + continue; + } + + target[key] = convert(value); + } + + // Preserve default if it doesn't conflict (simple pass-through) + // if (source['default'] !== undefined) { + // target['default'] = source['default']; + // } + + return target; + }; + + return convert(schema) as Record; +}