Merge PR #6734: fix(ai): omit unsupported Google enum values (@usr-bin-roygbiv)

This commit is contained in:
can1357
2026-07-30 01:27:35 +02:00
4 changed files with 233 additions and 54 deletions
+1
View File
@@ -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
+140 -38
View File
@@ -55,6 +55,7 @@ export interface NormalizeSchemaOptions {
inferTypeForBareEnum: boolean;
foldOneOfIntoAnyOf: boolean;
dropNonScalarEnum: boolean;
stringEnumsOnly?: boolean;
rejectResidualIncompatibilities?: ReadonlyArray<ResidualSchemaIncompatibility>;
validateAndFallback?: { fallback: unknown };
}
@@ -106,6 +107,15 @@ const SUBSCHEMA_VALUE_KEYS: Record<string, true> = {
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<string, true> = {
additionalProperties: true,
unevaluatedProperties: true,
};
/** Keywords whose value is an array of subschemas. */
const SUBSCHEMA_ARRAY_KEYS: Record<string, true> = {
anyOf: true,
@@ -124,6 +134,61 @@ const SUBSCHEMA_MAP_KEYS: Record<string, true> = {
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<object>(),
): 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,
});
}
+20 -8
View File
@@ -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<string, unknown>;
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
+72 -8
View File
@@ -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<string, unknown> };
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<string, unknown>;
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<string, unknown>;
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({