diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 14f496fbe..ac3e584da 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -29,6 +29,7 @@ - Fixed Anthropic retry loops ignoring `maxRetryDelayMs` for long server `retry-after` hints, so over-budget delays surface immediately without losing response details or abort cleanup ([#7003](https://github.com/can1357/oh-my-pi/issues/7003)). - Added interactive xAI API-key login with key validation through the xAI models endpoint. +- Fixed Google Gemini and Vertex tool declarations carrying numeric, boolean, object-valued, or mixed `enum` arrays that the Google Schema wire type cannot represent. Unsupported enums are omitted while valid string enums remain constrained. ## [17.1.8] - 2026-07-28 diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index 7cd2a6d37..d904d7685 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -55,6 +55,7 @@ export interface NormalizeSchemaOptions { inferTypeForBareEnum: boolean; foldOneOfIntoAnyOf: boolean; dropNonScalarEnum: boolean; + stringEnumsOnly?: boolean; rejectResidualIncompatibilities?: ReadonlyArray; validateAndFallback?: { fallback: unknown }; } @@ -106,6 +107,15 @@ const SUBSCHEMA_VALUE_KEYS: Record = { contentSchema: true, }; +/** + * Keywords whose value is either a boolean keyword value or an object + * subschema. Object values must be walked, while bare booleans stay literal. + */ +const BOOLEAN_OR_SCHEMA_VALUE_KEYS: Record = { + additionalProperties: true, + unevaluatedProperties: true, +}; + /** Keywords whose value is an array of subschemas. */ const SUBSCHEMA_ARRAY_KEYS: Record = { anyOf: true, @@ -124,6 +134,61 @@ const SUBSCHEMA_MAP_KEYS: Record = { definitions: true, }; +type SchemaChildKind = "schema" | "map"; + +/** Classify only JSON Schema-valued children; instance payloads remain opaque. */ +function classifySchemaChild(key: string, value: unknown, insideSchemaMap: boolean): SchemaChildKind | undefined { + if (insideSchemaMap) return "schema"; + const normalizedKey = SNAKE_TO_CAMEL_RENAMES.get(key) ?? key; + if (Object.hasOwn(SUBSCHEMA_MAP_KEYS, normalizedKey)) return "map"; + if (Object.hasOwn(SUBSCHEMA_VALUE_KEYS, normalizedKey) || Object.hasOwn(SUBSCHEMA_ARRAY_KEYS, normalizedKey)) { + return "schema"; + } + if (Object.hasOwn(BOOLEAN_OR_SCHEMA_VALUE_KEYS, normalizedKey) && isJsonObject(value)) return "schema"; + return undefined; +} + +function hasUnrepresentableGoogleEnumConstraint( + value: unknown, + insideSchemaMap = false, + seen = new Set(), +): boolean { + if (Array.isArray(value)) { + if (seen.has(value)) return false; + seen.add(value); + return value.some(entry => hasUnrepresentableGoogleEnumConstraint(entry, false, seen)); + } + if (!isJsonObject(value)) return false; + if (seen.has(value)) return false; + seen.add(value); + + if (insideSchemaMap) { + for (const key in value) { + if (Object.hasOwn(value, key) && hasUnrepresentableGoogleEnumConstraint(value[key], false, seen)) { + return true; + } + } + return false; + } + + if ( + Array.isArray(value.enum) && + (value.enum.length === 0 || value.enum.some(enumValue => typeof enumValue !== "string")) + ) { + return true; + } + if (Object.hasOwn(value, "const") && typeof value.const !== "string") return true; + + for (const key in value) { + if (!Object.hasOwn(value, key)) continue; + const childKind = classifySchemaChild(key, value[key], false); + if (childKind && hasUnrepresentableGoogleEnumConstraint(value[key], childKind === "map", seen)) { + return true; + } + } + return false; +} + const CLOUD_CODE_ASSIST_CLAUDE_FALLBACK_SCHEMA = { type: "object", properties: {}, @@ -361,14 +426,22 @@ function normalizeSchemaObjectNode(value: JsonObject, options: NormalizeSchemaWa continue; } if (options.stripNullableKeyword && key === "nullable") continue; - result[key] = normalizeSchemaNode(entry, { - ...options, - insideSchemaMap: !options.insideSchemaMap && Object.hasOwn(SUBSCHEMA_MAP_KEYS, key), - booleanIsSubschema: - options.insideSchemaMap || - Object.hasOwn(SUBSCHEMA_VALUE_KEYS, key) || - Object.hasOwn(SUBSCHEMA_ARRAY_KEYS, key), - }); + if ( + options.stringEnumsOnly && + !options.insideSchemaMap && + key === "not" && + hasUnrepresentableGoogleEnumConstraint(entry) + ) { + continue; + } + const childKind = classifySchemaChild(key, entry, options.insideSchemaMap); + result[key] = childKind + ? normalizeSchemaNode(entry, { + ...options, + insideSchemaMap: childKind === "map", + booleanIsSubschema: childKind === "schema", + }) + : entry; } applyDescriptionSpill(result, spill, options); return applyNodePostProcessing(result, options); @@ -387,14 +460,22 @@ function normalizeSchemaObjectNode(value: JsonObject, options: NormalizeSchemaWa constValue = entry; continue; } - result[key] = normalizeSchemaNode(entry, { - ...options, - insideSchemaMap: !options.insideSchemaMap && Object.hasOwn(SUBSCHEMA_MAP_KEYS, key), - booleanIsSubschema: - options.insideSchemaMap || - Object.hasOwn(SUBSCHEMA_VALUE_KEYS, key) || - Object.hasOwn(SUBSCHEMA_ARRAY_KEYS, key), - }); + if ( + options.stringEnumsOnly && + !options.insideSchemaMap && + key === "not" && + hasUnrepresentableGoogleEnumConstraint(entry) + ) { + continue; + } + const childKind = classifySchemaChild(key, entry, options.insideSchemaMap); + result[key] = childKind + ? normalizeSchemaNode(entry, { + ...options, + insideSchemaMap: childKind === "map", + booleanIsSubschema: childKind === "schema", + }) + : entry; } if (options.normalizeTypeArrayToNullable && Array.isArray(result.type)) { @@ -464,6 +545,7 @@ function applyNodePostProcessing(schema: JsonObject, options: NormalizeSchemaWal } if (options.foldOneOfIntoAnyOf) current = foldOneOfIntoAnyOf(current); if (options.dropNonScalarEnum) current = dropNonScalarEnumForMfjs(current); + if (options.stringEnumsOnly && options.booleanIsSubschema) current = dropNonStringEnumForGoogle(current); return current; } @@ -484,6 +566,13 @@ function dropNonScalarEnumForMfjs(schema: JsonObject): JsonObject { return copySchemaWithout(schema, "enum"); } +/** Google's Schema enum field accepts string values only; omit unsupported enums without dropping the node's type. */ +function dropNonStringEnumForGoogle(schema: JsonObject): JsonObject { + if (!Array.isArray(schema.enum)) return schema; + const isStringEnum = schema.enum.length > 0 && schema.enum.every(value => typeof value === "string"); + return isStringEnum ? schema : copySchemaWithout(schema, "enum"); +} + /** Copy all keys from a schema except the specified combiner key. */ export function copySchemaWithout(schema: JsonObject, combiner: string): JsonObject { const { [combiner]: _, ...rest } = schema; @@ -732,16 +821,25 @@ function collapseSameTypeCombinerVariants(schema: JsonObject, combiner: "anyOf" * create new anyOf in merged subtrees after child normalization already ran. */ export function stripResidualCombiners(value: unknown, epoch: number = epochNext()): unknown { + return stripResidualCombinersNode(value, epoch, false); +} + +function stripResidualCombinersNode(value: unknown, epoch: number, insideSchemaMap: boolean): unknown { if (Array.isArray(value)) { if (!once(value, epoch)) return []; - return value.map(entry => stripResidualCombiners(entry, epoch)); + return value.map(entry => stripResidualCombinersNode(entry, epoch, false)); } if (!isJsonObject(value)) return value; if (!once(value, epoch)) return {}; const result: JsonObject = {}; for (const key in value) { - if (Object.hasOwn(value, key)) result[key] = stripResidualCombiners(value[key], epoch); + if (!Object.hasOwn(value, key)) continue; + const entry = value[key]; + const childKind = classifySchemaChild(key, entry, insideSchemaMap); + result[key] = childKind ? stripResidualCombinersNode(entry, epoch, childKind === "map") : entry; } + if (insideSchemaMap) return result; + let current: JsonObject = result; let changed = true; while (changed) { @@ -840,6 +938,7 @@ function normalizeNullablePropertiesForCloudCodeAssist( value: unknown, isPropertySchema = false, epoch: number = epochNext(), + insideSchemaMap = false, ): NullableNormalizationResult { if (Array.isArray(value)) { if (!once(value, epoch)) { @@ -859,9 +958,14 @@ function normalizeNullablePropertiesForCloudCodeAssist( const normalized: JsonObject = {}; for (const key in value) { - if (Object.hasOwn(value, key)) - normalized[key] = normalizeNullablePropertiesForCloudCodeAssist(value[key], false, epoch).schema; + if (!Object.hasOwn(value, key)) continue; + const entry = value[key]; + const childKind = classifySchemaChild(key, entry, insideSchemaMap); + normalized[key] = childKind + ? normalizeNullablePropertiesForCloudCodeAssist(entry, false, epoch, childKind === "map").schema + : entry; } + if (insideSchemaMap) return { schema: normalized, nullable: false }; if (isJsonObject(normalized.properties)) { const properties = normalized.properties; @@ -933,7 +1037,7 @@ function hasResidualSchemaIncompatibilities( ): boolean { if (Array.isArray(value)) { if (!once(value, epoch)) return false; - return value.some(entry => hasResidualSchemaIncompatibilities(entry, checks, epoch, insideSchemaMap)); + return value.some(entry => hasResidualSchemaIncompatibilities(entry, checks, epoch, false)); } if (!isJsonObject(value)) { return false; @@ -942,25 +1046,22 @@ function hasResidualSchemaIncompatibilities( return false; } - if (checks.typeArray && Array.isArray(value.type)) return true; - if (checks.typeNull && value.type === "null") return true; - if (checks.nullable && Object.hasOwn(value, "nullable")) return true; - if (!insideSchemaMap && checks.not && Object.hasOwn(value, "not")) return true; - if (!insideSchemaMap && checks.combiners) { - for (const combiner of CCA_FORBIDDEN_COMBINERS) { - if (Array.isArray(value[combiner])) return true; + if (!insideSchemaMap) { + if (checks.typeArray && Array.isArray(value.type)) return true; + if (checks.typeNull && value.type === "null") return true; + if (checks.nullable && Object.hasOwn(value, "nullable")) return true; + if (checks.not && Object.hasOwn(value, "not")) return true; + if (checks.combiners) { + for (const combiner of CCA_FORBIDDEN_COMBINERS) { + if (Array.isArray(value[combiner])) return true; + } } } - for (const k in value) { - if (!Object.hasOwn(value, k)) continue; - if ( - hasResidualSchemaIncompatibilities( - value[k], - checks, - epoch, - !insideSchemaMap && Object.hasOwn(SUBSCHEMA_MAP_KEYS, k), - ) - ) { + for (const key in value) { + if (!Object.hasOwn(value, key)) continue; + const entry = value[key]; + const childKind = classifySchemaChild(key, entry, insideSchemaMap); + if (childKind && hasResidualSchemaIncompatibilities(entry, checks, epoch, childKind === "map")) { return true; } } @@ -1012,6 +1113,7 @@ export function normalizeSchemaForGoogle(value: unknown): unknown { extractNullableFromUnions: false, inferTypeForBareEnum: true, dropNonScalarEnum: false, + stringEnumsOnly: true, foldOneOfIntoAnyOf: false, }); } diff --git a/packages/ai/test/google-tool-schema.test.ts b/packages/ai/test/google-tool-schema.test.ts index efc086080..2370aab9d 100644 --- a/packages/ai/test/google-tool-schema.test.ts +++ b/packages/ai/test/google-tool-schema.test.ts @@ -585,14 +585,26 @@ describe("normalizeSchemaForGoogle parity with python-genai process_schema", () expect(sanitized).toEqual({ type: "string", enum: ["FOO"] }); }); - // Mirrors python-genai test_schema.py::test_process_schema_forbids_non_string_const - // We deviate intentionally: rather than raise on non-string const we accept - // the value as a singleton enum. Google's Schema proto accepts numeric enums - // and we prefer permissive normalization over surfacing a transformer-level error. - it("accepts non-string const as a singleton enum (intentional deviation from upstream raise)", () => { - const sanitized = normalizeSchemaForGoogle({ type: "integer", const: 123 }) as Record; - expect(sanitized.enum).toEqual([123]); - expect(sanitized.type).toBe("integer"); + // Mirrors python-genai test_schema.py::test_process_schema_forbids_non_string_const. + // Google enum fields accept strings only, so normalization drops the numeric + // singleton enum while preserving the integer type constraint. + it("omits a non-string const enum while preserving its type", () => { + const sanitized = normalizeSchemaForGoogle({ type: "integer", const: 123 }); + expect(sanitized).toEqual({ type: "integer" }); + }); + + it("drops negations whose non-string enums cannot be represented", () => { + const sanitized = normalizeSchemaForGoogle({ not: { enum: [1] } }); + expect(sanitized).toEqual({}); + expect( + normalizeSchemaForGoogle({ + not: { type: "object", properties: { value: { enum: [1] } } }, + }), + ).toEqual({}); + }); + + it("drops negations containing snake-case combiners with non-string enums", () => { + expect(normalizeSchemaForGoogle({ not: { any_of: [{ const: 1 }] } })).toEqual({}); }); // Mirrors python-genai test_schema.py::test_process_schema_order_properties_propagates_into_defs diff --git a/packages/ai/test/schema-normalization.test.ts b/packages/ai/test/schema-normalization.test.ts index f9fa3ddc1..d079ebf71 100644 --- a/packages/ai/test/schema-normalization.test.ts +++ b/packages/ai/test/schema-normalization.test.ts @@ -203,7 +203,58 @@ describe("upgradeJsonSchemaTo202012", () => { // --------------------------------------------------------------------------- describe("normalizeSchemaForGoogle", () => { - it("sets object type when converting an object const to an enum entry", () => { + it("preserves string enums and removes enums Google cannot represent", () => { + const sanitized = normalizeSchemaForGoogle({ + type: "object", + properties: { + valid: { type: "string", enum: ["draft", "published"] }, + numeric: { type: "number", enum: [1, 2] }, + mixed: { enum: ["draft", 1] }, + }, + }) as { properties: Record }; + + expect(sanitized.properties).toEqual({ + valid: { type: "string", enum: ["draft", "published"] }, + numeric: { type: "number" }, + mixed: {}, + }); + }); + + it("preserves enum keys inside object-valued defaults", () => { + expect( + normalizeSchemaForGoogle({ + type: "object", + default: { enum: [1], value: 2 }, + }), + ).toEqual({ + type: "object", + default: { enum: [1], value: 2 }, + properties: {}, + }); + }); + + it("keeps CCA incompatibility passes out of literal defaults", () => { + const literal = { + nullable: true, + allOf: [{ type: "object" }], + oneOf: [{ type: "string" }, { type: "number" }], + }; + expect( + normalizeSchemaForCCA({ + type: "object", + properties: { + value: { oneOf: [{ type: "string" }, { type: "string" }] }, + }, + default: literal, + }), + ).toEqual({ + type: "object", + properties: { value: { type: "string" } }, + default: literal, + }); + }); + + it("sets object type while removing an object-valued enum converted from const", () => { const sanitized = normalizeSchemaForGoogle({ const: { a: 1 }, }); @@ -211,11 +262,10 @@ describe("normalizeSchemaForGoogle", () => { expect(sanitized).toEqual({ type: "object", properties: {}, - enum: [{ a: 1 }], }); }); - it("deduplicates a deep-equal object const against an existing enum entry", () => { + it("removes an object-valued enum after deduplicating a deep-equal const", () => { const sanitized = normalizeSchemaForGoogle({ type: "object", enum: [{ a: 1 }], @@ -225,11 +275,10 @@ describe("normalizeSchemaForGoogle", () => { expect(sanitized).toEqual({ type: "object", properties: {}, - enum: [{ a: 1 }], }); }); - it("does not stamp a wrong scalar type when const variants span multiple primitive types", () => { + it("removes an enum when const variants span multiple primitive types", () => { const sanitized = normalizeSchemaForGoogle({ anyOf: [ { const: "A", type: "string" }, @@ -238,18 +287,18 @@ describe("normalizeSchemaForGoogle", () => { ], }) as Record; - expect(sanitized.enum).toEqual(["A", 1, true]); + expect(sanitized.enum).toBeUndefined(); expect(sanitized.type).toBeUndefined(); }); - it("collapses inferred null type to nullable when const is null", () => { + it("collapses inferred null type to nullable while removing its enum", () => { // After python-genai parity (handle_null_fields), bare `type: 'null'` is // folded into `nullable: true` so the schema is OpenAPI-compatible. const sanitized = normalizeSchemaForGoogle({ const: null }) as Record; expect(sanitized.type).toBeUndefined(); expect(sanitized.nullable).toBe(true); - expect(sanitized.enum).toEqual([null]); + expect(sanitized.enum).toBeUndefined(); }); it("coerces a boolean subschema literally named additionalProperties inside properties", () => { @@ -1277,6 +1326,21 @@ describe("normalizeSchemaForMoonshot", () => { expect(props.limit).toEqual({ type: "integer", default: 10 }); }); + it("normalizes schema-valued additionalProperties without walking literal payload objects", () => { + const literal = { oneOf: [{ const: "literal-a" }, { const: "literal-b" }] }; + const normalized = normalizeSchemaForMoonshot({ + type: "object", + additionalProperties: { oneOf: [{ const: 1 }, { const: 2 }] }, + default: literal, + }); + + expect(normalized).toEqual({ + type: "object", + additionalProperties: { type: "number", enum: [1, 2] }, + default: literal, + }); + }); + it("coerces boolean subschemas to MFJS object forms without changing boolean keywords", () => { expect( normalizeSchemaForMoonshot({