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:
parent
3ab3d04ec7
commit
213ff3f2d7
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user