diff --git a/packages/ai/src/protocols/utils/tool-schema.ts b/packages/ai/src/protocols/utils/tool-schema.ts index 2c6ab38c1ed..b6a2f71bb92 100644 --- a/packages/ai/src/protocols/utils/tool-schema.ts +++ b/packages/ai/src/protocols/utils/tool-schema.ts @@ -9,12 +9,24 @@ const tupleItemsSchema = (items: ReadonlyArray) => { return { anyOf: projected } } +// Moonshot rejects an `enum` without a sibling `type`. Its `type` may be one type, or one type plus "null". +const enumType = (values: ReadonlyArray) => { + const types = [ + ...new Set(values.map((value) => (value === null ? "null" : Array.isArray(value) ? "array" : typeof value))), + ] + if (types.length === 1) return types[0] + if (types.length === 2 && types.includes("null")) return [...types.filter((type) => type !== "null"), "null"] + return undefined +} + const moonshotNode = (schema: unknown): unknown => { if (Array.isArray(schema)) return schema.map(moonshotNode) if (!isRecord(schema)) return schema if (typeof schema.$ref === "string") return { $ref: schema.$ref } - return Object.fromEntries( - Object.entries(schema).flatMap(([key, value]) => { + const type = schema.type === undefined && Array.isArray(schema.enum) ? enumType(schema.enum) : undefined + return Object.fromEntries([ + ...(type === undefined ? [] : [["type", type]]), + ...Object.entries(schema).flatMap(([key, value]) => { if (key === "items" && Array.isArray(value)) return [[key, tupleItemsSchema(value)]] if (key === "prefixItems") { if ("items" in schema) return [] @@ -23,7 +35,7 @@ const moonshotNode = (schema: unknown): unknown => { if (key === "unevaluatedItems") return [] return [[key, moonshotNode(value)]] }), - ) + ]) } const moonshot = (schema: JsonSchema): JsonSchema => { diff --git a/packages/ai/test/tool-schema-projection.test.ts b/packages/ai/test/tool-schema-projection.test.ts index fdc86b2d3ce..ef12f0318eb 100644 --- a/packages/ai/test/tool-schema-projection.test.ts +++ b/packages/ai/test/tool-schema-projection.test.ts @@ -28,6 +28,38 @@ describe("tool schema projections", () => { }) }) + test("moonshot derives a type for untyped enums", () => { + expect( + ToolSchemaProjection.moonshot({ + type: "object", + properties: { + kind: { description: "The kind of flag", enum: ["boolean", "string"] }, + level: { enum: [1, 2.5] }, + optional: { enum: [null, "a"] }, + choice: { anyOf: [{ enum: [true, false] }, { type: "null" }] }, + list: { type: "array", items: { enum: ["x"] } }, + map: { type: "object", additionalProperties: { enum: ["y"] } }, + typed: { type: "string", enum: ["a", null] }, + mixed: { enum: ["a", 1] }, + }, + $defs: { Mode: { enum: ["fast"] } }, + }), + ).toEqual({ + type: "object", + properties: { + kind: { type: "string", description: "The kind of flag", enum: ["boolean", "string"] }, + level: { type: "number", enum: [1, 2.5] }, + optional: { type: ["string", "null"], enum: [null, "a"] }, + choice: { anyOf: [{ type: "boolean", enum: [true, false] }, { type: "null" }] }, + list: { type: "array", items: { type: "string", enum: ["x"] } }, + map: { type: "object", additionalProperties: { type: "string", enum: ["y"] } }, + typed: { type: "string", enum: ["a", null] }, + mixed: { enum: ["a", 1] }, + }, + $defs: { Mode: { type: "string", enum: ["fast"] } }, + }) + }) + test("gemini handles numeric enums, dangling required fields, untyped arrays, and scalar object keys", () => { expect( ToolSchemaProjection.gemini({