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
91 changes: 89 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(foldTypeKeywords(schema) as JSONSchema7) }]
}
return [name, tool]
}),
),
params,
messageTransformOptions: options,
headers: {
Expand Down Expand Up @@ -213,6 +223,83 @@ 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 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<string, string[]> = {
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<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)
const type = schema.type
if (Array.isArray(type)) {
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
return
}
// 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
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]
}
}

export function hasToolCalls(messages: ModelMessage[]): boolean {
for (const msg of messages) {
if (!Array.isArray(msg.content)) continue
Expand Down
158 changes: 158 additions & 0 deletions packages/opencode/test/session/llm-fold-type-keywords.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).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<string, unknown>).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)
})
})
Loading