Skip to content

Commit 213ff3f

Browse files
jquensejquenserekram1-node
authored
fix(opencode): sanitize OpenAI MCP tool schemas (anomalyco#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>
1 parent 3ab3d04 commit 213ff3f

2 files changed

Lines changed: 296 additions & 0 deletions

File tree

packages/opencode/src/provider/transform.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1286,6 +1286,96 @@ export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_T
12861286
return Math.min(model.limit.output, outputTokenMax) || outputTokenMax
12871287
}
12881288

1289+
type JsonRecord = Record<string, unknown>
1290+
1291+
function isPlainObject(value: unknown): value is JsonRecord {
1292+
return typeof value === "object" && value !== null && !Array.isArray(value)
1293+
}
1294+
1295+
// Mirrors Codex's Rust JSON schema compatibility lowering for OpenAI tool schemas.
1296+
function sanitizeOpenAISchema(value: unknown): unknown {
1297+
const types = ["string", "number", "boolean", "integer", "object", "array", "null"]
1298+
const compositionKeys = ["anyOf", "oneOf", "allOf"]
1299+
1300+
// JSON Schema's boolean form (`true`/`false`) is unsupported by OpenAI tool schemas.
1301+
if (typeof value === "boolean") return { type: "string" }
1302+
if (Array.isArray(value)) return value.map(sanitizeOpenAISchema)
1303+
if (!isPlainObject(value)) return value
1304+
1305+
const result: JsonRecord = {}
1306+
1307+
if (typeof value.$ref === "string") result.$ref = value.$ref
1308+
if (typeof value.description === "string") result.description = value.description
1309+
if ("const" in value) result.enum = [value.const]
1310+
else if (Array.isArray(value.enum)) result.enum = value.enum
1311+
1312+
if (isPlainObject(value.properties)) {
1313+
result.properties = Object.fromEntries(
1314+
Object.entries(value.properties).map(([key, item]) => [key, sanitizeOpenAISchema(item)]),
1315+
)
1316+
}
1317+
1318+
if (Array.isArray(value.required)) {
1319+
result.required = value.required.filter((item) => typeof item === "string")
1320+
}
1321+
1322+
if ("items" in value) result.items = sanitizeOpenAISchema(value.items)
1323+
1324+
if ("additionalProperties" in value) {
1325+
result.additionalProperties =
1326+
typeof value.additionalProperties === "boolean"
1327+
? value.additionalProperties
1328+
: sanitizeOpenAISchema(value.additionalProperties)
1329+
}
1330+
1331+
for (const key of compositionKeys) {
1332+
if (Array.isArray(value[key])) result[key] = value[key].map(sanitizeOpenAISchema)
1333+
}
1334+
1335+
for (const key of ["$defs", "definitions"]) {
1336+
if (isPlainObject(value[key])) {
1337+
result[key] = Object.fromEntries(
1338+
Object.entries(value[key]).map(([name, item]) => [name, sanitizeOpenAISchema(item)]),
1339+
)
1340+
}
1341+
}
1342+
1343+
const schemaTypes =
1344+
typeof value.type === "string"
1345+
? types.includes(value.type)
1346+
? [value.type]
1347+
: []
1348+
: Array.isArray(value.type)
1349+
? value.type.filter((item) => typeof item === "string" && types.includes(item))
1350+
: []
1351+
1352+
if (schemaTypes.length === 0 && (typeof result.$ref === "string" || compositionKeys.some((key) => key in result))) {
1353+
return result
1354+
}
1355+
1356+
// MCP schemas may omit `type` while still using keywords that imply one.
1357+
// Keep the schema usable after unsupported keywords are dropped.
1358+
const inferredTypes =
1359+
schemaTypes.length > 0
1360+
? schemaTypes
1361+
: ["properties", "required", "additionalProperties"].some((key) => key in value)
1362+
? ["object"]
1363+
: ["items", "prefixItems"].some((key) => key in value)
1364+
? ["array"]
1365+
: "enum" in result || "format" in value
1366+
? ["string"]
1367+
: ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"].some((key) => key in value)
1368+
? ["number"]
1369+
: []
1370+
1371+
if (inferredTypes.length === 0) return {}
1372+
1373+
result.type = inferredTypes.length === 1 ? inferredTypes[0] : inferredTypes
1374+
if (inferredTypes.includes("object") && !("properties" in result)) result.properties = {}
1375+
if (inferredTypes.includes("array") && !("items" in result)) result.items = { type: "string" }
1376+
return result
1377+
}
1378+
12891379
export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 {
12901380
/*
12911381
if (["openai", "azure"].includes(providerID)) {
@@ -1305,6 +1395,11 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7
13051395
}
13061396
*/
13071397

1398+
if (model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure") {
1399+
schema = sanitizeOpenAISchema(schema) as JSONSchema7
1400+
// Codex also applies lossy compaction above 4 KB; defer that until OpenCode needs the same schema budget.
1401+
}
1402+
13081403
if (model.providerID === "moonshotai" || model.api.id.toLowerCase().includes("kimi")) {
13091404
const sanitizeMoonshot = (obj: unknown): unknown => {
13101405
if (obj === null || typeof obj !== "object") return obj

packages/opencode/test/provider/transform.test.ts

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1154,6 +1154,207 @@ describe("ProviderTransform.schema - gemini non-object properties removal", () =
11541154
})
11551155
})
11561156

1157+
describe("ProviderTransform.schema - openai supported schema subset", () => {
1158+
const openaiModel = {
1159+
providerID: "openai",
1160+
api: {
1161+
id: "gpt-4.1",
1162+
npm: "@ai-sdk/openai",
1163+
},
1164+
} as any
1165+
1166+
test("removes unsupported JSON Schema keywords recursively", () => {
1167+
const result = ProviderTransform.schema(openaiModel, {
1168+
$schema: "https://json-schema.org/draft/2020-12/schema",
1169+
title: "Search",
1170+
type: "object",
1171+
properties: {
1172+
query: {
1173+
type: "string",
1174+
description: "Search query",
1175+
format: "uri",
1176+
pattern: "^https://",
1177+
minLength: 1,
1178+
maxLength: 100,
1179+
default: "https://example.com",
1180+
},
1181+
count: {
1182+
type: "integer",
1183+
minimum: 1,
1184+
maximum: 10,
1185+
multipleOf: 1,
1186+
},
1187+
createdAt: {
1188+
format: "date-time",
1189+
},
1190+
mode: {
1191+
const: "fast",
1192+
},
1193+
tags: {
1194+
type: "array",
1195+
minItems: 1,
1196+
maxItems: 3,
1197+
uniqueItems: true,
1198+
},
1199+
tuple: {
1200+
type: "array",
1201+
items: [
1202+
{ type: "number", minimum: 0 },
1203+
{ type: "string", pattern: "^ok$" },
1204+
],
1205+
},
1206+
metadata: {
1207+
type: "object",
1208+
patternProperties: {
1209+
"^x-": { type: "string" },
1210+
},
1211+
additionalProperties: {
1212+
type: "string",
1213+
pattern: "^safe$",
1214+
},
1215+
},
1216+
},
1217+
patternProperties: {
1218+
"^extra": { type: "string" },
1219+
},
1220+
required: ["query"],
1221+
additionalProperties: false,
1222+
} as any) as any
1223+
1224+
expect(result).toEqual({
1225+
type: "object",
1226+
properties: {
1227+
query: {
1228+
type: "string",
1229+
description: "Search query",
1230+
},
1231+
count: {
1232+
type: "integer",
1233+
},
1234+
createdAt: {
1235+
type: "string",
1236+
},
1237+
mode: {
1238+
enum: ["fast"],
1239+
type: "string",
1240+
},
1241+
tags: {
1242+
type: "array",
1243+
items: { type: "string" },
1244+
},
1245+
tuple: {
1246+
type: "array",
1247+
items: [{ type: "number" }, { type: "string" }],
1248+
},
1249+
metadata: {
1250+
type: "object",
1251+
properties: {},
1252+
additionalProperties: {
1253+
type: "string",
1254+
},
1255+
},
1256+
},
1257+
required: ["query"],
1258+
additionalProperties: false,
1259+
})
1260+
})
1261+
1262+
test("keeps local references and sanitizes definitions", () => {
1263+
const result = ProviderTransform.schema(openaiModel, {
1264+
type: "object",
1265+
properties: {
1266+
value: {
1267+
$ref: "#/$defs/Value",
1268+
description: "Referenced value",
1269+
examples: ["ignored"],
1270+
},
1271+
},
1272+
$defs: {
1273+
Value: {
1274+
type: "string",
1275+
pattern: "^value$",
1276+
description: "Definition description",
1277+
},
1278+
Unused: {
1279+
type: "number",
1280+
minimum: 0,
1281+
},
1282+
},
1283+
} as any) as any
1284+
1285+
expect(result.properties.value).toEqual({
1286+
$ref: "#/$defs/Value",
1287+
description: "Referenced value",
1288+
})
1289+
expect(result.$defs).toEqual({
1290+
Value: {
1291+
type: "string",
1292+
description: "Definition description",
1293+
},
1294+
Unused: {
1295+
type: "number",
1296+
},
1297+
})
1298+
})
1299+
1300+
test("does not sanitize non-openai providers", () => {
1301+
const result = ProviderTransform.schema(
1302+
{
1303+
providerID: "anthropic",
1304+
api: {
1305+
id: "claude-sonnet-4",
1306+
npm: "@ai-sdk/anthropic",
1307+
},
1308+
} as any,
1309+
{
1310+
type: "object",
1311+
properties: {
1312+
query: {
1313+
type: "string",
1314+
pattern: "^https://",
1315+
},
1316+
},
1317+
} as any,
1318+
) as any
1319+
1320+
expect(result.properties.query.pattern).toBe("^https://")
1321+
})
1322+
1323+
test.each([
1324+
["opencode", "@ai-sdk/openai"],
1325+
["custom-openai-compatible", "@ai-sdk/openai"],
1326+
["azure", "@ai-sdk/azure"],
1327+
])("sanitizes %s models using %s", (providerID, npm) => {
1328+
expect(
1329+
ProviderTransform.schema(
1330+
{
1331+
providerID,
1332+
api: {
1333+
id: "custom-model",
1334+
npm,
1335+
},
1336+
} as any,
1337+
{
1338+
type: "object",
1339+
properties: {
1340+
query: {
1341+
type: "string",
1342+
pattern: "^https://",
1343+
},
1344+
},
1345+
} as any,
1346+
),
1347+
).toEqual({
1348+
type: "object",
1349+
properties: {
1350+
query: {
1351+
type: "string",
1352+
},
1353+
},
1354+
})
1355+
})
1356+
})
1357+
11571358
describe("ProviderTransform.schema - moonshot $ref siblings", () => {
11581359
const moonshotModel = {
11591360
providerID: "moonshotai",

0 commit comments

Comments
 (0)