fix(opencode): sanitize OpenAI MCP tool schemas (#32489)

Co-authored-by: jquense <jquense@ramp.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
This commit is contained in:
Jason Quense 2026-06-16 20:00:58 -04:00 committed by GitHub
parent 3ab3d04ec7
commit 213ff3f2d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 296 additions and 0 deletions

View File

@ -1286,6 +1286,96 @@ export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_T
return Math.min(model.limit.output, outputTokenMax) || outputTokenMax
}
type JsonRecord = Record<string, unknown>
function isPlainObject(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
// Mirrors Codex's Rust JSON schema compatibility lowering for OpenAI tool schemas.
function sanitizeOpenAISchema(value: unknown): unknown {
const types = ["string", "number", "boolean", "integer", "object", "array", "null"]
const compositionKeys = ["anyOf", "oneOf", "allOf"]
// JSON Schema's boolean form (`true`/`false`) is unsupported by OpenAI tool schemas.
if (typeof value === "boolean") return { type: "string" }
if (Array.isArray(value)) return value.map(sanitizeOpenAISchema)
if (!isPlainObject(value)) return value
const result: JsonRecord = {}
if (typeof value.$ref === "string") result.$ref = value.$ref
if (typeof value.description === "string") result.description = value.description
if ("const" in value) result.enum = [value.const]
else if (Array.isArray(value.enum)) result.enum = value.enum
if (isPlainObject(value.properties)) {
result.properties = Object.fromEntries(
Object.entries(value.properties).map(([key, item]) => [key, sanitizeOpenAISchema(item)]),
)
}
if (Array.isArray(value.required)) {
result.required = value.required.filter((item) => typeof item === "string")
}
if ("items" in value) result.items = sanitizeOpenAISchema(value.items)
if ("additionalProperties" in value) {
result.additionalProperties =
typeof value.additionalProperties === "boolean"
? value.additionalProperties
: sanitizeOpenAISchema(value.additionalProperties)
}
for (const key of compositionKeys) {
if (Array.isArray(value[key])) result[key] = value[key].map(sanitizeOpenAISchema)
}
for (const key of ["$defs", "definitions"]) {
if (isPlainObject(value[key])) {
result[key] = Object.fromEntries(
Object.entries(value[key]).map(([name, item]) => [name, sanitizeOpenAISchema(item)]),
)
}
}
const schemaTypes =
typeof value.type === "string"
? types.includes(value.type)
? [value.type]
: []
: Array.isArray(value.type)
? value.type.filter((item) => typeof item === "string" && types.includes(item))
: []
if (schemaTypes.length === 0 && (typeof result.$ref === "string" || compositionKeys.some((key) => key in result))) {
return result
}
// MCP schemas may omit `type` while still using keywords that imply one.
// Keep the schema usable after unsupported keywords are dropped.
const inferredTypes =
schemaTypes.length > 0
? schemaTypes
: ["properties", "required", "additionalProperties"].some((key) => key in value)
? ["object"]
: ["items", "prefixItems"].some((key) => key in value)
? ["array"]
: "enum" in result || "format" in value
? ["string"]
: ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"].some((key) => key in value)
? ["number"]
: []
if (inferredTypes.length === 0) return {}
result.type = inferredTypes.length === 1 ? inferredTypes[0] : inferredTypes
if (inferredTypes.includes("object") && !("properties" in result)) result.properties = {}
if (inferredTypes.includes("array") && !("items" in result)) result.items = { type: "string" }
return result
}
export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 {
/*
if (["openai", "azure"].includes(providerID)) {
@ -1305,6 +1395,11 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7
}
*/
if (model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure") {
schema = sanitizeOpenAISchema(schema) as JSONSchema7
// Codex also applies lossy compaction above 4 KB; defer that until OpenCode needs the same schema budget.
}
if (model.providerID === "moonshotai" || model.api.id.toLowerCase().includes("kimi")) {
const sanitizeMoonshot = (obj: unknown): unknown => {
if (obj === null || typeof obj !== "object") return obj

View File

@ -1154,6 +1154,207 @@ describe("ProviderTransform.schema - gemini non-object properties removal", () =
})
})
describe("ProviderTransform.schema - openai supported schema subset", () => {
const openaiModel = {
providerID: "openai",
api: {
id: "gpt-4.1",
npm: "@ai-sdk/openai",
},
} as any
test("removes unsupported JSON Schema keywords recursively", () => {
const result = ProviderTransform.schema(openaiModel, {
$schema: "https://json-schema.org/draft/2020-12/schema",
title: "Search",
type: "object",
properties: {
query: {
type: "string",
description: "Search query",
format: "uri",
pattern: "^https://",
minLength: 1,
maxLength: 100,
default: "https://example.com",
},
count: {
type: "integer",
minimum: 1,
maximum: 10,
multipleOf: 1,
},
createdAt: {
format: "date-time",
},
mode: {
const: "fast",
},
tags: {
type: "array",
minItems: 1,
maxItems: 3,
uniqueItems: true,
},
tuple: {
type: "array",
items: [
{ type: "number", minimum: 0 },
{ type: "string", pattern: "^ok$" },
],
},
metadata: {
type: "object",
patternProperties: {
"^x-": { type: "string" },
},
additionalProperties: {
type: "string",
pattern: "^safe$",
},
},
},
patternProperties: {
"^extra": { type: "string" },
},
required: ["query"],
additionalProperties: false,
} as any) as any
expect(result).toEqual({
type: "object",
properties: {
query: {
type: "string",
description: "Search query",
},
count: {
type: "integer",
},
createdAt: {
type: "string",
},
mode: {
enum: ["fast"],
type: "string",
},
tags: {
type: "array",
items: { type: "string" },
},
tuple: {
type: "array",
items: [{ type: "number" }, { type: "string" }],
},
metadata: {
type: "object",
properties: {},
additionalProperties: {
type: "string",
},
},
},
required: ["query"],
additionalProperties: false,
})
})
test("keeps local references and sanitizes definitions", () => {
const result = ProviderTransform.schema(openaiModel, {
type: "object",
properties: {
value: {
$ref: "#/$defs/Value",
description: "Referenced value",
examples: ["ignored"],
},
},
$defs: {
Value: {
type: "string",
pattern: "^value$",
description: "Definition description",
},
Unused: {
type: "number",
minimum: 0,
},
},
} as any) as any
expect(result.properties.value).toEqual({
$ref: "#/$defs/Value",
description: "Referenced value",
})
expect(result.$defs).toEqual({
Value: {
type: "string",
description: "Definition description",
},
Unused: {
type: "number",
},
})
})
test("does not sanitize non-openai providers", () => {
const result = ProviderTransform.schema(
{
providerID: "anthropic",
api: {
id: "claude-sonnet-4",
npm: "@ai-sdk/anthropic",
},
} as any,
{
type: "object",
properties: {
query: {
type: "string",
pattern: "^https://",
},
},
} as any,
) as any
expect(result.properties.query.pattern).toBe("^https://")
})
test.each([
["opencode", "@ai-sdk/openai"],
["custom-openai-compatible", "@ai-sdk/openai"],
["azure", "@ai-sdk/azure"],
])("sanitizes %s models using %s", (providerID, npm) => {
expect(
ProviderTransform.schema(
{
providerID,
api: {
id: "custom-model",
npm,
},
} as any,
{
type: "object",
properties: {
query: {
type: "string",
pattern: "^https://",
},
},
} as any,
),
).toEqual({
type: "object",
properties: {
query: {
type: "string",
},
},
})
})
})
describe("ProviderTransform.schema - moonshot $ref siblings", () => {
const moonshotModel = {
providerID: "moonshotai",