From f9dd333568965dace77020f8eb5f02f8eaf74e4e Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 16 Oct 2026 19:05:08 -0700 Subject: [PATCH 1/2] fix: drop empty-string enum values from Gemini tool schemas The Gemini API rejects a function declaration whose `enum`/`const` contains an empty string. `answer_commit`'s `optional()` union accepts `z.literal("")`, so every Gemini request that declared it failed with a 400 before the model ran. - The Gemini branch of `ProviderTransform.schema` now drops empty-string `enum` values and `const: ""`, keeping the branch type. Zod still validates the actual tool arguments. - `tool-schema-gemini.test.ts` runs every registered analyst/builder tool through the Google and Vertex transforms and asserts no empty enum/const remains, plus a unit case for the sanitizer. Closes #1531 Co-Authored-By: Claude Opus 5.5 --- packages/opencode/src/provider/transform.ts | 9 +- .../altimate/tools/tool-schema-gemini.test.ts | 107 ++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/altimate/tools/tool-schema-gemini.test.ts diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 7602d92af5..da3262e172 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1613,9 +1613,16 @@ export namespace ProviderTransform { const result: any = {} for (const [key, value] of Object.entries(obj)) { + // altimate_change start — Gemini rejects empty-string enum/const values ("cannot be empty"); + // drop them and keep the branch's type. Tool arguments are still validated by Zod at runtime. + if (key === "const" && value === "") continue + if (key === "enum" && Array.isArray(value) && value.every((v) => String(v) === "")) continue + // altimate_change end if (key === "enum" && Array.isArray(value)) { // Convert all enum values to strings - result[key] = value.map((v) => String(v)) + // altimate_change start — skip empty-string values Gemini rejects + result[key] = value.map((v) => String(v)).filter((v) => v !== "") + // altimate_change end // If we have integer type with enum, change type to string if (result.type === "integer" || result.type === "number") { result.type = "string" diff --git a/packages/opencode/test/altimate/tools/tool-schema-gemini.test.ts b/packages/opencode/test/altimate/tools/tool-schema-gemini.test.ts new file mode 100644 index 0000000000..bca8eeddbd --- /dev/null +++ b/packages/opencode/test/altimate/tools/tool-schema-gemini.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Agent } from "@/agent/agent" +import { Config } from "@/config/config" +import { RuntimeFlags } from "@/effect/runtime-flags" +import type { Provider } from "@/provider/provider" +import { ModelID, ProviderID } from "@/provider/schema" +import { ProviderTransform } from "@/provider/transform" +import { ToolJsonSchema } from "@/tool/json-schema" +import { ToolRegistry } from "@/tool/registry" +import { TestConfig } from "../../fixture/config" +import { disposeAllInstances } from "../../fixture/fixture" +import { testEffect } from "../../lib/effect" + +const it = testEffect( + LayerNode.buildLayer(LayerNode.group([ToolRegistry.node, Agent.node]), { + replacements: [ + LayerNode.replace(Config.node, TestConfig.layer()), + LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer({ enableParallel: false })), + ], + }), +) + +afterEach(disposeAllInstances) + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +// Gemini rejects a function declaration whose enum (or const) contains an empty string: +// "function_declarations[i].parameters...enum[0]: cannot be empty". +function emptyEnumValues(schema: unknown, location = "#"): string[] { + if (Array.isArray(schema)) return schema.flatMap((child, index) => emptyEnumValues(child, `${location}/${index}`)) + if (!isObject(schema)) return [] + const found: string[] = [] + if (Array.isArray(schema.enum) && schema.enum.some((value) => String(value) === "")) found.push(`${location}/enum`) + if (schema.const === "") found.push(`${location}/const`) + for (const [key, child] of Object.entries(schema)) { + if (key === "enum" || key === "const" || key === "default") continue + found.push(...emptyEnumValues(child, `${location}/${key}`)) + } + return found +} + +function model(providerID: ProviderID): Provider.Model { + const id = "gemini-4-argon" + return { + id: ModelID.make(id), + providerID, + api: { id, npm: providerID === ProviderID.google ? "@ai-sdk/google" : "@ai-sdk/google-vertex", url: "https://example.invalid" }, + name: id, + capabilities: { + temperature: true, + reasoning: true, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 1_000_000, output: 64_000 }, + status: "active", + release_date: "2026-01-01", + options: {}, + headers: {}, + } +} + +describe("Gemini tool JSON schemas", () => { + for (const providerID of [ProviderID.google, ProviderID.googleVertex]) { + for (const name of ["analyst", "builder"]) { + it.instance(`${providerID}: no registered ${name} tool declares an empty-string enum or const`, () => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const agents = yield* Agent.Service + const agent = yield* agents.get(name) + if (!agent) throw new Error(`${name} agent not found`) + const target = model(providerID) + const tools = yield* registry.tools({ providerID, modelID: target.id, agent }) + expect(tools.some((tool) => tool.id === "answer_commit")).toBe(name === "analyst") + const errors = tools.flatMap((tool) => + emptyEnumValues(ProviderTransform.schema(target, ToolJsonSchema.fromTool(tool))).map( + (location) => `${tool.id}: ${location}`, + ), + ) + expect(errors).toEqual([]) + }), + ) + } + } + + test("drops empty-string enum and const values but keeps the remaining enum", () => { + const result = ProviderTransform.schema(model(ProviderID.googleVertex), { + type: "object", + properties: { + unresolved: { anyOf: [{ type: "object", properties: {} }, { type: "string", enum: [""] }] }, + blank: { type: "string", const: "" }, + mode: { type: "string", enum: ["", "fast", "slow"] }, + }, + } as any) as any + expect(result.properties.unresolved.anyOf[1]).toEqual({ type: "string" }) + expect(result.properties.blank).toEqual({ type: "string" }) + expect(result.properties.mode.enum).toEqual(["fast", "slow"]) + }) +}) From e4a56e59b4b8328c1b9a7f66f44b3f4dcc78c236 Mon Sep 17 00:00:00 2001 From: anandgupta42 Date: Fri, 16 Oct 2026 19:15:38 -0700 Subject: [PATCH 2/2] fix: keep `type: "string"` when an untyped empty `const`/`enum` is dropped for Gemini - Dropping an empty-string `const`/`enum` from a node without `type` left `{}`, which accepts any value; set `type: "string"` instead - Leave an empty `enum: []` untouched rather than treating it as all-empty-strings - Add tests for untyped `const`, untyped `enum`, nullable literal, and empty `enum` Co-Authored-By: Claude Opus 5.5 --- packages/opencode/src/provider/transform.ts | 17 +++++++++++++++-- .../altimate/tools/tool-schema-gemini.test.ts | 16 ++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index da3262e172..ff92912faa 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1612,11 +1612,20 @@ export namespace ProviderTransform { } const result: any = {} + // altimate_change start — track dropped empty literals so an untyped node stays a string + let droppedEmptyLiteral = false + // altimate_change end for (const [key, value] of Object.entries(obj)) { // altimate_change start — Gemini rejects empty-string enum/const values ("cannot be empty"); // drop them and keep the branch's type. Tool arguments are still validated by Zod at runtime. - if (key === "const" && value === "") continue - if (key === "enum" && Array.isArray(value) && value.every((v) => String(v) === "")) continue + if (key === "const" && value === "") { + droppedEmptyLiteral = true + continue + } + if (key === "enum" && Array.isArray(value) && value.length > 0 && value.every((v) => String(v) === "")) { + droppedEmptyLiteral = true + continue + } // altimate_change end if (key === "enum" && Array.isArray(value)) { // Convert all enum values to strings @@ -1634,6 +1643,10 @@ export namespace ProviderTransform { } } + // altimate_change start — an untyped node that lost its empty literal would become `{}` (any value) + if (droppedEmptyLiteral && result.type === undefined) result.type = "string" + // altimate_change end + // altimate_change start — Gemini requires a single type; split JSON Schema type arrays if (Array.isArray(result.type)) { const hasNull = result.type.includes("null") diff --git a/packages/opencode/test/altimate/tools/tool-schema-gemini.test.ts b/packages/opencode/test/altimate/tools/tool-schema-gemini.test.ts index bca8eeddbd..68424ff6fe 100644 --- a/packages/opencode/test/altimate/tools/tool-schema-gemini.test.ts +++ b/packages/opencode/test/altimate/tools/tool-schema-gemini.test.ts @@ -104,4 +104,20 @@ describe("Gemini tool JSON schemas", () => { expect(result.properties.blank).toEqual({ type: "string" }) expect(result.properties.mode.enum).toEqual(["fast", "slow"]) }) + + test("keeps an untyped node a string when its empty const or enum is dropped", () => { + const result = ProviderTransform.schema(model(ProviderID.googleVertex), { + type: "object", + properties: { + constOnly: { anyOf: [{ const: "" }, { type: "number" }] }, + enumOnly: { enum: [""] }, + nullable: { type: ["string", "null"], const: "" }, + emptyEnum: { type: "string", enum: [] }, + }, + } as any) as any + expect(result.properties.constOnly.anyOf[0]).toEqual({ type: "string" }) + expect(result.properties.enumOnly).toEqual({ type: "string" }) + expect(result.properties.nullable).toEqual({ anyOf: [{ type: "string" }], nullable: true }) + expect(result.properties.emptyEnum.enum).toEqual([]) + }) })