diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 8ec3887769..50fb93e996 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1089,6 +1089,21 @@ export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JS } */ + if (model.providerID === "moonshotai" || model.api.id.toLowerCase().includes("kimi")) { + const sanitizeMoonshot = (obj: unknown): unknown => { + if (obj === null || typeof obj !== "object") return obj + if (Array.isArray(obj)) return obj.map(sanitizeMoonshot) + // Moonshot expands $ref before validation and rejects sibling keywords like description on the same node. + if ("$ref" in obj && typeof obj.$ref === "string") return { $ref: obj.$ref } + const result = Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, sanitizeMoonshot(value)])) + // MFJS does not support tuple-style `items` arrays; it requires one schema object for all array items. + if (Array.isArray(result.items)) result.items = result.items[0] ?? {} + return result + } + + schema = sanitizeMoonshot(schema) as JSONSchema.BaseSchema | JSONSchema7 + } + // 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 d9eb364074..9929ff21d2 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -855,6 +855,160 @@ describe("ProviderTransform.schema - gemini non-object properties removal", () = }) }) +describe("ProviderTransform.schema - moonshot $ref siblings", () => { + const moonshotModel = { + providerID: "moonshotai", + api: { + id: "kimi-k2", + }, + } as any + + test("removes sibling descriptions from referenced tool parameter schemas", () => { + const schema = { + type: "object", + properties: { + deviceType: { + description: "Optional. The type of device that captured the screenshot, e.g. mobile or desktop.", + enum: ["DEVICE_TYPE_UNSPECIFIED", "MOBILE", "DESKTOP", "TABLET", "AGNOSTIC"], + type: "string", + }, + modelId: { + description: "Optional. The model to use for generation.", + enum: ["MODEL_ID_UNSPECIFIED", "GEMINI_3_PRO", "GEMINI_3_FLASH", "GEMINI_3_1_PRO"], + type: "string", + }, + projectId: { + description: "Required. The project ID of screens to generate variants for.", + type: "string", + }, + prompt: { + description: "Required. The input text used to generate the variants.", + type: "string", + }, + selectedScreenIds: { + description: "Required. The screen ids of screen to generate variants for.", + items: { + type: "string", + }, + type: "array", + }, + variantOptions: { + $ref: "#/$defs/VariantOptions", + description: + "Required. The variant options for generation, including the number of variants, creative range, and aspects to focus on.", + }, + }, + required: ["projectId", "selectedScreenIds", "prompt", "variantOptions"], + $defs: { + VariantOptions: { + description: + "Configuration options for design variant generation. This message captures all parameters used to generate variants, allowing the configuration to be stored, replayed, or analyzed.", + properties: { + aspects: { + description: "Optional. Specific aspects to focus on. If empty, all aspects may be varied.", + items: { + enum: [ + "VARIANT_ASPECT_UNSPECIFIED", + "LAYOUT", + "COLOR_SCHEME", + "IMAGES", + "TEXT_FONT", + "TEXT_CONTENT", + ], + type: "string", + }, + type: "array", + }, + creativeRange: { + description: "Optional. Creative range for variations. Default: EXPLORE", + enum: ["CREATIVE_RANGE_UNSPECIFIED", "REFINE", "EXPLORE", "REIMAGINE"], + type: "string", + }, + variantCount: { + description: "Optional. Number of variants to generate (1-5). Default: 3", + format: "int32", + type: "integer", + }, + }, + type: "object", + }, + }, + description: "Request message for GenerateVariants.", + additionalProperties: false, + } as any + + const result = ProviderTransform.schema(moonshotModel, schema) as any + + expect(result.properties.variantOptions).toEqual({ + $ref: "#/$defs/VariantOptions", + }) + expect(result.$defs.VariantOptions.description).toBe(schema.$defs.VariantOptions.description) + }) + + test("also runs for kimi models outside the moonshot provider", () => { + const result = ProviderTransform.schema( + { + providerID: "openrouter", + name: "Kimi K2", + api: { + id: "moonshotai/kimi-k2", + }, + } as any, + { + type: "object", + properties: { + value: { + $ref: "#/$defs/Value", + description: "Moonshot rejects this sibling after ref expansion.", + }, + }, + $defs: { + Value: { + description: "Referenced schema description stays here.", + type: "object", + }, + }, + } as any, + ) as any + + expect(result.properties.value).toEqual({ + $ref: "#/$defs/Value", + }) + }) + + test("converts tuple-style array items to a single item schema", () => { + const result = ProviderTransform.schema( + moonshotModel, + { + type: "object", + properties: { + codeSpec: { + type: "object", + properties: { + accessibility: { + type: "object", + properties: { + renderedSize: { + description: "Rendered size [width, height] in px", + type: "array", + items: [{ type: "number" }, { type: "number" }], + minItems: 2, + maxItems: 2, + }, + }, + }, + }, + }, + }, + } as any, + ) as any + + expect(result.properties.codeSpec.properties.accessibility.properties.renderedSize.items).toEqual({ + type: "number", + }) + }) +}) + describe("ProviderTransform.message - DeepSeek reasoning content", () => { test("DeepSeek with tool calls includes reasoning_content in providerOptions", () => { const msgs = [