Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 12 additions & 8 deletions src/converters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions src/json-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,3 +380,38 @@ export function toJsonSchema(schema: unknown): unknown {
if (!isRecord(schema)) return {}
return convertSchema(schema, new WeakSet<object>())
}

/**
* 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<string, unknown> = {}
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
}
130 changes: 130 additions & 0 deletions tests/test-pure-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> },
]
const properties = (tool.input_schema.properties ?? {}) as Record<string, unknown>
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<string, unknown>).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<string, unknown>).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<string, unknown> },
]
const properties = (tool.input_schema.properties ?? {}) as Record<string, unknown>
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<string, unknown> },
]
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<string, unknown> }]
assert.deepEqual(tool.input_schema, toJsonSchema(nullableTool.parameters))
})
})

describe("messagesToCC()", () => {
it("converts user, assistant, and tool result messages", () => {
const result = messagesToCC([
Expand Down
45 changes: 45 additions & 0 deletions tests/test-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading