From f6a0c55944994f48e9684ad771766d79a6fc2801 Mon Sep 17 00:00:00 2001 From: Role1776 Date: Wed, 19 Aug 2026 13:12:03 +0100 Subject: [PATCH 1/2] fix(session): fold schema items into gemini union branches --- packages/opencode/src/session/llm/request.ts | 81 +++++++++++++++- .../test/session/llm-fold-array-items.test.ts | 93 +++++++++++++++++++ 2 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/session/llm-fold-array-items.test.ts diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 4f93411107df..2039c26094f5 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -11,7 +11,7 @@ import { ProviderTransform } from "@/provider/transform" import { SystemPrompt } from "../system" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Effect, Record } from "effect" -import { jsonSchema, tool as aiTool, type ModelMessage, type Tool } from "ai" +import { jsonSchema, tool as aiTool, type JSONSchema7, type ModelMessage, type Tool } from "ai" import type { Plugin } from "@/plugin" import { mergeDeep } from "remeda" @@ -181,7 +181,17 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre return { system, messages, - tools: Object.fromEntries(Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b))), + tools: Object.fromEntries( + Object.entries(tools) + .toSorted(([a], [b]) => a.localeCompare(b)) + .map(([name, tool]) => { + if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") { + const schema = extractJsonSchema(tool.inputSchema) + if (schema) return [name, { ...tool, inputSchema: jsonSchema(foldArrayItems(schema) as JSONSchema7) }] + } + return [name, tool] + }), + ), params, messageTransformOptions: options, headers: { @@ -213,6 +223,73 @@ function resolveTools(input: Pick input.user.tools?.[k] !== false && !disabled.has(k)) } +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +// @ai-sdk/google's convertJSONSchemaToOpenAPISchema splits a nullable array +// written as `type: ["null", "array"]` into `anyOf: [{ type: "array" }]` but +// leaves a sibling `items` dangling at the parent, which Gemini rejects. Fold +// `items` into the array-typed branches of any union so the generated +// function declaration carries `items` inside the array branch. Returns a +// deep clone — the caller's original schema is never mutated, so tools shared +// across providers (or defined once at module scope) stay untouched. +export const foldArrayItems = (schema: unknown): unknown => { + const clone = structuredClone(schema) + fold(clone) + return clone +} + +// Only treat objects that actually look like JSON Schema as schemas; anything +// else (e.g. a raw Zod instance passed as inputSchema) is left alone. +function extractJsonSchema(inputSchema: unknown): Record | undefined { + const candidate = isRecord(inputSchema) && isRecord(inputSchema.jsonSchema) ? inputSchema.jsonSchema : inputSchema + const JSON_SCHEMA_KEYS = ["type", "properties", "items", "$ref", "anyOf", "oneOf", "allOf", "$defs", "definitions"] + return isRecord(candidate) && JSON_SCHEMA_KEYS.some((key) => key in candidate) ? candidate : undefined +} + +function fold(schema: unknown): void { + if (Array.isArray(schema)) { + for (const item of schema) fold(item) + return + } + if (!isRecord(schema)) return + for (const value of Object.values(schema)) fold(value) + if (schema.items === undefined) return + const type = schema.type + if (Array.isArray(type)) { + // Carry non-array, non-null members over as extra branches instead of + // discarding them; only array/null become anyOf branches. + const branches: Record[] = type.filter((t) => t !== "array" && t !== "null").map((t) => ({ type: t })) + branches.unshift({ type: "array", items: schema.items }) + if (type.includes("null")) branches.push({ type: "null" }) + schema.anyOf = branches + delete schema.type + delete schema.items + return + } + // Folding items into every allOf branch would change intersection + // semantics, so restrict this to unions. + const combiner = ["anyOf", "oneOf"].find((key) => Array.isArray(schema[key])) + if (!combiner) return + let matched = false + const branches = schema[combiner] + if (!Array.isArray(branches)) return + for (const branch of branches) { + if (!isRecord(branch)) continue + const branchType = branch.type + if ( + (branchType === "array" || (Array.isArray(branchType) && branchType.includes("array"))) && + branch.items === undefined + ) { + branch.items = schema.items + matched = true + } + } + // If no branch is array-typed, dropping items would silently weaken + // validation — keep it on the parent. + if (matched) delete schema.items +} + export function hasToolCalls(messages: ModelMessage[]): boolean { for (const msg of messages) { if (!Array.isArray(msg.content)) continue diff --git a/packages/opencode/test/session/llm-fold-array-items.test.ts b/packages/opencode/test/session/llm-fold-array-items.test.ts new file mode 100644 index 000000000000..14d118cef722 --- /dev/null +++ b/packages/opencode/test/session/llm-fold-array-items.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test" +import { foldArrayItems } from "../../src/session/llm/request" + +const rec = (value: any): any => value + +describe("foldArrayItems", () => { + test("folds items into nullable array type union", () => { + const schema = { + type: ["null", "array"], + items: { type: "string" }, + description: "list of tags", + } + const result: any = foldArrayItems(schema) + expect(result).toEqual({ + anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }], + description: "list of tags", + }) + }) + + test("does not mutate the input schema", () => { + const schema = { type: ["null", "array"], items: { type: "string" } } + const copy = structuredClone(schema) + foldArrayItems(schema) + expect(schema).toEqual(copy) + }) + + test("preserves non-array and non-null members of a multi-type union", () => { + const result: any = foldArrayItems({ type: ["string", "array"], items: { type: "number" } }) + expect(result.anyOf).toEqual([ + { type: "array", items: { type: "number" } }, + { type: "string" }, + ]) + }) + + test("folds items into array-typed anyOf branches", () => { + const result: any = foldArrayItems({ + items: { type: "string" }, + anyOf: [{ type: "array" }, { type: "object", properties: {} }], + }) + expect(result).toEqual({ + anyOf: [{ type: "array", items: { type: "string" } }, { type: "object", properties: {} }], + }) + }) + + test("keeps items on the parent when no combiner branch is array-typed", () => { + const schema = { + items: { type: "string" }, + anyOf: [{ type: "string" }, { type: "number" }], + } + const result: any = foldArrayItems(schema) + expect(result.items).toEqual({ type: "string" }) + }) + + test("does not fold into allOf branches", () => { + const schema = { items: { type: "string" }, allOf: [{ type: "array" }] } + const result: any = foldArrayItems(schema) + expect(result.items).toEqual({ type: "string" }) + expect((rec(result.allOf)[0] as Record).items).toBeUndefined() + }) + + test("recurses into nested schemas", () => { + const result: any = foldArrayItems({ + properties: { + nested: { type: ["null", "array"], items: { type: "boolean" } }, + }, + }) + expect(result.properties).toEqual({ + nested: { anyOf: [{ type: "array", items: { type: "boolean" } }, { type: "null" }] }, + }) + }) + + test("leaves existing branch items untouched", () => { + const own = { type: "integer" } + const result: any = foldArrayItems({ + items: { type: "string" }, + anyOf: [{ type: "array", items: own }], + }) + expect((rec(result.anyOf)[0] as Record).items).toEqual({ type: "integer" }) + }) + + test("is idempotent", () => { + const once = foldArrayItems({ type: ["null", "array"], items: { type: "string" } }) + const twice = foldArrayItems(once) + expect(twice).toEqual(once) + }) + + test("returns non-schema objects unchanged", () => { + const zodLike = { _def: { typeName: "ZodString" }, parse: "not-a-function-here" } + expect(foldArrayItems(zodLike)).toEqual(zodLike) + expect(foldArrayItems("scalar")).toEqual("scalar") + expect(foldArrayItems([1, 2])).toEqual([1, 2]) + }) +}) From b4944fa6afa3c7bc45d87fb4c203539eb64a6912 Mon Sep 17 00:00:00 2001 From: Role1776 Date: Sat, 3 Oct 2026 14:15:56 +0100 Subject: [PATCH 2/2] fix(session): fold object keywords into gemini union branches too Nullable objects (`type: ["object", "null"]`) hit the same @ai-sdk/google conversion gap as nullable arrays: `properties`/`required` stay on the anyOf parent and Gemini rejects the request with `properties: only allowed for OBJECT type`. Generalize the fold to move every type-specific keyword (items/minItems/maxItems, properties/required/additionalProperties) into its own branch, and leave nodes with a single type alone. --- packages/opencode/src/session/llm/request.ts | 74 ++++---- .../test/session/llm-fold-array-items.test.ts | 93 ----------- .../session/llm-fold-type-keywords.test.ts | 158 ++++++++++++++++++ 3 files changed, 200 insertions(+), 125 deletions(-) delete mode 100644 packages/opencode/test/session/llm-fold-array-items.test.ts create mode 100644 packages/opencode/test/session/llm-fold-type-keywords.test.ts diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 2039c26094f5..6aaf329a00f0 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -187,7 +187,7 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre .map(([name, tool]) => { if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") { const schema = extractJsonSchema(tool.inputSchema) - if (schema) return [name, { ...tool, inputSchema: jsonSchema(foldArrayItems(schema) as JSONSchema7) }] + if (schema) return [name, { ...tool, inputSchema: jsonSchema(foldTypeKeywords(schema) as JSONSchema7) }] } return [name, tool] }), @@ -226,19 +226,26 @@ function resolveTools(input: Pick => typeof value === "object" && value !== null && !Array.isArray(value) -// @ai-sdk/google's convertJSONSchemaToOpenAPISchema splits a nullable array -// written as `type: ["null", "array"]` into `anyOf: [{ type: "array" }]` but -// leaves a sibling `items` dangling at the parent, which Gemini rejects. Fold -// `items` into the array-typed branches of any union so the generated -// function declaration carries `items` inside the array branch. Returns a -// deep clone — the caller's original schema is never mutated, so tools shared -// across providers (or defined once at module scope) stay untouched. -export const foldArrayItems = (schema: unknown): unknown => { +// @ai-sdk/google's convertJSONSchemaToOpenAPISchema splits a multi-type node +// such as `type: ["object", "null"]` into `anyOf: [{ type: "object" }]` but +// leaves type-specific keywords (`items`, `properties`, `required`, ...) +// dangling at the parent, which Gemini rejects with errors like +// `properties: only allowed for OBJECT type`. Move those keywords into the +// branch of the type they belong to. Returns a deep clone — the caller's +// original schema is never mutated, so tools shared across providers (or +// defined once at module scope) stay untouched. +export const foldTypeKeywords = (schema: unknown): unknown => { const clone = structuredClone(schema) fold(clone) return clone } +// Keywords that are only valid on a node of the given type. +const TYPE_KEYWORDS: Record = { + array: ["items", "minItems", "maxItems"], + object: ["properties", "required", "additionalProperties"], +} + // Only treat objects that actually look like JSON Schema as schemas; anything // else (e.g. a raw Zod instance passed as inputSchema) is left alone. function extractJsonSchema(inputSchema: unknown): Record | undefined { @@ -254,40 +261,43 @@ function fold(schema: unknown): void { } if (!isRecord(schema)) return for (const value of Object.values(schema)) fold(value) - if (schema.items === undefined) return const type = schema.type if (Array.isArray(type)) { - // Carry non-array, non-null members over as extra branches instead of - // discarding them; only array/null become anyOf branches. - const branches: Record[] = type.filter((t) => t !== "array" && t !== "null").map((t) => ({ type: t })) - branches.unshift({ type: "array", items: schema.items }) + const owned = (t: unknown) => (TYPE_KEYWORDS[String(t)] ?? []).filter((key) => key in schema) + if (!type.some((t) => owned(t).length > 0)) return + // Carry the other non-null members over as plain branches instead of + // discarding them; branches that own keywords go first. + const branches = type + .filter((t) => t !== "null") + .map((t) => Object.fromEntries([["type", t], ...owned(t).map((key) => [key, schema[key]])])) + .toSorted((a, b) => Object.keys(b).length - Object.keys(a).length) if (type.includes("null")) branches.push({ type: "null" }) + for (const t of type) for (const key of owned(t)) delete schema[key] schema.anyOf = branches delete schema.type - delete schema.items return } - // Folding items into every allOf branch would change intersection - // semantics, so restrict this to unions. + // A node with its own single type may legally carry its keywords. + if (type !== undefined) return + // Folding into every allOf branch would change intersection semantics, so + // restrict this to unions. const combiner = ["anyOf", "oneOf"].find((key) => Array.isArray(schema[key])) if (!combiner) return - let matched = false - const branches = schema[combiner] - if (!Array.isArray(branches)) return - for (const branch of branches) { - if (!isRecord(branch)) continue - const branchType = branch.type - if ( - (branchType === "array" || (Array.isArray(branchType) && branchType.includes("array"))) && - branch.items === undefined - ) { - branch.items = schema.items - matched = true + const branches = (schema[combiner] as unknown[]).filter(isRecord) + for (const [t, keys] of Object.entries(TYPE_KEYWORDS)) { + const present = keys.filter((key) => key in schema) + if (present.length === 0) continue + const targets = branches.filter((branch) => + Array.isArray(branch.type) ? branch.type.includes(t) : branch.type === t, + ) + // If no branch has this type, dropping the keywords would silently weaken + // validation — keep them on the parent. + if (targets.length === 0) continue + for (const branch of targets) { + for (const key of present) if (!(key in branch)) branch[key] = schema[key] } + for (const key of present) delete schema[key] } - // If no branch is array-typed, dropping items would silently weaken - // validation — keep it on the parent. - if (matched) delete schema.items } export function hasToolCalls(messages: ModelMessage[]): boolean { diff --git a/packages/opencode/test/session/llm-fold-array-items.test.ts b/packages/opencode/test/session/llm-fold-array-items.test.ts deleted file mode 100644 index 14d118cef722..000000000000 --- a/packages/opencode/test/session/llm-fold-array-items.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { foldArrayItems } from "../../src/session/llm/request" - -const rec = (value: any): any => value - -describe("foldArrayItems", () => { - test("folds items into nullable array type union", () => { - const schema = { - type: ["null", "array"], - items: { type: "string" }, - description: "list of tags", - } - const result: any = foldArrayItems(schema) - expect(result).toEqual({ - anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }], - description: "list of tags", - }) - }) - - test("does not mutate the input schema", () => { - const schema = { type: ["null", "array"], items: { type: "string" } } - const copy = structuredClone(schema) - foldArrayItems(schema) - expect(schema).toEqual(copy) - }) - - test("preserves non-array and non-null members of a multi-type union", () => { - const result: any = foldArrayItems({ type: ["string", "array"], items: { type: "number" } }) - expect(result.anyOf).toEqual([ - { type: "array", items: { type: "number" } }, - { type: "string" }, - ]) - }) - - test("folds items into array-typed anyOf branches", () => { - const result: any = foldArrayItems({ - items: { type: "string" }, - anyOf: [{ type: "array" }, { type: "object", properties: {} }], - }) - expect(result).toEqual({ - anyOf: [{ type: "array", items: { type: "string" } }, { type: "object", properties: {} }], - }) - }) - - test("keeps items on the parent when no combiner branch is array-typed", () => { - const schema = { - items: { type: "string" }, - anyOf: [{ type: "string" }, { type: "number" }], - } - const result: any = foldArrayItems(schema) - expect(result.items).toEqual({ type: "string" }) - }) - - test("does not fold into allOf branches", () => { - const schema = { items: { type: "string" }, allOf: [{ type: "array" }] } - const result: any = foldArrayItems(schema) - expect(result.items).toEqual({ type: "string" }) - expect((rec(result.allOf)[0] as Record).items).toBeUndefined() - }) - - test("recurses into nested schemas", () => { - const result: any = foldArrayItems({ - properties: { - nested: { type: ["null", "array"], items: { type: "boolean" } }, - }, - }) - expect(result.properties).toEqual({ - nested: { anyOf: [{ type: "array", items: { type: "boolean" } }, { type: "null" }] }, - }) - }) - - test("leaves existing branch items untouched", () => { - const own = { type: "integer" } - const result: any = foldArrayItems({ - items: { type: "string" }, - anyOf: [{ type: "array", items: own }], - }) - expect((rec(result.anyOf)[0] as Record).items).toEqual({ type: "integer" }) - }) - - test("is idempotent", () => { - const once = foldArrayItems({ type: ["null", "array"], items: { type: "string" } }) - const twice = foldArrayItems(once) - expect(twice).toEqual(once) - }) - - test("returns non-schema objects unchanged", () => { - const zodLike = { _def: { typeName: "ZodString" }, parse: "not-a-function-here" } - expect(foldArrayItems(zodLike)).toEqual(zodLike) - expect(foldArrayItems("scalar")).toEqual("scalar") - expect(foldArrayItems([1, 2])).toEqual([1, 2]) - }) -}) diff --git a/packages/opencode/test/session/llm-fold-type-keywords.test.ts b/packages/opencode/test/session/llm-fold-type-keywords.test.ts new file mode 100644 index 000000000000..b032842cc883 --- /dev/null +++ b/packages/opencode/test/session/llm-fold-type-keywords.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test" +import { foldTypeKeywords } from "../../src/session/llm/request" + +const rec = (value: any): any => value + +describe("foldTypeKeywords", () => { + test("folds items into nullable array type union", () => { + const schema = { + type: ["null", "array"], + items: { type: "string" }, + description: "list of tags", + } + const result: any = foldTypeKeywords(schema) + expect(result).toEqual({ + anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }], + description: "list of tags", + }) + }) + + test("does not mutate the input schema", () => { + const schema = { type: ["null", "array"], items: { type: "string" } } + const copy = structuredClone(schema) + foldTypeKeywords(schema) + expect(schema).toEqual(copy) + }) + + test("preserves non-array and non-null members of a multi-type union", () => { + const result: any = foldTypeKeywords({ type: ["string", "array"], items: { type: "number" } }) + expect(result.anyOf).toEqual([ + { type: "array", items: { type: "number" } }, + { type: "string" }, + ]) + }) + + test("folds items into array-typed anyOf branches", () => { + const result: any = foldTypeKeywords({ + items: { type: "string" }, + anyOf: [{ type: "array" }, { type: "object", properties: {} }], + }) + expect(result).toEqual({ + anyOf: [{ type: "array", items: { type: "string" } }, { type: "object", properties: {} }], + }) + }) + + test("keeps items on the parent when no combiner branch is array-typed", () => { + const schema = { + items: { type: "string" }, + anyOf: [{ type: "string" }, { type: "number" }], + } + const result: any = foldTypeKeywords(schema) + expect(result.items).toEqual({ type: "string" }) + }) + + test("does not fold into allOf branches", () => { + const schema = { items: { type: "string" }, allOf: [{ type: "array" }] } + const result: any = foldTypeKeywords(schema) + expect(result.items).toEqual({ type: "string" }) + expect((rec(result.allOf)[0] as Record).items).toBeUndefined() + }) + + test("recurses into nested schemas", () => { + const result: any = foldTypeKeywords({ + properties: { + nested: { type: ["null", "array"], items: { type: "boolean" } }, + }, + }) + expect(result.properties).toEqual({ + nested: { anyOf: [{ type: "array", items: { type: "boolean" } }, { type: "null" }] }, + }) + }) + + test("leaves existing branch items untouched", () => { + const own = { type: "integer" } + const result: any = foldTypeKeywords({ + items: { type: "string" }, + anyOf: [{ type: "array", items: own }], + }) + expect((rec(result.anyOf)[0] as Record).items).toEqual({ type: "integer" }) + }) + + test("is idempotent", () => { + const once = foldTypeKeywords({ type: ["null", "array"], items: { type: "string" } }) + const twice = foldTypeKeywords(once) + expect(twice).toEqual(once) + }) + + test("returns non-schema objects unchanged", () => { + const zodLike = { _def: { typeName: "ZodString" }, parse: "not-a-function-here" } + expect(foldTypeKeywords(zodLike)).toEqual(zodLike) + expect(foldTypeKeywords("scalar")).toEqual("scalar") + expect(foldTypeKeywords([1, 2])).toEqual([1, 2]) + }) + + test("folds properties and required into nullable object type union", () => { + const result: any = foldTypeKeywords({ + type: ["object", "null"], + properties: { name: { type: "string" } }, + required: ["name"], + additionalProperties: false, + description: "owner", + }) + expect(result).toEqual({ + anyOf: [ + { type: "object", properties: { name: { type: "string" } }, required: ["name"], additionalProperties: false }, + { type: "null" }, + ], + description: "owner", + }) + }) + + test("splits array and object keywords into their own branches", () => { + const result: any = foldTypeKeywords({ + type: ["array", "object"], + items: { type: "string" }, + minItems: 1, + properties: { a: { type: "number" } }, + }) + expect(result.anyOf).toEqual([ + { type: "array", items: { type: "string" }, minItems: 1 }, + { type: "object", properties: { a: { type: "number" } } }, + ]) + }) + + test("leaves keywords whose type is not in the union on the parent", () => { + const result: any = foldTypeKeywords({ + type: ["array", "null"], + items: { type: "string" }, + properties: { a: { type: "number" } }, + }) + expect(result.properties).toEqual({ a: { type: "number" } }) + expect(result.anyOf[0]).toEqual({ type: "array", items: { type: "string" } }) + }) + + test("folds properties into object-typed anyOf branches", () => { + const result: any = foldTypeKeywords({ + properties: { a: { type: "string" } }, + required: ["a"], + anyOf: [{ type: "object" }, { type: "null" }], + }) + expect(result).toEqual({ + anyOf: [{ type: "object", properties: { a: { type: "string" } }, required: ["a"] }, { type: "null" }], + }) + }) + + test("keeps keywords on a parent with its own single type", () => { + const schema = { + type: "object", + properties: { a: { type: "string" } }, + anyOf: [{ type: "object", required: ["a"] }], + } + expect(foldTypeKeywords(schema)).toEqual(schema) + }) + + test("leaves single-type schemas untouched", () => { + const schema = { type: "object", properties: { tags: { type: "array", items: { type: "string" } } } } + expect(foldTypeKeywords(schema)).toEqual(schema) + }) +})