Merge pull request #1214 from kfxmvp/fix/issue-1186-schema-converter

fix: add configurable OpenAPI 3.0 schema compliance for Gemini compatibility (#1186)
This commit is contained in:
Mingholy
2025-12-17 11:12:57 +08:00
committed by GitHub
10 changed files with 295 additions and 3 deletions

View File

@@ -627,7 +627,12 @@ The MCP integration tracks several states:
### Schema Compatibility
- **Property stripping:** The system automatically removes certain schema properties (`$schema`, `additionalProperties`) for Qwen API compatibility
- **Schema compliance mode:** By default (`schemaCompliance: "auto"`), tool schemas are passed through as-is. Set `"model": { "generationConfig": { "schemaCompliance": "openapi_30" } }` in your `settings.json` to convert models to Strict OpenAPI 3.0 format.
- **OpenAPI 3.0 transformations:** When `openapi_30` mode is enabled, the system handles:
- Nullable types: `["string", "null"]` -> `type: "string", nullable: true`
- Const values: `const: "foo"` -> `enum: ["foo"]`
- Exclusive limits: numeric `exclusiveMinimum` -> boolean form with `minimum`
- Keyword removal: `$schema`, `$id`, `dependencies`, `patternProperties`
- **Name sanitization:** Tool names are automatically sanitized to meet API requirements
- **Conflict resolution:** Tool name conflicts between servers are resolved through automatic prefixing

View File

@@ -659,6 +659,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' },
],
},
},
},
},

View File

@@ -310,6 +310,7 @@ export default {
'Tool Output Truncation Lines': 'Tool Output Truncation Lines',
'Folder Trust': 'Folder Trust',
'Vision Model Preview': 'Vision Model Preview',
'Tool Schema Compliance': 'Tool Schema Compliance',
// Settings enum options
'Auto (detect from system)': 'Auto (detect from system)',
Text: 'Text',

View File

@@ -300,6 +300,7 @@ export default {
'Tool Output Truncation Lines': '工具输出截断行数',
'Folder Trust': '文件夹信任',
'Vision Model Preview': '视觉模型预览',
'Tool Schema Compliance': '工具 Schema 兼容性',
// Settings enum options
'Auto (detect from system)': '自动(从系统检测)',
Text: '文本',

View File

@@ -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(

View File

@@ -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;
}
/**
@@ -205,6 +211,10 @@ export class OpenAIContentConverter {
);
}
if (parameters) {
parameters = convertSchema(parameters, this.schemaCompliance);
}
openAITools.push({
type: 'function',
function: {

View File

@@ -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,
);
});
});

View File

@@ -34,6 +34,7 @@ export class ContentGenerationPipeline {
this.client = this.config.provider.buildClient();
this.converter = new OpenAIContentConverter(
this.contentGeneratorConfig.model,
this.contentGeneratorConfig.schemaCompliance,
);
}

View File

@@ -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);
});
});
});

View File

@@ -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<string, unknown>,
mode: SchemaComplianceMode = 'auto',
): Record<string, unknown> {
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<string, unknown>): Record<string, unknown> {
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<string, unknown>;
const target: Record<string, unknown> = {};
// 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<string, unknown>;
}