Skip to content

Commit f9dd333

Browse files
anandgupta42claude
andcommitted
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 <[email protected]>
1 parent 19f909c commit f9dd333

2 files changed

Lines changed: 115 additions & 1 deletion

File tree

‎packages/opencode/src/provider/transform.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1613,9 +1613,16 @@ export namespace ProviderTransform {
16131613

16141614
const result: any = {}
16151615
for (const [key, value] of Object.entries(obj)) {
1616+
// altimate_change start — Gemini rejects empty-string enum/const values ("cannot be empty");
1617+
// drop them and keep the branch's type. Tool arguments are still validated by Zod at runtime.
1618+
if (key === "const" && value === "") continue
1619+
if (key === "enum" && Array.isArray(value) && value.every((v) => String(v) === "")) continue
1620+
// altimate_change end
16161621
if (key === "enum" && Array.isArray(value)) {
16171622
// Convert all enum values to strings
1618-
result[key] = value.map((v) => String(v))
1623+
// altimate_change start — skip empty-string values Gemini rejects
1624+
result[key] = value.map((v) => String(v)).filter((v) => v !== "")
1625+
// altimate_change end
16191626
// If we have integer type with enum, change type to string
16201627
if (result.type === "integer" || result.type === "number") {
16211628
result.type = "string"
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { afterEach, describe, expect, test } from "bun:test"
2+
import { Effect } from "effect"
3+
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
4+
import { Agent } from "@/agent/agent"
5+
import { Config } from "@/config/config"
6+
import { RuntimeFlags } from "@/effect/runtime-flags"
7+
import type { Provider } from "@/provider/provider"
8+
import { ModelID, ProviderID } from "@/provider/schema"
9+
import { ProviderTransform } from "@/provider/transform"
10+
import { ToolJsonSchema } from "@/tool/json-schema"
11+
import { ToolRegistry } from "@/tool/registry"
12+
import { TestConfig } from "../../fixture/config"
13+
import { disposeAllInstances } from "../../fixture/fixture"
14+
import { testEffect } from "../../lib/effect"
15+
16+
const it = testEffect(
17+
LayerNode.buildLayer(LayerNode.group([ToolRegistry.node, Agent.node]), {
18+
replacements: [
19+
LayerNode.replace(Config.node, TestConfig.layer()),
20+
LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer({ enableParallel: false })),
21+
],
22+
}),
23+
)
24+
25+
afterEach(disposeAllInstances)
26+
27+
function isObject(value: unknown): value is Record<string, unknown> {
28+
return typeof value === "object" && value !== null && !Array.isArray(value)
29+
}
30+
31+
// Gemini rejects a function declaration whose enum (or const) contains an empty string:
32+
// "function_declarations[i].parameters...enum[0]: cannot be empty".
33+
function emptyEnumValues(schema: unknown, location = "#"): string[] {
34+
if (Array.isArray(schema)) return schema.flatMap((child, index) => emptyEnumValues(child, `${location}/${index}`))
35+
if (!isObject(schema)) return []
36+
const found: string[] = []
37+
if (Array.isArray(schema.enum) && schema.enum.some((value) => String(value) === "")) found.push(`${location}/enum`)
38+
if (schema.const === "") found.push(`${location}/const`)
39+
for (const [key, child] of Object.entries(schema)) {
40+
if (key === "enum" || key === "const" || key === "default") continue
41+
found.push(...emptyEnumValues(child, `${location}/${key}`))
42+
}
43+
return found
44+
}
45+
46+
function model(providerID: ProviderID): Provider.Model {
47+
const id = "gemini-4-argon"
48+
return {
49+
id: ModelID.make(id),
50+
providerID,
51+
api: { id, npm: providerID === ProviderID.google ? "@ai-sdk/google" : "@ai-sdk/google-vertex", url: "https://example.invalid" },
52+
name: id,
53+
capabilities: {
54+
temperature: true,
55+
reasoning: true,
56+
attachment: false,
57+
toolcall: true,
58+
input: { text: true, audio: false, image: false, video: false, pdf: false },
59+
output: { text: true, audio: false, image: false, video: false, pdf: false },
60+
interleaved: false,
61+
},
62+
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
63+
limit: { context: 1_000_000, output: 64_000 },
64+
status: "active",
65+
release_date: "2026-01-01",
66+
options: {},
67+
headers: {},
68+
}
69+
}
70+
71+
describe("Gemini tool JSON schemas", () => {
72+
for (const providerID of [ProviderID.google, ProviderID.googleVertex]) {
73+
for (const name of ["analyst", "builder"]) {
74+
it.instance(`${providerID}: no registered ${name} tool declares an empty-string enum or const`, () =>
75+
Effect.gen(function* () {
76+
const registry = yield* ToolRegistry.Service
77+
const agents = yield* Agent.Service
78+
const agent = yield* agents.get(name)
79+
if (!agent) throw new Error(`${name} agent not found`)
80+
const target = model(providerID)
81+
const tools = yield* registry.tools({ providerID, modelID: target.id, agent })
82+
expect(tools.some((tool) => tool.id === "answer_commit")).toBe(name === "analyst")
83+
const errors = tools.flatMap((tool) =>
84+
emptyEnumValues(ProviderTransform.schema(target, ToolJsonSchema.fromTool(tool))).map(
85+
(location) => `${tool.id}: ${location}`,
86+
),
87+
)
88+
expect(errors).toEqual([])
89+
}),
90+
)
91+
}
92+
}
93+
94+
test("drops empty-string enum and const values but keeps the remaining enum", () => {
95+
const result = ProviderTransform.schema(model(ProviderID.googleVertex), {
96+
type: "object",
97+
properties: {
98+
unresolved: { anyOf: [{ type: "object", properties: {} }, { type: "string", enum: [""] }] },
99+
blank: { type: "string", const: "" },
100+
mode: { type: "string", enum: ["", "fast", "slow"] },
101+
},
102+
} as any) as any
103+
expect(result.properties.unresolved.anyOf[1]).toEqual({ type: "string" })
104+
expect(result.properties.blank).toEqual({ type: "string" })
105+
expect(result.properties.mode.enum).toEqual(["fast", "slow"])
106+
})
107+
})

0 commit comments

Comments
 (0)