diff --git a/CHANGELOG.md b/CHANGELOG.md index 9545c6c..c34c53a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Normalize nullable type arrays for `google/gemini-*` tools on the generate transport to avoid the gateway's `any_of` validation error (#99), preserving required fields, literal data, and schemas for unrelated models. + ## 0.7.0 - 2026-09-15 - Honor host-resolved image input on the generate transport, including explicit text-only restrictions; fall back to catalog metadata only when host input is absent or empty. Filter unsupported modality strings without unsafe casts. diff --git a/src/converters.ts b/src/converters.ts index 7a1a1eb..4d028a9 100644 --- a/src/converters.ts +++ b/src/converters.ts @@ -3,7 +3,7 @@ import { homedir } from "node:os" import { join } from "node:path" import type { MessageLike, StopReason, ToolLike } from "./types.ts" -import { toJsonSchema } from "./json-schema.ts" +import { geminiSafeJsonSchema, toJsonSchema } from "./json-schema.ts" export { toJsonSchema } from "./json-schema.ts" @@ -200,14 +200,18 @@ export function getEnvironmentInfo(): string { return `${process.platform}-${process.arch}, Node.js ${process.version}` } -export function toolsToJson(tools?: readonly ToolLike[]): unknown[] { +export function toolsToJson(tools?: readonly ToolLike[], modelId?: string): unknown[] { if (!tools) return [] - return tools.map((tool) => ({ - type: "function", - name: tool.name, - description: tool.description, - input_schema: tool.parameters ? toJsonSchema(tool.parameters) : {}, - })) + const geminiSafe = modelId !== undefined && modelId.startsWith("google/gemini-") + return tools.map((tool) => { + const schema = tool.parameters ? toJsonSchema(tool.parameters) : {} + return { + type: "function", + name: tool.name, + description: tool.description, + input_schema: geminiSafe ? geminiSafeJsonSchema(schema) : schema, + } + }) } interface ToolCallState { diff --git a/src/core.ts b/src/core.ts index 51e2d5b..e178096 100644 --- a/src/core.ts +++ b/src/core.ts @@ -561,7 +561,7 @@ export function createStreamCommandCode(deps: CoreDependencies) { params: { model: model.id, messages: messagesToCC(context.messages, { allowImages }), - tools: toolsToJson(context.tools), + tools: toolsToJson(context.tools, model.id), system: systemPromptToText(context.systemPrompt), max_tokens: generateMaxTokens(model, options), stream: true, diff --git a/src/json-schema.ts b/src/json-schema.ts index a2ffc02..84b904a 100644 --- a/src/json-schema.ts +++ b/src/json-schema.ts @@ -380,3 +380,38 @@ export function toJsonSchema(schema: unknown): unknown { if (!isRecord(schema)) return {} return convertSchema(schema, new WeakSet()) } + +/** + * Normalize nullable type arrays for Gemini's generate transport (#99). + * The gateway can turn these into any_of with sibling fields, which Vertex + * rejects. Use a single type plus nullable, omit null defaults, and leave + * genuine multi-type unions and required fields unchanged. + */ +export function geminiSafeJsonSchema(schema: unknown): unknown { + if (Array.isArray(schema)) return schema.map(geminiSafeJsonSchema) + if (!isRecord(schema)) return schema + const out: Record = {} + for (const [key, value] of Object.entries(schema)) { + if (key === "default" && value === null) continue + // Literal values (const, enum, defaults, examples) are data, not schemas. + const converted = + SCHEMA_MAP_FIELDS.has(key) && isRecord(value) + ? Object.fromEntries( + Object.entries(value).map(([name, child]) => [name, geminiSafeJsonSchema(child)]), + ) + : SCHEMA_ARRAY_FIELDS.has(key) || SCHEMA_VALUE_FIELDS.has(key) + ? geminiSafeJsonSchema(value) + : value + setSchemaProperty(out, key, converted) + } + const type = out.type + if (Array.isArray(type)) { + const entries = type.filter((entry): entry is string => typeof entry === "string") + const nonNull = entries.filter((entry) => entry !== "null") + if (entries.length === type.length && entries.includes("null") && nonNull.length === 1) { + out.type = nonNull[0] + out.nullable = true + } + } + return out +} diff --git a/tests/test-pure-functions.ts b/tests/test-pure-functions.ts index 1bbb209..7a7932e 100644 --- a/tests/test-pure-functions.ts +++ b/tests/test-pure-functions.ts @@ -550,6 +550,136 @@ describe("toolsToJson()", () => { }) }) +describe("toolsToJson() gemini schema dialect", () => { + const nullableTool = { + name: "memory_auto_improve", + description: "Memory maintenance", + parameters: { + type: "object", + properties: { + min_confidence: { + description: "Override the proposal confidence floor for this run.", + type: ["number", "null"], + format: "float", + default: null, + }, + nested: { + type: "object", + properties: { flag: { type: ["boolean", "null"], default: null } }, + required: ["flag"], + }, + list: { type: "array", items: { type: ["string", "null"] } }, + multi: { type: ["string", "number"] }, + }, + required: ["nested"], + }, + } + + it("collapses null-terminated type unions for google/gemini-* models", () => { + const original = structuredClone(nullableTool) + const [tool] = toolsToJson([nullableTool], "google/gemini-3.8-flash") as [ + { input_schema: Record }, + ] + const properties = (tool.input_schema.properties ?? {}) as Record + assert.deepEqual(properties.min_confidence, { + description: "Override the proposal confidence floor for this run.", + type: "number", + format: "float", + nullable: true, + }) + assert.deepEqual((properties.nested as Record).properties, { + flag: { type: "boolean", nullable: true }, + }) + assert.deepEqual(properties.list, { type: "array", items: { type: "string", nullable: true } }) + assert.deepEqual(tool.input_schema.required, ["nested"]) + assert.deepEqual((properties.nested as Record).required, ["flag"]) + assert.deepEqual(nullableTool, original) + }) + + it("preserves literal data instead of treating it as a schema", () => { + const literal = { type: ["string", "null"], default: null } + const parameters = { + type: "object", + const: literal, + enum: [literal], + default: literal, + examples: [literal], + } + const [tool] = toolsToJson( + [{ name: "literal", description: "Literal data", parameters }], + "google/gemini-3.8-flash", + ) as [{ input_schema: unknown }] + assert.deepEqual(tool.input_schema, parameters) + }) + + it("preserves special property names when normalizing nullable schemas", () => { + const properties = Object.fromEntries([["__proto__", { type: ["string", "null"] }]]) + const [tool] = toolsToJson( + [ + { + name: "special", + description: "Special keys", + parameters: { + type: "object", + properties, + required: ["__proto__"], + }, + }, + ], + "google/gemini-3.8-flash", + ) as [{ input_schema: unknown }] + assert.deepEqual(tool.input_schema, { + type: "object", + properties: Object.fromEntries([["__proto__", { type: "string", nullable: true }]]), + required: ["__proto__"], + }) + }) + + it("normalizes definitions and composition while preserving boolean schemas", () => { + const [tool] = toolsToJson( + [ + { + name: "composed", + description: "Composed schema", + parameters: { + type: "object", + $defs: { value: { type: ["null", "string"] } }, + allOf: [{ properties: { value: { type: "string", nullable: true } } }], + additionalProperties: false, + }, + }, + ], + "google/gemini-3.8-flash", + ) as [{ input_schema: unknown }] + assert.deepEqual(tool.input_schema, { + type: "object", + $defs: { value: { type: "string", nullable: true } }, + allOf: [{ properties: { value: { type: "string", nullable: true } } }], + additionalProperties: false, + }) + }) + + it("keeps genuine multi-type unions untouched", () => { + const [tool] = toolsToJson([nullableTool], "google/gemini-3.8-flash") as [ + { input_schema: Record }, + ] + const properties = (tool.input_schema.properties ?? {}) as Record + assert.deepEqual(properties.multi, { type: ["string", "number"] }) + }) + + it("keeps schemas verbatim for non-gemini models", () => { + const [tool] = toolsToJson([nullableTool], "gpt-5.4") as [ + { input_schema: Record }, + ] + assert.deepEqual(tool.input_schema, toJsonSchema(nullableTool.parameters)) + }) + + it("keeps schemas verbatim when no model id is given", () => { + const [tool] = toolsToJson([nullableTool]) as [{ input_schema: Record }] + assert.deepEqual(tool.input_schema, toJsonSchema(nullableTool.parameters)) + }) +}) + describe("messagesToCC()", () => { it("converts user, assistant, and tool result messages", () => { const result = messagesToCC([ diff --git a/tests/test-stream.ts b/tests/test-stream.ts index 9a8a3a6..9a3486e 100644 --- a/tests/test-stream.ts +++ b/tests/test-stream.ts @@ -777,6 +777,51 @@ describe("streamCommandCode — request serialization", () => { assert.equal(headers["x-session-id"], undefined) }) + it("normalizes nullable tool parameters only for Gemini on the generate wire (#99)", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + const field = { + description: "Override the proposal confidence floor for this run.", + type: ["number", "null"], + format: "float", + default: null, + } + const parameters = { type: "object", properties: { min_confidence: field }, required: [] } + const context = makeContext({ + tools: [{ name: "memory_auto_improve", description: "Memory maintenance", parameters }], + }) + for (const [id, expectedField] of [ + ["gpt-5.4", field], + [ + "google/gemini-3.8-flash", + { + description: field.description, + type: "number", + format: "float", + nullable: true, + }, + ], + ] as const) { + const events = await collectEvents( + streamCommandCode(makeModel({ id }), context, { apiKey: "mock-key" }), + ) + assert.equal(events.at(-1)?.type, "done") + const body = server.lastRequestBody() + assert.equal(objectAt(body, ["params", "model"]), id) + assert.deepEqual(objectAt(body, ["params", "tools"]), [ + { + type: "function", + name: "memory_auto_improve", + description: "Memory maintenance", + input_schema: { ...parameters, properties: { min_confidence: expectedField } }, + }, + ]) + } + }) + it("sends developer advisories as user messages in position, without system hoisting", async () => { server.mockResponse({ type: "success",