diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 08b2f4922104..d0dda821bef8 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1281,6 +1281,10 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 } */ + if (model.api.npm === "@ai-sdk/openai-compatible") { + schema = inlineLocalRefs(schema) + } + if (model.providerID === "moonshotai" || model.api.id.toLowerCase().includes("kimi")) { const sanitizeMoonshot = (obj: unknown): unknown => { if (obj === null || typeof obj !== "object") return obj @@ -1381,4 +1385,66 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 return schema } +function inlineLocalRefs(schema: JSONSchema7): JSONSchema7 { + const visit = (node: unknown, stack: Set): unknown => { + if (Array.isArray(node)) return node.map((item) => visit(item, stack)) + if (!isSchemaObject(node)) return node + + const ref = typeof node.$ref === "string" ? node.$ref : undefined + if (ref?.startsWith("#/")) { + if (stack.has(ref)) return localRefSiblings(node) + + const target = resolveLocalRef(schema, ref) + if (target !== undefined) { + const nextStack = new Set(stack).add(ref) + const expanded = visit(target, nextStack) + const siblings = localRefSiblings(node) + const resolved = isSchemaObject(expanded) ? mergeDeep(expanded, siblings) : siblings + return visit(resolved, nextStack) + } + return localRefSiblings(node) + } + + return Object.fromEntries( + Object.entries(node) + .filter(([key]) => key !== "$defs" && key !== "definitions") + .map(([key, value]) => [key, visit(value, stack)]), + ) + } + + const result = visit(schema, new Set()) + if (isSchemaObject(result)) return result as JSONSchema7 + return schema +} + +function localRefSiblings(node: Record) { + return Object.fromEntries( + Object.entries(node) + .filter(([key]) => key !== "$ref") + .map(([key, value]) => [key, cloneSchemaValue(value)]), + ) +} + +function cloneSchemaValue(node: unknown): unknown { + if (Array.isArray(node)) return node.map(cloneSchemaValue) + if (!isSchemaObject(node)) return node + return Object.fromEntries(Object.entries(node).map(([key, value]) => [key, cloneSchemaValue(value)])) +} + +function resolveLocalRef(root: JSONSchema7, ref: string) { + return ref + .slice(2) + .split("/") + .map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~")) + .reduce((node, key) => { + if (isSchemaObject(node)) return node[key] + if (Array.isArray(node)) return node[Number(key)] + return undefined + }, root) +} + +function isSchemaObject(node: unknown): node is Record { + return node !== null && typeof node === "object" && !Array.isArray(node) +} + export * as ProviderTransform from "./transform" diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 2bce1585608c..be573a1740bb 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -925,6 +925,90 @@ describe("ProviderTransform.schema - gemini non-object properties removal", () = }) }) +describe("ProviderTransform.schema - openai-compatible local refs", () => { + const model = { + providerID: "fireworks", + api: { + id: "accounts/fireworks/models/deepseek-v4-flash", + npm: "@ai-sdk/openai-compatible", + }, + } as any + + function hasRef(node: unknown): boolean { + if (node === null || typeof node !== "object") return false + if (Array.isArray(node)) return node.some(hasRef) + return Object.entries(node).some(([key, value]) => key === "$ref" || hasRef(value)) + } + + test("inlines local refs in MCP tool schemas", () => { + const result = ProviderTransform.schema(model, { + type: "object", + properties: { + operations: { + type: "array", + items: { + anyOf: [ + { + type: "object", + properties: { + asset_id: { + $ref: "#/$defs/AssetId", + description: "The Canva asset id to edit.", + }, + }, + required: ["asset_id"], + }, + ], + }, + }, + }, + $defs: { + AssetId: { + type: "string", + minLength: 1, + }, + }, + } as any) as any + + expect(result.properties.operations.items.anyOf[0].properties.asset_id).toEqual({ + type: "string", + minLength: 1, + description: "The Canva asset id to edit.", + }) + expect(result.$defs).toBeUndefined() + expect(hasRef(result)).toBe(false) + }) + + test("replaces recursive local refs with permissive schemas", () => { + const result = ProviderTransform.schema(model, { + type: "object", + properties: { + operation: { + $ref: "#/$defs/Operation", + }, + }, + $defs: { + Operation: { + type: "object", + properties: { + name: { type: "string" }, + child: { $ref: "#/$defs/Operation" }, + }, + }, + }, + } as any) as any + + expect(result.properties.operation).toEqual({ + type: "object", + properties: { + name: { type: "string" }, + child: {}, + }, + }) + expect(hasRef(result)).toBe(false) + }) +}) + describe("ProviderTransform.schema - moonshot $ref siblings", () => { const moonshotModel = { providerID: "moonshotai",