diff --git a/src/adapters/openai-chat/tool-schema.ts b/src/adapters/openai-chat/tool-schema.ts index 23f77121bd5..d9d95406ec3 100644 --- a/src/adapters/openai-chat/tool-schema.ts +++ b/src/adapters/openai-chat/tool-schema.ts @@ -380,6 +380,20 @@ function normalizeMoonshotSchemaNode( } merged[key] = normalized; } + + // Re-normalize only composed properties that retain a $ref alongside sibling keywords + if (isXaiObjectSchema(merged.properties)) { + for (const [propName, propVal] of Object.entries(merged.properties as Record)) { + if (isXaiObjectSchema(propVal) && typeof propVal.$ref === "string" && moonshotRefTargetKeys(propVal).length > 0) { + (merged.properties as Record)[propName] = normalizeMoonshotSchemaNode( + propVal, + root, + state, + depth + 1, + ); + } + } + } return merged; } @@ -397,6 +411,37 @@ function normalizeMoonshotSchemaNode( ? value : normalizeMoonshotSchemaNode(value, root, state, depth + 1); } + + // Moonshot MFJS requirements: + // 1. Stamp "object" if properties are present, or if allOf defines object properties/variants, + // so Moonshot's validator recognizes the schema as a valid termination condition. + // 2. Infer scalar types for bare const and enum keywords. + if (out.type === undefined) { + const isObjectAllOf = Array.isArray(out.allOf) && out.allOf.some( + variant => isXaiObjectSchema(variant) && ( + variant.type === "object" || + variant.properties !== undefined || + variant.additionalProperties !== undefined + ), + ); + if (out.properties !== undefined || out.additionalProperties !== undefined || isObjectAllOf) { + out.type = "object"; + } else if (out.const !== undefined) { + const t = typeof out.const; + if (t === "string" || t === "number" || t === "boolean") { + out.type = t; + } + } else if (Array.isArray(out.enum) && out.enum.length > 0) { + if (out.enum.every(x => typeof x === "string")) { + out.type = "string"; + } else if (out.enum.every(x => typeof x === "number")) { + out.type = "number"; + } else if (out.enum.every(x => typeof x === "boolean")) { + out.type = "boolean"; + } + } + } + return out; } diff --git a/tests/providers/moonshot-tool-schema.test.ts b/tests/providers/moonshot-tool-schema.test.ts index ed1626927ad..c3519790ac2 100644 --- a/tests/providers/moonshot-tool-schema.test.ts +++ b/tests/providers/moonshot-tool-schema.test.ts @@ -447,4 +447,71 @@ describe("Moonshot tool schema normalization (issue #2673)", () => { expect(parameters?.$defs).toEqual(CODEX_STYLE_SCHEMA.$defs as Record); expect(siblingRefPaths(parameters).length).toBeGreaterThan(0); }); + + test("infers object type for allOf/properties and scalar types for const/enum", async () => { + const parameters = await emittedParameters("https://api.kimi.com/coding/v1", { + name: "inference_tool", + parameters: { + type: "object", + properties: { + leaf: { + allOf: [{ properties: { id: { type: "integer" } } }], + }, + status: { const: "ACTIVE" }, + count: { const: 42 }, + flag: { const: true }, + color: { enum: ["red", "blue"] }, + toggle: { enum: [true, false] }, + stringAllOf: { allOf: [{ type: "string" }, { minLength: 1 }] }, + }, + }, + }); + + const props = parameters?.properties as Record>; + expect(props.leaf.type).toBe("object"); + expect(props.status.type).toBe("string"); + expect(props.count.type).toBe("number"); + expect(props.flag.type).toBe("boolean"); + expect(props.color.type).toBe("string"); + expect(props.toggle.type).toBe("boolean"); + expect(props.stringAllOf.type).toBeUndefined(); + }); + + test("re-normalizes composed properties when sibling narrows a referenced property", async () => { + // When Base defines `op: { $ref: "#/$defs/Op" }` and a sibling node narrows it with + // `properties: { op: { const: "AND" } }`, `composeProperties` merges them. The merged + // property must re-normalize rather than emitting a `$ref` beside `const`. + const parameters = await emittedParameters("https://api.kimi.com/coding/v1", { + name: "ast_tool", + parameters: { + type: "object", + $defs: { + Op: { type: "string", enum: ["AND", "OR"] }, + Base: { + type: "object", + properties: { + op: { $ref: "#/$defs/Op" }, + left: { type: "string" }, + }, + }, + }, + properties: { + andNode: { + $ref: "#/$defs/Base", + properties: { + op: { const: "AND" }, + }, + }, + }, + }, + }); + + expect(siblingRefPaths(parameters)).toEqual([]); + const andNode = (parameters?.properties as Record>).andNode; + const op = (andNode.properties as Record>).op; + expect(op.$ref).toBeUndefined(); + expect(op.const).toBe("AND"); + expect(op.type).toBe("string"); + }); }); +