mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
fix(cli): also reject root anyOf/oneOf schemas whose branches can't accept objects
Addresses a review follow-up: the previous root-object check only inspected
the top-level `type` keyword, so a schema like
`{"anyOf":[{"type":"array"},{"type":"string"}]}` slipped through even though
none of its branches can ever validate the object-shaped arguments that
function-calling APIs send.
Replace the single `type` check with `schemaRootAcceptsObject`, which
recursively walks root-level anyOf/oneOf branches and requires at least one
to accept objects. Absent `type`, `type: "object"`, `type: ["object", ...]`,
and mixed anyOf branches where one accepts object all still pass. `allOf`
is left to Ajv's runtime behaviour — guessing intent across contradictory
allOf branches at parse time is fragile.
This commit is contained in:
parent
868408dbf6
commit
2f6b406b9d
2 changed files with 76 additions and 14 deletions
|
|
@ -165,6 +165,40 @@ export interface CliArgs {
|
|||
inputFile?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the root of the given schema can accept a JSON object.
|
||||
*
|
||||
* Considers:
|
||||
* - explicit root `type` (string or array)
|
||||
* - root `anyOf` / `oneOf` branches (at least one branch must accept
|
||||
* object-typed values)
|
||||
*
|
||||
* Leaves `allOf` alone — tight interactions between `allOf` branches with
|
||||
* contradictory types are rare for `--json-schema` input and we'd rather
|
||||
* let Ajv surface that at runtime than guess wrong here.
|
||||
*/
|
||||
function schemaRootAcceptsObject(schema: Record<string, unknown>): boolean {
|
||||
const rawType = schema['type'];
|
||||
if (rawType !== undefined) {
|
||||
const types = Array.isArray(rawType) ? rawType : [rawType];
|
||||
return types.includes('object');
|
||||
}
|
||||
for (const key of ['anyOf', 'oneOf'] as const) {
|
||||
const variants = schema[key];
|
||||
if (Array.isArray(variants) && variants.length > 0) {
|
||||
return variants.some(
|
||||
(v) =>
|
||||
typeof v === 'object' &&
|
||||
v !== null &&
|
||||
!Array.isArray(v) &&
|
||||
schemaRootAcceptsObject(v as Record<string, unknown>),
|
||||
);
|
||||
}
|
||||
}
|
||||
// No narrowing at the root — lenient default, treated as object-compatible.
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the `--json-schema` argument into a parsed JSON Schema object.
|
||||
*
|
||||
|
|
@ -218,18 +252,17 @@ export function resolveJsonSchemaArg(
|
|||
|
||||
// The schema will be installed as a TOOL PARAMETER schema. All function-
|
||||
// calling APIs (Gemini/OpenAI/Anthropic) require tool arguments to be a
|
||||
// JSON object, so a root type like "array" or "string" registers an
|
||||
// unusable synthetic tool that the model could never satisfy. Reject any
|
||||
// explicit non-object root here. Absent `type`, `type: "object"`, or a
|
||||
// `type` array that includes `"object"` are all acceptable.
|
||||
const rawType = (parsed as Record<string, unknown>)['type'];
|
||||
if (rawType !== undefined) {
|
||||
const types = Array.isArray(rawType) ? rawType : [rawType];
|
||||
if (!types.includes('object')) {
|
||||
throw new FatalConfigError(
|
||||
`--json-schema root "type" must be "object" (tool parameters are object-valued); got ${JSON.stringify(rawType)}.`,
|
||||
);
|
||||
}
|
||||
// JSON object, so a schema that cannot accept objects registers an
|
||||
// unusable synthetic tool the model could never satisfy. Check the root
|
||||
// *and* any top-level anyOf/oneOf narrowing — a schema without a root
|
||||
// `type` but whose only anyOf branches are non-object is equally broken.
|
||||
if (!schemaRootAcceptsObject(parsed as Record<string, unknown>)) {
|
||||
throw new FatalConfigError(
|
||||
'--json-schema root must accept object-typed values (tool parameters ' +
|
||||
'are always JSON objects). Every branch of a root anyOf/oneOf must ' +
|
||||
'be satisfiable by an object, or the root must omit `type` / declare ' +
|
||||
'`type: "object"`.',
|
||||
);
|
||||
}
|
||||
|
||||
// Ajv compile-time validation. SchemaValidator.validate is deliberately
|
||||
|
|
|
|||
|
|
@ -82,10 +82,10 @@ describe('resolveJsonSchemaArg', () => {
|
|||
|
||||
it('rejects a schema whose root type is not object', () => {
|
||||
expect(() => resolveJsonSchemaArg('{"type":"array"}')).toThrow(
|
||||
/root "type" must be "object"/,
|
||||
/must accept object-typed values/,
|
||||
);
|
||||
expect(() => resolveJsonSchemaArg('{"type":"string"}')).toThrow(
|
||||
/root "type" must be "object"/,
|
||||
/must accept object-typed values/,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -101,4 +101,33 @@ describe('resolveJsonSchemaArg', () => {
|
|||
const schema = resolveJsonSchemaArg('{"properties":{"foo":{}}}');
|
||||
expect(schema).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects root anyOf where no branch accepts object', () => {
|
||||
expect(() =>
|
||||
resolveJsonSchemaArg(
|
||||
'{"anyOf":[{"type":"array"},{"type":"string"}]}',
|
||||
),
|
||||
).toThrow(/must accept object-typed values/);
|
||||
});
|
||||
|
||||
it('rejects root oneOf where no branch accepts object', () => {
|
||||
expect(() =>
|
||||
resolveJsonSchemaArg('{"oneOf":[{"type":"number"},{"type":"boolean"}]}'),
|
||||
).toThrow(/must accept object-typed values/);
|
||||
});
|
||||
|
||||
it('accepts root anyOf when at least one branch accepts object', () => {
|
||||
const schema = resolveJsonSchemaArg(
|
||||
'{"anyOf":[{"type":"object"},{"type":"string"}]}',
|
||||
);
|
||||
expect(schema).toBeDefined();
|
||||
});
|
||||
|
||||
it('accepts nested anyOf/oneOf chains where a deep branch accepts object', () => {
|
||||
// The recursion should see through one level of nesting.
|
||||
const schema = resolveJsonSchemaArg(
|
||||
'{"anyOf":[{"oneOf":[{"type":"object"}]},{"type":"string"}]}',
|
||||
);
|
||||
expect(schema).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue