mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
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:
@@ -627,7 +627,12 @@ The MCP integration tracks several states:
|
|||||||
|
|
||||||
### Schema Compatibility
|
### 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
|
- **Name sanitization:** Tool names are automatically sanitized to meet API requirements
|
||||||
- **Conflict resolution:** Tool name conflicts between servers are resolved through automatic prefixing
|
- **Conflict resolution:** Tool name conflicts between servers are resolved through automatic prefixing
|
||||||
|
|
||||||
|
|||||||
@@ -659,6 +659,22 @@ const SETTINGS_SCHEMA = {
|
|||||||
childKey: 'disableCacheControl',
|
childKey: 'disableCacheControl',
|
||||||
showInDialog: true,
|
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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ export default {
|
|||||||
'Tool Output Truncation Lines': 'Tool Output Truncation Lines',
|
'Tool Output Truncation Lines': 'Tool Output Truncation Lines',
|
||||||
'Folder Trust': 'Folder Trust',
|
'Folder Trust': 'Folder Trust',
|
||||||
'Vision Model Preview': 'Vision Model Preview',
|
'Vision Model Preview': 'Vision Model Preview',
|
||||||
|
'Tool Schema Compliance': 'Tool Schema Compliance',
|
||||||
// Settings enum options
|
// Settings enum options
|
||||||
'Auto (detect from system)': 'Auto (detect from system)',
|
'Auto (detect from system)': 'Auto (detect from system)',
|
||||||
Text: 'Text',
|
Text: 'Text',
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ export default {
|
|||||||
'Tool Output Truncation Lines': '工具输出截断行数',
|
'Tool Output Truncation Lines': '工具输出截断行数',
|
||||||
'Folder Trust': '文件夹信任',
|
'Folder Trust': '文件夹信任',
|
||||||
'Vision Model Preview': '视觉模型预览',
|
'Vision Model Preview': '视觉模型预览',
|
||||||
|
'Tool Schema Compliance': '工具 Schema 兼容性',
|
||||||
// Settings enum options
|
// Settings enum options
|
||||||
'Auto (detect from system)': '自动(从系统检测)',
|
'Auto (detect from system)': '自动(从系统检测)',
|
||||||
Text: '文本',
|
Text: '文本',
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ export type ContentGeneratorConfig = {
|
|||||||
};
|
};
|
||||||
proxy?: string | undefined;
|
proxy?: string | undefined;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
|
// Schema compliance mode for tool definitions
|
||||||
|
schemaCompliance?: 'auto' | 'openapi_30';
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createContentGeneratorConfig(
|
export function createContentGeneratorConfig(
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ import { GenerateContentResponse, FinishReason } from '@google/genai';
|
|||||||
import type OpenAI from 'openai';
|
import type OpenAI from 'openai';
|
||||||
import { safeJsonParse } from '../../utils/safeJsonParse.js';
|
import { safeJsonParse } from '../../utils/safeJsonParse.js';
|
||||||
import { StreamingToolCallParser } from './streamingToolCallParser.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
|
* Extended usage type that supports both OpenAI standard format and alternative formats
|
||||||
@@ -80,11 +84,13 @@ interface ParsedParts {
|
|||||||
*/
|
*/
|
||||||
export class OpenAIContentConverter {
|
export class OpenAIContentConverter {
|
||||||
private model: string;
|
private model: string;
|
||||||
|
private schemaCompliance: SchemaComplianceMode;
|
||||||
private streamingToolCallParser: StreamingToolCallParser =
|
private streamingToolCallParser: StreamingToolCallParser =
|
||||||
new StreamingToolCallParser();
|
new StreamingToolCallParser();
|
||||||
|
|
||||||
constructor(model: string) {
|
constructor(model: string, schemaCompliance: SchemaComplianceMode = 'auto') {
|
||||||
this.model = model;
|
this.model = model;
|
||||||
|
this.schemaCompliance = schemaCompliance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -205,6 +211,10 @@ export class OpenAIContentConverter {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parameters) {
|
||||||
|
parameters = convertSchema(parameters, this.schemaCompliance);
|
||||||
|
}
|
||||||
|
|
||||||
openAITools.push({
|
openAITools.push({
|
||||||
type: 'function',
|
type: 'function',
|
||||||
function: {
|
function: {
|
||||||
|
|||||||
@@ -108,7 +108,10 @@ describe('ContentGenerationPipeline', () => {
|
|||||||
describe('constructor', () => {
|
describe('constructor', () => {
|
||||||
it('should initialize with correct configuration', () => {
|
it('should initialize with correct configuration', () => {
|
||||||
expect(mockProvider.buildClient).toHaveBeenCalled();
|
expect(mockProvider.buildClient).toHaveBeenCalled();
|
||||||
expect(OpenAIContentConverter).toHaveBeenCalledWith('test-model');
|
expect(OpenAIContentConverter).toHaveBeenCalledWith(
|
||||||
|
'test-model',
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export class ContentGenerationPipeline {
|
|||||||
this.client = this.config.provider.buildClient();
|
this.client = this.config.provider.buildClient();
|
||||||
this.converter = new OpenAIContentConverter(
|
this.converter = new OpenAIContentConverter(
|
||||||
this.contentGeneratorConfig.model,
|
this.contentGeneratorConfig.model,
|
||||||
|
this.contentGeneratorConfig.schemaCompliance,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
118
packages/core/src/utils/schemaConverter.test.ts
Normal file
118
packages/core/src/utils/schemaConverter.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
135
packages/core/src/utils/schemaConverter.ts
Normal file
135
packages/core/src/utils/schemaConverter.ts
Normal 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>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user