diff --git a/src/app/api/models/[modelId]/__tests__/route.test.ts b/src/app/api/models/[modelId]/__tests__/route.test.ts index 42a3ca09..cb4e4daf 100644 --- a/src/app/api/models/[modelId]/__tests__/route.test.ts +++ b/src/app/api/models/[modelId]/__tests__/route.test.ts @@ -451,6 +451,329 @@ describe("/api/models/[modelId] schema endpoint", () => { }); }); + describe("real Fal Kling v2.6 pro image-to-video schema", () => { + it("should detect both start_image_url and end_image_url as image inputs", async () => { + // Exact schema structure from Fal API for fal-ai/kling-video/v2.6/pro/image-to-video + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + models: [{ + openapi: { + openapi: "3.0.4", + components: { + schemas: { + KlingVideoV26ProImageToVideoInput: { + title: "ImageToVideoV26ProRequest", + type: "object", + properties: { + prompt: { + title: "Prompt", + type: "string", + maxLength: 2500, + }, + duration: { + enum: ["5", "10"], + title: "Duration", + type: "string", + description: "The duration of the generated video in seconds", + default: "5", + }, + generate_audio: { + title: "Generate Audio", + type: "boolean", + description: "Whether to generate native audio for the video.", + default: true, + }, + start_image_url: { + description: "URL of the image to be used for the video", + type: "string", + title: "Start Image Url", + }, + end_image_url: { + title: "End Image Url", + type: "string", + description: "URL of the image to be used for the end of the video", + }, + negative_prompt: { + title: "Negative Prompt", + type: "string", + maxLength: 2500, + default: "blur, distort, and low quality", + }, + }, + required: ["prompt", "start_image_url"], + }, + }, + }, + paths: { + "/fal-ai/kling-video/v2.6/pro/image-to-video": { + post: { + requestBody: { + required: true, + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/KlingVideoV26ProImageToVideoInput", + }, + }, + }, + }, + }, + }, + }, + }, + }], + }), + }); + + const modelId = `fal-ai/kling-video-real-${testCounter}`; + const request = createMockSchemaRequest(modelId, "fal"); + const response = await GET(request, { params: Promise.resolve({ modelId }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + + const imageInputNames = data.inputs + .filter((i: { type: string }) => i.type === "image") + .map((i: { name: string }) => i.name); + const textInputNames = data.inputs + .filter((i: { type: string }) => i.type === "text") + .map((i: { name: string }) => i.name); + const paramNames = data.parameters.map((p: { name: string }) => p.name); + + // Both image URL fields should be detected as image inputs + expect(imageInputNames).toContain("start_image_url"); + expect(imageInputNames).toContain("end_image_url"); + + // Text inputs + expect(textInputNames).toContain("prompt"); + expect(textInputNames).toContain("negative_prompt"); + + // Parameters (not image or text inputs) + expect(paramNames).toContain("duration"); + expect(paramNames).toContain("generate_audio"); + + // Image inputs should NOT appear as parameters + expect(paramNames).not.toContain("start_image_url"); + expect(paramNames).not.toContain("end_image_url"); + }); + }); + + describe("anyOf/oneOf nullable schema patterns", () => { + // Helper to create fal.ai response with components.schemas for $ref resolution + function createFalModelResponseWithComponents( + inputProperties: Record, + required: string[] = [], + components?: Record + ) { + return { + ok: true, + json: () => Promise.resolve({ + models: [{ + openapi: { + paths: { + "/": { + post: { + requestBody: { + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/Input", + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Input: { + type: "object", + properties: inputProperties, + required, + }, + ...components, + }, + }, + }, + }], + }), + }; + } + + it("should detect anyOf nullable string as image input (Kling pattern)", async () => { + // Exact pattern from Kling v2.6 image-to-video on Fal + mockFetch.mockResolvedValueOnce( + createFalModelResponseWithComponents({ + prompt: { + type: "string", + description: "Text prompt", + }, + image_url: { + type: "string", + description: "The URL of the image", + }, + end_image_url: { + anyOf: [{ type: "string" }, { type: "null" }], + description: "The URL of the end image", + }, + }, ["prompt", "image_url"]) + ); + + const modelId = `fal-ai/kling-anyof-${testCounter}`; + const request = createMockSchemaRequest(modelId, "fal"); + const response = await GET(request, { params: Promise.resolve({ modelId }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + + const imageInputNames = data.inputs + .filter((i: { type: string }) => i.type === "image") + .map((i: { name: string }) => i.name); + + // Both image_url and end_image_url should be image inputs + expect(imageInputNames).toContain("image_url"); + expect(imageInputNames).toContain("end_image_url"); + }); + + it("should detect anyOf with format: uri as image input", async () => { + mockFetch.mockResolvedValueOnce( + createFalModelResponseWithComponents({ + reference_image: { + anyOf: [ + { type: "string", format: "uri" }, + { type: "null" }, + ], + description: "Reference image URL", + }, + }) + ); + + const modelId = `fal-ai/anyof-uri-${testCounter}`; + const request = createMockSchemaRequest(modelId, "fal"); + const response = await GET(request, { params: Promise.resolve({ modelId }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + + const imageInputNames = data.inputs + .filter((i: { type: string }) => i.type === "image") + .map((i: { name: string }) => i.name); + + expect(imageInputNames).toContain("reference_image"); + }); + + it("should NOT detect anyOf with non-string types as image input", async () => { + mockFetch.mockResolvedValueOnce( + createFalModelResponseWithComponents({ + image_guidance_scale: { + anyOf: [{ type: "number" }, { type: "null" }], + description: "Image guidance scale", + }, + image_count: { + anyOf: [{ type: "integer" }, { type: "null" }], + description: "Number of images", + }, + }) + ); + + const modelId = `fal-ai/anyof-nonstring-${testCounter}`; + const request = createMockSchemaRequest(modelId, "fal"); + const response = await GET(request, { params: Promise.resolve({ modelId }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + + const paramNames = data.parameters.map((p: { name: string }) => p.name); + const inputNames = data.inputs.map((i: { name: string }) => i.name); + + expect(paramNames).toContain("image_guidance_scale"); + expect(paramNames).toContain("image_count"); + expect(inputNames).not.toContain("image_guidance_scale"); + expect(inputNames).not.toContain("image_count"); + }); + + it("should handle oneOf pattern same as anyOf", async () => { + mockFetch.mockResolvedValueOnce( + createFalModelResponseWithComponents({ + start_image: { + oneOf: [{ type: "string" }, { type: "null" }], + description: "The start image URL", + }, + }) + ); + + const modelId = `fal-ai/oneof-${testCounter}`; + const request = createMockSchemaRequest(modelId, "fal"); + const response = await GET(request, { params: Promise.resolve({ modelId }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + + const imageInputNames = data.inputs + .filter((i: { type: string }) => i.type === "image") + .map((i: { name: string }) => i.name); + + expect(imageInputNames).toContain("start_image"); + }); + + it("should resolve anyOf parameter types correctly (not default to string)", async () => { + mockFetch.mockResolvedValueOnce( + createFalModelResponseWithComponents({ + seed: { + anyOf: [{ type: "integer" }, { type: "null" }], + description: "Random seed", + }, + guidance_scale: { + anyOf: [{ type: "number" }, { type: "null" }], + description: "Guidance scale", + default: 7.5, + }, + }) + ); + + const modelId = `fal-ai/anyof-types-${testCounter}`; + const request = createMockSchemaRequest(modelId, "fal"); + const response = await GET(request, { params: Promise.resolve({ modelId }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + + const seedParam = data.parameters.find((p: { name: string }) => p.name === "seed"); + const guidanceParam = data.parameters.find((p: { name: string }) => p.name === "guidance_scale"); + + expect(seedParam?.type).toBe("integer"); + expect(guidanceParam?.type).toBe("number"); + }); + + it("should NOT classify anyOf boolean with image in name as image input", async () => { + mockFetch.mockResolvedValueOnce( + createFalModelResponseWithComponents({ + enable_image_enhancement: { + anyOf: [{ type: "boolean" }, { type: "null" }], + description: "Enable image enhancement", + default: false, + }, + }) + ); + + const modelId = `fal-ai/anyof-bool-${testCounter}`; + const request = createMockSchemaRequest(modelId, "fal"); + const response = await GET(request, { params: Promise.resolve({ modelId }) }); + const data = await response.json(); + + expect(response.status).toBe(200); + + const paramNames = data.parameters.map((p: { name: string }) => p.name); + const inputNames = data.inputs.map((i: { name: string }) => i.name); + + expect(paramNames).toContain("enable_image_enhancement"); + expect(inputNames).not.toContain("enable_image_enhancement"); + }); + }); + describe("error handling", () => { it("should return 400 for invalid provider", async () => { const request = createMockSchemaRequest("test/model", "invalid"); diff --git a/src/app/api/models/[modelId]/route.ts b/src/app/api/models/[modelId]/route.ts index 1300fc5b..3c6ac278 100644 --- a/src/app/api/models/[modelId]/route.ts +++ b/src/app/api/models/[modelId]/route.ts @@ -123,10 +123,11 @@ function toLabel(name: string): string { * Image inputs must be strings (URLs or base64) or arrays of strings. * Integers, booleans, numbers with "image" in the name are NOT image inputs. */ -function isImageInput(name: string, prop: Record): boolean { +function isImageInput(name: string, prop: Record, schemaComponents?: Record): boolean { // First check: must be a string type (images are URLs or base64 strings) // Integers, booleans, numbers are NEVER image inputs regardless of name - const propType = prop.type as string | undefined; + const resolved = resolvePropertyType(prop, schemaComponents); + const propType = resolved.type; if (propType !== "string" && propType !== "array") { return false; } @@ -146,8 +147,8 @@ function isImageInput(name: string, prop: Record): boolean { return false; } - // Check format hints (OpenAPI format field) - strong signal for image URLs - const format = prop.format as string | undefined; + // Check format hints (OpenAPI format field or resolved format) - strong signal for image URLs + const format = (prop.format ?? resolved.format) as string | undefined; if (format === "uri" || format === "data-uri" || format === "binary") { // Only treat as image if name also suggests it's an image if (IMAGE_INPUT_PATTERNS.includes(name) || @@ -215,6 +216,68 @@ function resolveRef( return resolved || null; } +/** + * Resolve the effective type and format from an OpenAPI property. + * + * Handles wrapper patterns used by code generators (e.g. Pydantic → OpenAPI): + * - anyOf / oneOf: picks the first non-null type (nullable pattern) + * - allOf: merges referenced schemas + * - $ref: resolves from schemaComponents + * - Direct type: returns immediately (fast path — no behavior change) + */ +function resolvePropertyType( + prop: Record, + schemaComponents?: Record +): { type?: string; format?: string } { + // Fast path: direct type is defined — existing behaviour, no change + if (prop.type !== undefined) { + return { type: prop.type as string, format: prop.format as string | undefined }; + } + + // anyOf / oneOf — pick the first non-null variant + const variants = (prop.anyOf ?? prop.oneOf) as Array> | undefined; + if (variants && Array.isArray(variants)) { + for (const variant of variants) { + // Resolve $ref inside variant + if (variant.$ref && typeof variant.$ref === "string" && schemaComponents) { + const resolved = resolveRef(variant.$ref as string, schemaComponents); + if (resolved && resolved.type && resolved.type !== "null") { + return { type: resolved.type as string, format: (resolved.format ?? prop.format) as string | undefined }; + } + } + if (variant.type && variant.type !== "null") { + return { type: variant.type as string, format: (variant.format ?? prop.format) as string | undefined }; + } + } + } + + // allOf — merge referenced schemas + const allOf = prop.allOf as Array> | undefined; + if (allOf && Array.isArray(allOf) && schemaComponents) { + for (const item of allOf) { + if (item.$ref && typeof item.$ref === "string") { + const resolved = resolveRef(item.$ref as string, schemaComponents); + if (resolved && resolved.type) { + return { type: resolved.type as string, format: (resolved.format ?? prop.format) as string | undefined }; + } + } + if (item.type) { + return { type: item.type as string, format: (item.format ?? prop.format) as string | undefined }; + } + } + } + + // $ref at top level + if (prop.$ref && typeof prop.$ref === "string" && schemaComponents) { + const resolved = resolveRef(prop.$ref as string, schemaComponents); + if (resolved && resolved.type) { + return { type: resolved.type as string, format: (resolved.format ?? prop.format) as string | undefined }; + } + } + + return {}; +} + /** * Convert OpenAPI schema property to ModelParameter */ @@ -229,55 +292,80 @@ function convertSchemaProperty( return null; } - // Determine type and extract enum from allOf/$ref if present + // Determine type and extract enum from allOf/$ref/anyOf/oneOf if present let type: ModelParameter["type"] = "string"; let enumValues: unknown[] | undefined; let resolvedDefault: unknown; let resolvedDescription: string | undefined; - const schemaType = prop.type as string | undefined; - const allOf = prop.allOf as Array> | undefined; + // Use resolvePropertyType() to handle anyOf/oneOf/allOf/$ref patterns + const resolved = resolvePropertyType(prop, schemaComponents); + const effectiveType = resolved.type; - if (schemaType === "integer") { + if (effectiveType === "integer") { type = "integer"; - } else if (schemaType === "number") { + } else if (effectiveType === "number") { type = "number"; - } else if (schemaType === "boolean") { + } else if (effectiveType === "boolean") { type = "boolean"; - } else if (schemaType === "array") { + } else if (effectiveType === "array") { type = "array"; - } else if (allOf && allOf.length > 0 && schemaComponents) { - // Handle allOf with $ref - resolve references and extract enum/type + } + + // Extract enum/default/description from allOf with $ref + const allOf = prop.allOf as Array> | undefined; + if (allOf && allOf.length > 0 && schemaComponents) { for (const item of allOf) { const itemRef = item.$ref as string | undefined; if (itemRef) { - const resolved = resolveRef(itemRef, schemaComponents); - if (resolved) { - // Extract type from resolved schema - if (resolved.type === "integer") type = "integer"; - else if (resolved.type === "number") type = "number"; - else if (resolved.type === "boolean") type = "boolean"; - - // Extract enum from resolved schema - if (Array.isArray(resolved.enum)) { - enumValues = resolved.enum; + const refResolved = resolveRef(itemRef, schemaComponents); + if (refResolved) { + if (Array.isArray(refResolved.enum)) { + enumValues = refResolved.enum; } - // Extract default from resolved schema - if (resolved.default !== undefined && resolvedDefault === undefined) { - resolvedDefault = resolved.default; + if (refResolved.default !== undefined && resolvedDefault === undefined) { + resolvedDefault = refResolved.default; } - // Extract description from resolved schema - if (resolved.description && !resolvedDescription) { - resolvedDescription = resolved.description as string; + if (refResolved.description && !resolvedDescription) { + resolvedDescription = refResolved.description as string; } } } else if (Array.isArray(item.enum)) { - // Direct enum in allOf item enumValues = item.enum; } } } + // Extract enum/default/description from anyOf/oneOf variants + const variants = (prop.anyOf ?? prop.oneOf) as Array> | undefined; + if (variants && Array.isArray(variants)) { + for (const variant of variants) { + if (variant.type === "null") continue; + // Resolve $ref inside variant + if (variant.$ref && typeof variant.$ref === "string" && schemaComponents) { + const refResolved = resolveRef(variant.$ref as string, schemaComponents); + if (refResolved) { + if (Array.isArray(refResolved.enum) && !enumValues) { + enumValues = refResolved.enum; + } + if (refResolved.default !== undefined && resolvedDefault === undefined) { + resolvedDefault = refResolved.default; + } + if (refResolved.description && !resolvedDescription) { + resolvedDescription = refResolved.description as string; + } + } + } else { + if (Array.isArray(variant.enum) && !enumValues) { + enumValues = variant.enum; + } + if (variant.default !== undefined && resolvedDefault === undefined) { + resolvedDefault = variant.default; + } + } + } + } + const parameter: ModelParameter = { name, type, @@ -439,14 +527,15 @@ function extractParametersFromSchema( for (const [name, prop] of Object.entries(properties)) { // Check if this is a connectable input (image or text) // Pass both name AND prop to check schema type, not just name - if (isImageInput(name, prop)) { + if (isImageInput(name, prop, schemaComponents)) { + const resolvedType = resolvePropertyType(prop, schemaComponents).type; inputs.push({ name, type: "image", required: required.includes(name), label: toLabel(name), description: prop.description as string | undefined, - isArray: prop.type === "array", + isArray: resolvedType === "array", }); continue; }