diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 853d03c1d8b9..078b86dc2421 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -869,6 +869,24 @@ export namespace ProviderTransform { } */ + // Sanitize tool schemas for strict validators (OpenAI Codex, Vertex AI, etc.) + // 1. Strip non-standard keywords ($schema, ref) that Zod meta injects + // 2. Ensure all properties are in required when additionalProperties is false + const sanitize = (obj: any): any => { + if (obj === null || typeof obj !== "object") return obj + if (Array.isArray(obj)) return obj.map(sanitize) + const result: any = {} + for (const [key, value] of Object.entries(obj)) { + if (key === "$schema" || key === "ref") continue + result[key] = typeof value === "object" && value !== null ? sanitize(value) : value + } + if (result.type === "object" && result.additionalProperties === false && result.properties) { + result.required = Object.keys(result.properties) + } + return result + } + schema = sanitize(schema) + // Convert integer enums to string enums for Google/Gemini if (model.providerID === "google" || model.api.id.includes("gemini")) { const sanitizeGemini = (obj: any): any => { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 3494cb56fdd0..571a21056063 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -620,6 +620,141 @@ describe("ProviderTransform.schema - gemini non-object properties removal", () = }) }) +describe("ProviderTransform.schema - strip non-standard keywords", () => { + const model = { + providerID: "openai-compatible", + api: { id: "gemini-2.5-flash" }, + } as any + + test("strips $schema from root", () => { + const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { name: { type: "string" } }, + } as any + + const result = ProviderTransform.schema(model, schema) as any + + expect(result.$schema).toBeUndefined() + expect(result.properties.name.type).toBe("string") + }) + + test("strips ref from nested objects", () => { + const schema = { + type: "object", + properties: { + items: { + type: "array", + items: { + ref: "QuestionOption", + type: "object", + properties: { label: { type: "string" } }, + }, + }, + }, + } as any + + const result = ProviderTransform.schema(model, schema) as any + + expect(result.properties.items.items.ref).toBeUndefined() + expect(result.properties.items.items.type).toBe("object") + expect(result.properties.items.items.properties.label.type).toBe("string") + }) +}) + +describe("ProviderTransform.schema - strict required for additionalProperties false", () => { + const model = { + providerID: "openai-compatible", + api: { id: "gpt-5" }, + } as any + + test("adds missing properties to required when additionalProperties is false", () => { + const schema = { + type: "object", + properties: { + question: { type: "string" }, + header: { type: "string" }, + options: { type: "array", items: { type: "string" } }, + multiple: { type: "boolean" }, + }, + required: ["question", "header", "options"], + additionalProperties: false, + } as any + + const result = ProviderTransform.schema(model, schema) as any + + expect(result.required).toContain("multiple") + expect(result.required).toContain("question") + expect(result.required).toContain("header") + expect(result.required).toContain("options") + expect(result.required).toHaveLength(4) + }) + + test("handles nested objects with additionalProperties false", () => { + const schema = { + type: "object", + properties: { + questions: { + type: "array", + items: { + type: "object", + properties: { + question: { type: "string" }, + multiple: { type: "boolean" }, + }, + required: ["question"], + additionalProperties: false, + }, + }, + }, + required: ["questions"], + additionalProperties: false, + } as any + + const result = ProviderTransform.schema(model, schema) as any + + expect(result.properties.questions.items.required).toContain("multiple") + expect(result.properties.questions.items.required).toContain("question") + }) + + test("does not modify schemas without additionalProperties false", () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], + } as any + + const result = ProviderTransform.schema(model, schema) as any + + expect(result.required).toEqual(["name"]) + }) + + test("works for all providers not just gemini", () => { + const codexModel = { + providerID: "openai-compatible", + api: { id: "gpt-5.3-codex" }, + } as any + + const schema = { + type: "object", + properties: { + a: { type: "string" }, + b: { type: "number" }, + }, + required: ["a"], + additionalProperties: false, + } as any + + const result = ProviderTransform.schema(codexModel, schema) as any + + expect(result.required).toContain("a") + expect(result.required).toContain("b") + }) +}) + describe("ProviderTransform.message - DeepSeek reasoning content", () => { test("DeepSeek with tool calls includes reasoning_content in providerOptions", () => { const msgs = [