From 16e03728f091eea2c67169fedb4c782fc69a3381 Mon Sep 17 00:00:00 2001 From: Roy Date: Sun, 26 Jul 2026 16:30:56 +0000 Subject: [PATCH 01/10] fix(ai): omit unsupported Google enum values (cherry picked from commit 6cc7916f9ff508ee0864229fe83a3c31a585f832) --- packages/ai/CHANGELOG.md | 3 ++ packages/ai/src/utils/schema/normalize.ts | 10 ++++++ packages/ai/test/google-tool-schema.test.ts | 14 ++++----- packages/ai/test/schema-normalization.test.ts | 31 ++++++++++++++----- 4 files changed, 42 insertions(+), 16 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 65cd39d3f..192a9f446 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -58,6 +58,9 @@ - Kimi Code now sends its session-stable prompt cache key on both supported transports: `prompt_cache_key` for OpenAI-compatible requests and `metadata.user_id` for Anthropic-compatible requests. Explicit keys survive side-channel session IDs, while `cacheRetention: "none"` still disables automatic affinity ([#6049](https://github.com/can1357/oh-my-pi/issues/6049)). - Fresh encrypted auth-broker snapshot caches are revalidated within a short startup budget, so one-shot clients see newly imported or revoked credentials immediately when the broker is reachable while retaining cache fallback for transport and server failures. - Fixed custom `anthropic-messages` endpoints dropping native web-search call/result blocks in the leaked-thinking wrapper, preserving signed continuation history in source order without carrying a preceding text signature onto later unsigned blocks ([#6703](https://github.com/can1357/oh-my-pi/issues/6703)). +### Fixed + +- 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.4] - 2026-07-26 diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index 7cd2a6d37..3460eb0b9 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 }; } @@ -464,6 +465,7 @@ function applyNodePostProcessing(schema: JsonObject, options: NormalizeSchemaWal } if (options.foldOneOfIntoAnyOf) current = foldOneOfIntoAnyOf(current); if (options.dropNonScalarEnum) current = dropNonScalarEnumForMfjs(current); + if (options.stringEnumsOnly) current = dropNonStringEnumForGoogle(current); return current; } @@ -484,6 +486,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; @@ -1012,6 +1021,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..4bf0affff 100644 --- a/packages/ai/test/google-tool-schema.test.ts +++ b/packages/ai/test/google-tool-schema.test.ts @@ -585,14 +585,12 @@ 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" }); }); // 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..6746dc9d2 100644 --- a/packages/ai/test/schema-normalization.test.ts +++ b/packages/ai/test/schema-normalization.test.ts @@ -203,7 +203,24 @@ 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("sets object type while removing an object-valued enum converted from const", () => { const sanitized = normalizeSchemaForGoogle({ const: { a: 1 }, }); @@ -211,11 +228,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 +241,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 +253,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", () => { From d17ae32b1daf70bdbb3e14c22ec70cd1c37a1df3 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Sun, 26 Jul 2026 20:58:55 +0000 Subject: [PATCH 02/10] fix(ai): preserve enum keys in Google schema defaults (cherry picked from commit 226f00efb3c330f5ed6b0583c4b65e2fa75b9f5b) --- packages/ai/src/utils/schema/normalize.ts | 2 +- packages/ai/test/schema-normalization.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index 3460eb0b9..9b701b167 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -465,7 +465,7 @@ function applyNodePostProcessing(schema: JsonObject, options: NormalizeSchemaWal } if (options.foldOneOfIntoAnyOf) current = foldOneOfIntoAnyOf(current); if (options.dropNonScalarEnum) current = dropNonScalarEnumForMfjs(current); - if (options.stringEnumsOnly) current = dropNonStringEnumForGoogle(current); + if (options.stringEnumsOnly && options.booleanIsSubschema) current = dropNonStringEnumForGoogle(current); return current; } diff --git a/packages/ai/test/schema-normalization.test.ts b/packages/ai/test/schema-normalization.test.ts index 6746dc9d2..8339246b2 100644 --- a/packages/ai/test/schema-normalization.test.ts +++ b/packages/ai/test/schema-normalization.test.ts @@ -220,6 +220,19 @@ describe("normalizeSchemaForGoogle", () => { }); }); + it("preserves enum keys inside object-valued defaults", () => { + expect( + normalizeSchemaForGoogle({ + type: "object", + default: { enum: [1], value: 2 }, + }), + ).toEqual({ + type: "object", + default: { enum: [1], type: "number", value: 2 }, + properties: {}, + }); + }); + it("sets object type while removing an object-valued enum converted from const", () => { const sanitized = normalizeSchemaForGoogle({ const: { a: 1 }, From 2f579e1ac1ddc2e9e7af1ad88688c69c6a955766 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 07:42:05 +0000 Subject: [PATCH 03/10] fix(ai): preserve literal schema payloads (cherry picked from commit 3631bc5bd0a27eabb774bf800e0144523d25a55b) --- packages/ai/src/utils/schema/normalize.ts | 42 ++++++++++++------- packages/ai/test/schema-normalization.test.ts | 2 +- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index 9b701b167..d45c2ab38 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -362,14 +362,19 @@ 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), - }); + const childInsideSchemaMap = !options.insideSchemaMap && Object.hasOwn(SUBSCHEMA_MAP_KEYS, key); + const childBooleanIsSubschema = + options.insideSchemaMap || + Object.hasOwn(SUBSCHEMA_VALUE_KEYS, key) || + Object.hasOwn(SUBSCHEMA_ARRAY_KEYS, key); + result[key] = + childInsideSchemaMap || childBooleanIsSubschema + ? normalizeSchemaNode(entry, { + ...options, + insideSchemaMap: childInsideSchemaMap, + booleanIsSubschema: childBooleanIsSubschema, + }) + : entry; } applyDescriptionSpill(result, spill, options); return applyNodePostProcessing(result, options); @@ -388,14 +393,19 @@ 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), - }); + const childInsideSchemaMap = !options.insideSchemaMap && Object.hasOwn(SUBSCHEMA_MAP_KEYS, key); + const childBooleanIsSubschema = + options.insideSchemaMap || + Object.hasOwn(SUBSCHEMA_VALUE_KEYS, key) || + Object.hasOwn(SUBSCHEMA_ARRAY_KEYS, key); + result[key] = + childInsideSchemaMap || childBooleanIsSubschema + ? normalizeSchemaNode(entry, { + ...options, + insideSchemaMap: childInsideSchemaMap, + booleanIsSubschema: childBooleanIsSubschema, + }) + : entry; } if (options.normalizeTypeArrayToNullable && Array.isArray(result.type)) { diff --git a/packages/ai/test/schema-normalization.test.ts b/packages/ai/test/schema-normalization.test.ts index 8339246b2..904f21c4b 100644 --- a/packages/ai/test/schema-normalization.test.ts +++ b/packages/ai/test/schema-normalization.test.ts @@ -228,7 +228,7 @@ describe("normalizeSchemaForGoogle", () => { }), ).toEqual({ type: "object", - default: { enum: [1], type: "number", value: 2 }, + default: { enum: [1], value: 2 }, properties: {}, }); }); From 34069920a67ca0392b45d6e73ad0038f1034a947 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 10:04:42 +0000 Subject: [PATCH 04/10] fix(ai): normalize map value schemas (cherry picked from commit 836951121076394b775771b29070f1022d7591c7) --- packages/ai/CHANGELOG.md | 4 +--- packages/ai/src/utils/schema/normalize.ts | 23 +++++++++++++++---- packages/ai/test/schema-normalization.test.ts | 15 ++++++++++++ 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 192a9f446..e4bb1d7bc 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -33,6 +33,7 @@ - Fixed an HTTP 400 error when resuming or replaying OpenAI history after an interrupted native Computer Use turn. - Fixed connection 404 errors when using Google Vertex AI in multi-region locations (eu and us) by correctly resolving regional endpoint (REP) hosts. - Fixed a resource leak in SqliteAuthCredentialStore.close() where unclosed prepared statements kept the SQLite connection alive, preventing database file cleanup (especially on Windows where files remained locked). +- 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.7] - 2026-07-27 @@ -58,9 +59,6 @@ - Kimi Code now sends its session-stable prompt cache key on both supported transports: `prompt_cache_key` for OpenAI-compatible requests and `metadata.user_id` for Anthropic-compatible requests. Explicit keys survive side-channel session IDs, while `cacheRetention: "none"` still disables automatic affinity ([#6049](https://github.com/can1357/oh-my-pi/issues/6049)). - Fresh encrypted auth-broker snapshot caches are revalidated within a short startup budget, so one-shot clients see newly imported or revoked credentials immediately when the broker is reachable while retaining cache fallback for transport and server failures. - Fixed custom `anthropic-messages` endpoints dropping native web-search call/result blocks in the leaked-thinking wrapper, preserving signed continuation history in source order without carrying a preceding text signature onto later unsigned blocks ([#6703](https://github.com/can1357/oh-my-pi/issues/6703)). -### Fixed - -- 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.4] - 2026-07-26 diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index d45c2ab38..7c7d2c2cb 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -107,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, @@ -367,12 +376,15 @@ function normalizeSchemaObjectNode(value: JsonObject, options: NormalizeSchemaWa options.insideSchemaMap || Object.hasOwn(SUBSCHEMA_VALUE_KEYS, key) || Object.hasOwn(SUBSCHEMA_ARRAY_KEYS, key); + const childIsSubschema = + childBooleanIsSubschema || + (Object.hasOwn(BOOLEAN_OR_SCHEMA_VALUE_KEYS, key) && isJsonObject(entry)); result[key] = - childInsideSchemaMap || childBooleanIsSubschema + childInsideSchemaMap || childIsSubschema ? normalizeSchemaNode(entry, { ...options, insideSchemaMap: childInsideSchemaMap, - booleanIsSubschema: childBooleanIsSubschema, + booleanIsSubschema: childIsSubschema, }) : entry; } @@ -398,12 +410,15 @@ function normalizeSchemaObjectNode(value: JsonObject, options: NormalizeSchemaWa options.insideSchemaMap || Object.hasOwn(SUBSCHEMA_VALUE_KEYS, key) || Object.hasOwn(SUBSCHEMA_ARRAY_KEYS, key); + const childIsSubschema = + childBooleanIsSubschema || + (Object.hasOwn(BOOLEAN_OR_SCHEMA_VALUE_KEYS, key) && isJsonObject(entry)); result[key] = - childInsideSchemaMap || childBooleanIsSubschema + childInsideSchemaMap || childIsSubschema ? normalizeSchemaNode(entry, { ...options, insideSchemaMap: childInsideSchemaMap, - booleanIsSubschema: childBooleanIsSubschema, + booleanIsSubschema: childIsSubschema, }) : entry; } diff --git a/packages/ai/test/schema-normalization.test.ts b/packages/ai/test/schema-normalization.test.ts index 904f21c4b..b49c2e7fb 100644 --- a/packages/ai/test/schema-normalization.test.ts +++ b/packages/ai/test/schema-normalization.test.ts @@ -1305,6 +1305,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({ From 05d5b8c12d5e829cac4906a76b03ce7a6f154c02 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 10:33:21 +0000 Subject: [PATCH 05/10] style(ai): format schema normalization (cherry picked from commit aee3d7e5e26c3a5cb3e950266a2561ece4b1d20c) --- packages/ai/src/utils/schema/normalize.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index 7c7d2c2cb..237ed3f2f 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -377,8 +377,7 @@ function normalizeSchemaObjectNode(value: JsonObject, options: NormalizeSchemaWa Object.hasOwn(SUBSCHEMA_VALUE_KEYS, key) || Object.hasOwn(SUBSCHEMA_ARRAY_KEYS, key); const childIsSubschema = - childBooleanIsSubschema || - (Object.hasOwn(BOOLEAN_OR_SCHEMA_VALUE_KEYS, key) && isJsonObject(entry)); + childBooleanIsSubschema || (Object.hasOwn(BOOLEAN_OR_SCHEMA_VALUE_KEYS, key) && isJsonObject(entry)); result[key] = childInsideSchemaMap || childIsSubschema ? normalizeSchemaNode(entry, { @@ -411,8 +410,7 @@ function normalizeSchemaObjectNode(value: JsonObject, options: NormalizeSchemaWa Object.hasOwn(SUBSCHEMA_VALUE_KEYS, key) || Object.hasOwn(SUBSCHEMA_ARRAY_KEYS, key); const childIsSubschema = - childBooleanIsSubschema || - (Object.hasOwn(BOOLEAN_OR_SCHEMA_VALUE_KEYS, key) && isJsonObject(entry)); + childBooleanIsSubschema || (Object.hasOwn(BOOLEAN_OR_SCHEMA_VALUE_KEYS, key) && isJsonObject(entry)); result[key] = childInsideSchemaMap || childIsSubschema ? normalizeSchemaNode(entry, { From 2ee5a10923efa8fa48a9e34d1f78a8b032e078be Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 10:46:36 +0000 Subject: [PATCH 06/10] fix(ai): preserve CCA literal payloads (cherry picked from commit b026a96326e3d7df89771f3224476dc37eeeaa9c) --- packages/ai/src/utils/schema/normalize.ts | 114 ++++++++++-------- packages/ai/test/schema-normalization.test.ts | 21 ++++ 2 files changed, 85 insertions(+), 50 deletions(-) diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index 237ed3f2f..5c62392fc 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -134,6 +134,17 @@ 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"; + if (Object.hasOwn(SUBSCHEMA_MAP_KEYS, key)) return "map"; + if (Object.hasOwn(SUBSCHEMA_VALUE_KEYS, key) || Object.hasOwn(SUBSCHEMA_ARRAY_KEYS, key)) return "schema"; + if (Object.hasOwn(BOOLEAN_OR_SCHEMA_VALUE_KEYS, key) && isJsonObject(value)) return "schema"; + return undefined; +} + const CLOUD_CODE_ASSIST_CLAUDE_FALLBACK_SCHEMA = { type: "object", properties: {}, @@ -371,21 +382,14 @@ function normalizeSchemaObjectNode(value: JsonObject, options: NormalizeSchemaWa continue; } if (options.stripNullableKeyword && key === "nullable") continue; - const childInsideSchemaMap = !options.insideSchemaMap && Object.hasOwn(SUBSCHEMA_MAP_KEYS, key); - const childBooleanIsSubschema = - options.insideSchemaMap || - Object.hasOwn(SUBSCHEMA_VALUE_KEYS, key) || - Object.hasOwn(SUBSCHEMA_ARRAY_KEYS, key); - const childIsSubschema = - childBooleanIsSubschema || (Object.hasOwn(BOOLEAN_OR_SCHEMA_VALUE_KEYS, key) && isJsonObject(entry)); - result[key] = - childInsideSchemaMap || childIsSubschema - ? normalizeSchemaNode(entry, { - ...options, - insideSchemaMap: childInsideSchemaMap, - booleanIsSubschema: childIsSubschema, - }) - : entry; + 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); @@ -404,21 +408,14 @@ function normalizeSchemaObjectNode(value: JsonObject, options: NormalizeSchemaWa constValue = entry; continue; } - const childInsideSchemaMap = !options.insideSchemaMap && Object.hasOwn(SUBSCHEMA_MAP_KEYS, key); - const childBooleanIsSubschema = - options.insideSchemaMap || - Object.hasOwn(SUBSCHEMA_VALUE_KEYS, key) || - Object.hasOwn(SUBSCHEMA_ARRAY_KEYS, key); - const childIsSubschema = - childBooleanIsSubschema || (Object.hasOwn(BOOLEAN_OR_SCHEMA_VALUE_KEYS, key) && isJsonObject(entry)); - result[key] = - childInsideSchemaMap || childIsSubschema - ? normalizeSchemaNode(entry, { - ...options, - insideSchemaMap: childInsideSchemaMap, - booleanIsSubschema: childIsSubschema, - }) - : entry; + 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)) { @@ -764,16 +761,27 @@ 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) { @@ -872,6 +880,7 @@ function normalizeNullablePropertiesForCloudCodeAssist( value: unknown, isPropertySchema = false, epoch: number = epochNext(), + insideSchemaMap = false, ): NullableNormalizationResult { if (Array.isArray(value)) { if (!once(value, epoch)) { @@ -891,9 +900,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; @@ -965,7 +979,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; @@ -974,24 +988,24 @@ 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; + for (const key in value) { + if (!Object.hasOwn(value, key)) continue; + const entry = value[key]; + const childKind = classifySchemaChild(key, entry, insideSchemaMap); if ( - hasResidualSchemaIncompatibilities( - value[k], - checks, - epoch, - !insideSchemaMap && Object.hasOwn(SUBSCHEMA_MAP_KEYS, k), - ) + childKind && + hasResidualSchemaIncompatibilities(entry, checks, epoch, childKind === "map") ) { return true; } diff --git a/packages/ai/test/schema-normalization.test.ts b/packages/ai/test/schema-normalization.test.ts index b49c2e7fb..d079ebf71 100644 --- a/packages/ai/test/schema-normalization.test.ts +++ b/packages/ai/test/schema-normalization.test.ts @@ -233,6 +233,27 @@ describe("normalizeSchemaForGoogle", () => { }); }); + 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 }, From b8c5f1544edbd010396eae066a32132437eefcd0 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 10:47:32 +0000 Subject: [PATCH 07/10] style(ai): format schema traversal fix (cherry picked from commit ffa704ed0abdd6f27d0ce56d8c97ccd64d025010) --- packages/ai/src/utils/schema/normalize.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index 5c62392fc..d64d09c51 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -776,9 +776,7 @@ function stripResidualCombinersNode(value: unknown, epoch: number, insideSchemaM 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; + result[key] = childKind ? stripResidualCombinersNode(entry, epoch, childKind === "map") : entry; } if (insideSchemaMap) return result; @@ -1003,10 +1001,7 @@ function hasResidualSchemaIncompatibilities( if (!Object.hasOwn(value, key)) continue; const entry = value[key]; const childKind = classifySchemaChild(key, entry, insideSchemaMap); - if ( - childKind && - hasResidualSchemaIncompatibilities(entry, checks, epoch, childKind === "map") - ) { + if (childKind && hasResidualSchemaIncompatibilities(entry, checks, epoch, childKind === "map")) { return true; } } From b9b17a6cbc56840a5f10deddda432db26767bc8a Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 15:27:55 +0000 Subject: [PATCH 08/10] docs: keep enum fix unreleased (cherry picked from commit c90b6302e8e7eab30f5a0c635e6373bae1fdc90d) --- packages/ai/CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index e4bb1d7bc..5ec5238e4 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -25,6 +25,9 @@ - Fixed direct Anthropic Claude Opus requests failing with HTTP 400 when the endpoint rejects strict tool fields. - Fixed usage-based credential ranking for Anthropic accounts where a missing long-window (7-day) metric was incorrectly treated as a short-window metric. - Fixed legacy Codex usage blocks continuing to gate all models after per-meter backoff was introduced, splitting the old shared scope into independent chat and spark blocks while maintaining backward compatibility with older clients and database schemas. +### Fixed + +- 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 @@ -33,7 +36,6 @@ - Fixed an HTTP 400 error when resuming or replaying OpenAI history after an interrupted native Computer Use turn. - Fixed connection 404 errors when using Google Vertex AI in multi-region locations (eu and us) by correctly resolving regional endpoint (REP) hosts. - Fixed a resource leak in SqliteAuthCredentialStore.close() where unclosed prepared statements kept the SQLite connection alive, preventing database file cleanup (especially on Windows where files remained locked). -- 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.7] - 2026-07-27 From 6dcd28c3025bbe55f2593e2a7cef62d55c9d536c Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 16:21:32 +0000 Subject: [PATCH 09/10] fix(ai): preserve negated enum semantics (cherry picked from commit c96aa0d61445de12597abf76c397ce5f9707bcb8) --- packages/ai/src/utils/schema/normalize.ts | 57 +++++++++++++++++++++ packages/ai/test/google-tool-schema.test.ts | 10 ++++ 2 files changed, 67 insertions(+) diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index d64d09c51..12a43c3e5 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -145,6 +145,47 @@ function classifySchemaChild(key: string, value: unknown, insideSchemaMap: boole 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: {}, @@ -382,6 +423,14 @@ function normalizeSchemaObjectNode(value: JsonObject, options: NormalizeSchemaWa continue; } if (options.stripNullableKeyword && key === "nullable") continue; + if ( + options.stringEnumsOnly && + !options.insideSchemaMap && + key === "not" && + hasUnrepresentableGoogleEnumConstraint(entry) + ) { + continue; + } const childKind = classifySchemaChild(key, entry, options.insideSchemaMap); result[key] = childKind ? normalizeSchemaNode(entry, { @@ -408,6 +457,14 @@ function normalizeSchemaObjectNode(value: JsonObject, options: NormalizeSchemaWa constValue = entry; continue; } + if ( + options.stringEnumsOnly && + !options.insideSchemaMap && + key === "not" && + hasUnrepresentableGoogleEnumConstraint(entry) + ) { + continue; + } const childKind = classifySchemaChild(key, entry, options.insideSchemaMap); result[key] = childKind ? normalizeSchemaNode(entry, { diff --git a/packages/ai/test/google-tool-schema.test.ts b/packages/ai/test/google-tool-schema.test.ts index 4bf0affff..6a8c3465f 100644 --- a/packages/ai/test/google-tool-schema.test.ts +++ b/packages/ai/test/google-tool-schema.test.ts @@ -593,6 +593,16 @@ describe("normalizeSchemaForGoogle parity with python-genai process_schema", () 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({}); + }); + // Mirrors python-genai test_schema.py::test_process_schema_order_properties_propagates_into_defs it("propagates auto propertyOrdering into inlined $defs targets", () => { const schema = { From 47ac33aa1f812c36201baadb49f68d4653f4b9da Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 28 Jul 2026 16:33:58 +0000 Subject: [PATCH 10/10] fix(ai): inspect snake-case schema children (cherry picked from commit 41590a873ca838f9ac3b54e1008fd7c97424c3d3) --- packages/ai/src/utils/schema/normalize.ts | 9 ++++++--- packages/ai/test/google-tool-schema.test.ts | 4 ++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index 12a43c3e5..d904d7685 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -139,9 +139,12 @@ 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"; - if (Object.hasOwn(SUBSCHEMA_MAP_KEYS, key)) return "map"; - if (Object.hasOwn(SUBSCHEMA_VALUE_KEYS, key) || Object.hasOwn(SUBSCHEMA_ARRAY_KEYS, key)) return "schema"; - if (Object.hasOwn(BOOLEAN_OR_SCHEMA_VALUE_KEYS, key) && isJsonObject(value)) 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; } diff --git a/packages/ai/test/google-tool-schema.test.ts b/packages/ai/test/google-tool-schema.test.ts index 6a8c3465f..2370aab9d 100644 --- a/packages/ai/test/google-tool-schema.test.ts +++ b/packages/ai/test/google-tool-schema.test.ts @@ -603,6 +603,10 @@ describe("normalizeSchemaForGoogle parity with python-genai process_schema", () ).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 it("propagates auto propertyOrdering into inlined $defs targets", () => { const schema = {