fix(core): coerce stringified JSON values for anyOf/oneOf schemas (#2839)

Some LLMs serialize array/object values as JSON strings when the schema
uses anyOf/oneOf with mixed types (e.g., Python's `list[str] | None`).
The model returns '["url"]' (a string) instead of ["url"] (an array),
causing Ajv to reject otherwise valid input.

Add fixStringifiedJsonValues() coercion — same pattern as the existing
fixBooleanValues() — that parses stringified arrays/objects back to their
intended type when the schema expects a non-string type.
This commit is contained in:
tanzhenxin 2026-04-03 23:02:25 +08:00
parent 61bc80fe19
commit 0eaa5e4561
2 changed files with 238 additions and 0 deletions

View file

@ -210,6 +210,132 @@ describe('SchemaValidator', () => {
});
});
describe('stringified JSON value coercion', () => {
it('should coerce stringified array for anyOf [array, null]', () => {
const schema = {
type: 'object',
properties: {
urls: {
anyOf: [
{ type: 'array', items: { type: 'string' } },
{ type: 'null' },
],
default: null,
},
},
};
const params = { urls: '["https://example.com"]' };
expect(SchemaValidator.validate(schema, params)).toBeNull();
expect(params.urls).toEqual(['https://example.com']);
});
it('should coerce stringified object for anyOf [object, null]', () => {
const schema = {
type: 'object',
properties: {
config: {
anyOf: [
{
type: 'object',
properties: { key: { type: 'string' } },
},
{ type: 'null' },
],
},
},
};
const params = { config: '{"key":"value"}' };
expect(SchemaValidator.validate(schema, params)).toBeNull();
expect(params.config).toEqual({ key: 'value' });
});
it('should coerce stringified array for oneOf [array, null]', () => {
const schema = {
type: 'object',
properties: {
items: {
oneOf: [
{ type: 'array', items: { type: 'integer' } },
{ type: 'null' },
],
},
},
};
const params = { items: '[1, 2, 3]' };
expect(SchemaValidator.validate(schema, params)).toBeNull();
expect(params.items).toEqual([1, 2, 3]);
});
it('should not coerce when schema accepts string type', () => {
const schema = {
type: 'object',
properties: {
data: {
anyOf: [
{ type: 'string' },
{ type: 'array', items: { type: 'string' } },
],
},
},
};
const params = { data: '["hello"]' };
expect(SchemaValidator.validate(schema, params)).toBeNull();
// Value should remain a string since string is accepted
expect(params.data).toBe('["hello"]');
});
it('should not coerce invalid JSON strings', () => {
const schema = {
type: 'object',
properties: {
urls: {
anyOf: [
{ type: 'array', items: { type: 'string' } },
{ type: 'null' },
],
},
},
};
const params = { urls: '[not valid json' };
expect(SchemaValidator.validate(schema, params)).not.toBeNull();
});
it('should not coerce strings that do not look like JSON', () => {
const schema = {
type: 'object',
properties: {
urls: {
anyOf: [
{ type: 'array', items: { type: 'string' } },
{ type: 'null' },
],
},
},
required: ['urls'],
};
const params = { urls: 'hello world' };
expect(SchemaValidator.validate(schema, params)).not.toBeNull();
});
it('should handle stringified array with plain type (no anyOf)', () => {
// Should NOT coerce when there is no anyOf/oneOf — the schema just
// says type: array, and a string value is simply invalid.
const schema = {
type: 'object',
properties: {
urls: { type: 'array', items: { type: 'string' } },
},
required: ['urls'],
};
const params = { urls: '["https://example.com"]' };
// No anyOf/oneOf, so fixStringifiedJsonValues won't have types to check
// against — but getAcceptedTypes reads plain 'type' too, so it should
// still coerce since 'string' is not in the accepted types.
expect(SchemaValidator.validate(schema, params)).toBeNull();
expect(params.urls).toEqual(['https://example.com']);
});
});
describe('JSON Schema version support', () => {
it('should support JSON Schema draft-2020-12', () => {
const schema = {
@ -280,6 +406,29 @@ describe('SchemaValidator', () => {
expect(SchemaValidator.validate(schema, params)).toBeNull();
});
it('should handle anyOf union types with draft-2020-12', () => {
const schema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
properties: {
urls: {
anyOf: [
{ type: 'array', items: { type: 'string' } },
{ type: 'null' },
],
default: null,
},
},
};
expect(
SchemaValidator.validate(schema, {
urls: ['https://example.com'],
}),
).toBeNull();
expect(SchemaValidator.validate(schema, { urls: null })).toBeNull();
expect(SchemaValidator.validate(schema, {})).toBeNull();
});
it('should gracefully handle unsupported schema versions', () => {
// draft-2019-09 is not supported by Ajv by default
const schema = {