mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
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
This commit is contained in:
@@ -628,6 +628,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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -207,6 +213,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