Files
OmniRoute/tests/unit/12308-gemini-response-schema-nullable.test.ts
Amirreza Kimiyaei b84059213f fix(gemini): preserve response-schema nullability across union flattening (#12310)
* fix(gemini): preserve response-schema nullability across union flattening

cleanJSONSchemaForAntigravity flattens every union spelling of nullable before
the schema reaches Gemini: flattenTypeArrays turns ["string","null"] into
"string" and flattenAnyOfOneOf drops the {"type":"null"} branch. Correct for
tool parameters, wrong for response schemas — a model with nothing to say can
no longer answer null, so it returns the string "null" or fabricates a value,
and either reaches the client as schema-conformant data. Pydantic emits the
anyOf spelling for Optional[str], so the fabricating path is the common one.

A Phase 1b walk now records Gemini's sibling-key spelling, nullable: true, on
any node whose union carries null — before Phase 2 destroys the evidence. The
key is absent from GEMINI_UNSUPPORTED_SCHEMA_KEYS so it survives sanitizing,
and flattenAnyOfOneOf's Object.assign cannot clobber a key the surviving
branch lacks. Opt-in via { preserveNullable: true }, passed only by the
responseSchema call site; the three tool-parameter call sites keep the default.

Closes #12308

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(changelog): add fragment for #12308 gemini nullable schema fix

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 12:21:02 -03:00

82 lines
3.2 KiB
TypeScript

// Regression for #12308: cleanJSONSchemaForAntigravity flattened every union
// spelling of "nullable" (type arrays, anyOf, oneOf) before the schema reached
// Gemini, which is correct for tool parameters and wrong for response schemas —
// a model with nothing to say could no longer answer null, so it returned the
// *string* "null" or fabricated a value. With { preserveNullable: true } the
// union is recorded as Gemini's `nullable: true` sibling key before Phase 2
// destroys it; the key is absent from GEMINI_UNSUPPORTED_SCHEMA_KEYS, so it
// survives sanitizing. Tool parameters keep the default (no flag, no change).
import test from "node:test";
import assert from "node:assert/strict";
const { cleanJSONSchemaForAntigravity, GEMINI_UNSUPPORTED_SCHEMA_KEYS } = await import(
"../../open-sse/translator/helpers/geminiHelper.ts"
);
const clean = (schema: unknown) => cleanJSONSchemaForAntigravity(schema, { preserveNullable: true });
const valueProp = (out: unknown) =>
(out as { properties: { value: Record<string, unknown> } }).properties.value;
const wrap = (value: unknown) => ({
type: "object",
properties: { value },
required: ["value"],
additionalProperties: false,
});
test("type-array union survives as nullable: true", () => {
const out = valueProp(clean(wrap({ type: ["string", "null"] })));
assert.equal(out.type, "string");
assert.equal(out.nullable, true);
});
test("anyOf union survives as nullable: true (the Pydantic Optional[str] shape)", () => {
const out = valueProp(clean(wrap({ anyOf: [{ type: "string" }, { type: "null" }] })));
assert.equal(out.type, "string");
assert.equal(out.nullable, true);
});
test("oneOf union survives as nullable: true", () => {
const out = valueProp(clean(wrap({ oneOf: [{ type: "number" }, { type: "null" }] })));
assert.equal(out.type, "number");
assert.equal(out.nullable, true);
});
test("nested nullable fields are marked too", () => {
const out = clean({
type: "object",
properties: {
items: {
type: "array",
items: { type: "object", properties: { note: { type: ["string", "null"] } } },
},
},
}) as Record<string, never>;
const note = out.properties["items"]["items"]["properties"]["note"];
assert.equal(note.type, "string");
assert.equal(note.nullable, true);
});
test("a union without a null branch is left alone", () => {
const out = valueProp(clean(wrap({ anyOf: [{ type: "string" }, { type: "number" }] })));
assert.ok(!("nullable" in out));
});
test("an explicit nullable: true sent by the caller still passes through", () => {
const out = valueProp(clean(wrap({ type: "string", nullable: true })));
assert.equal(out.type, "string");
assert.equal(out.nullable, true);
});
test("default call (tool parameters) is unchanged: unions flatten, nothing is marked", () => {
const out = cleanJSONSchemaForAntigravity(wrap({ type: ["string", "null"] })) as {
properties: { value: Record<string, unknown> };
};
assert.equal(out.properties.value.type, "string");
assert.ok(!("nullable" in out.properties.value));
});
test("guard: nullable must stay out of GEMINI_UNSUPPORTED_SCHEMA_KEYS or the fix silently dies", () => {
assert.ok(!GEMINI_UNSUPPORTED_SCHEMA_KEYS.has("nullable"));
});