diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 92ff8fece835..693d92242d30 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] @@ -1286,6 +1287,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 +} + type JsonRecord = Record function isPlainObject(value: unknown): value is JsonRecord { @@ -1418,6 +1452,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 04d75bfba273..0c3946fb7061 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -4406,3 +4406,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" }) + }) +})