Skip to content
Open
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
81 changes: 79 additions & 2 deletions packages/opencode/src/session/llm/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -213,6 +223,73 @@ function resolveTools(input: Pick<PrepareInput, "tools" | "agent" | "permission"
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
}

const isRecord = (value: unknown): value is Record<string, unknown> =>
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<string, unknown> | 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<string, unknown>[] = 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
Expand Down
93 changes: 93 additions & 0 deletions packages/opencode/test/session/llm-fold-array-items.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).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<string, unknown>).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])
})
})
Loading