Merge PR #6734: fix(ai): omit unsupported Google enum values (@usr-bin-roygbiv)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user