From 01543a0b1655fe8f0800297a25eb1784d80a9439 Mon Sep 17 00:00:00 2001 From: lexlian <9305752+lexlian@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:24:31 +0800 Subject: [PATCH] fix(provider): expand \$ref/\$defs for DeepSeek compatibility DeepSeek API rejects JSON Schema 2020-12 \$ref pointers in MCP tool schemas, causing AttributeError: 'NoneType' object has no attribute 'lookup' when using MCP servers with \$defs references (Asana, Notion). Adds expandRefs() that inlines \$ref targets from \$defs/definitions before sending schemas to DeepSeek. Handles nested \$refs, preserves override fields on referencing nodes, and guards against circular refs. Closes #32829 Closes #29220 (duplicate) fix: expand \$ref references in JSON Schema for DeepSeek compatibility --- packages/opencode/src/provider/transform.ts | 39 +++++++ .../opencode/test/provider/transform.test.ts | 106 ++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 79cfa3ea508a..ffb0a8fabcf2 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -4,6 +4,7 @@ import type { JSONSchema7 } from "@ai-sdk/provider" import type * as Provider from "./provider" import type * as ModelsDev from "@opencode-ai/core/models-dev" import { iife } from "@/util/iife" +import { isRecord } from "@/util/record" type Modality = NonNullable["input"][number] @@ -1241,6 +1242,39 @@ export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_T return Math.min(model.limit.output, outputTokenMax) || outputTokenMax } +function expandRefs(schema: JSONSchema7): JSONSchema7 { + const definitions = schema.$defs ?? (schema as any).definitions + + function expand(value: unknown, seen = new Set()): unknown { + if (Array.isArray(value)) return value.map((v) => expand(v, seen)) + if (!isRecord(value)) return value + + // Resolve local $ref + if (typeof value.$ref === "string" && definitions) { + const name = + value.$ref.match(/^#\/\$defs\/(.+)$/)?.[1] ?? + value.$ref.match(/^#\/definitions\/(.+)$/)?.[1] + if (name && !seen.has(name)) { + const target = (definitions as Record)[name] + if (target && isRecord(target)) { + const { $ref: _, ...targetRest } = value + seen.add(name) + return expand({ ...target, ...targetRest }, seen) + } + } + } + + const result: Record = {} + for (const [key, val] of Object.entries(value)) { + if (key === "$defs" || key === "definitions") continue + result[key] = expand(val, seen) + } + return result + } + + return expand(schema) as JSONSchema7 +} + export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 { /* if (["openai", "azure"].includes(providerID)) { @@ -1278,6 +1312,11 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 } } + // Expand $ref/$defs for DeepSeek (rejects JSON Schema 2020-12 $ref pointers) + if (model.api.id.toLowerCase().includes("deepseek")) { + schema = expandRefs(schema) + } + // Convert integer enums to string enums for Google/Gemini if (model.providerID === "google" || model.api.id.includes("gemini")) { const isPlainObject = (node: unknown): node is Record => diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 674d4ef00475..c8321c6ca580 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3935,3 +3935,109 @@ describe("ProviderTransform.providerOptions - ai-gateway-provider", () => { expect(result).toEqual({ openaiCompatible: { reasoningEffort: "high" } }) }) }) + +describe("ProviderTransform.schema - deepseek $ref expansion", () => { + const deepseekModel = { + providerID: "deepseek", + api: { id: "deepseek-chat" }, + } as any + + test("expands simple $ref with $defs", () => { + const schema = { + type: "object", + properties: { query: { $ref: "#/$defs/Query" } }, + $defs: { Query: { type: "string", description: "Search query" } }, + } as any + const result = ProviderTransform.schema(deepseekModel, schema) as any + expect(result.properties?.query).toEqual({ type: "string", description: "Search query" }) + expect(result.$defs).toBeUndefined() + }) + + test("expands nested $ref references", () => { + const schema = { + type: "object", + properties: { user: { $ref: "#/$defs/User" } }, + $defs: { + User: { + type: "object", + properties: { name: { type: "string" }, address: { $ref: "#/$defs/Address" } }, + }, + Address: { type: "string" }, + }, + } as any + const result = ProviderTransform.schema(deepseekModel, schema) as any + expect(result.properties?.user).toEqual({ + type: "object", + properties: { name: { type: "string" }, address: { type: "string" } }, + }) + }) + + test("handles circular $ref by returning empty object", () => { + const schema = { + type: "object", + properties: { node: { $ref: "#/$defs/Node" } }, + $defs: { Node: { type: "object", properties: { self: { $ref: "#/$defs/Node" } } } }, + } as any + const result = ProviderTransform.schema(deepseekModel, schema) as any + // Circular ref: second visit returns empty (seen set prevents infinite loop) + expect(result.properties?.node?.properties?.self).toBeDefined() + }) + + test("preserves description when expanding $ref", () => { + const schema = { + type: "object", + properties: { + query: { + $ref: "#/$defs/Query", + description: "Override description", + }, + }, + $defs: { Query: { type: "string", description: "Original description" } }, + } as any + const result = ProviderTransform.schema(deepseekModel, schema) as any + // Override description should be preserved over definition description + expect(result.properties?.query).toEqual({ + type: "string", + description: "Override description", + }) + }) + + test("expands definitions (legacy format)", () => { + const schema = { + type: "object", + properties: { query: { $ref: "#/definitions/Query" } }, + definitions: { Query: { type: "string" } }, + } as any + const result = ProviderTransform.schema(deepseekModel, schema) as any + expect(result.properties?.query).toEqual({ type: "string" }) + expect(result.definitions).toBeUndefined() + }) + + test("does not expand $ref for non-DeepSeek providers", () => { + const openaiModel = { providerID: "openai", api: { id: "gpt-4o" } } as any + const schema = { + type: "object", + properties: { query: { $ref: "#/$defs/Query" } }, + $defs: { Query: { type: "string" } }, + } as any + const result = ProviderTransform.schema(openaiModel, schema) as any + // Should be unchanged — $ref preserved + expect(result.properties?.query?.$ref).toBe("#/$defs/Query") + expect(result.$defs).toBeDefined() + }) + + test("expands $ref in array items", () => { + const schema = { + type: "object", + properties: { + tags: { + type: "array", + items: { $ref: "#/$defs/Tag" }, + }, + }, + $defs: { Tag: { type: "string" } }, + } as any + const result = ProviderTransform.schema(deepseekModel, schema) as any + expect(result.properties?.tags?.items).toEqual({ type: "string" }) + }) +})