Browse Source

fix: resolve anyOf/oneOf property types for dynamic image input detection

Add resolvePropertyType() helper to extract effective type/format from
OpenAPI wrapper patterns (anyOf, oneOf, allOf, $ref) used by Pydantic
and other code generators. Update isImageInput() and convertSchemaProperty()
to use resolved types instead of reading prop.type directly.

This ensures nullable fields like `{anyOf: [{type: "string"}, {type: "null"}]}`
are correctly identified as image inputs when their name matches image patterns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
fa199148a1
  1. 323
      src/app/api/models/[modelId]/__tests__/route.test.ts
  2. 153
      src/app/api/models/[modelId]/route.ts

323
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<string, unknown>,
required: string[] = [],
components?: Record<string, unknown>
) {
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");

153
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<string, unknown>): boolean {
function isImageInput(name: string, prop: Record<string, unknown>, schemaComponents?: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>,
schemaComponents?: Record<string, unknown>
): { 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<Record<string, unknown>> | 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<Record<string, unknown>> | 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<Record<string, unknown>> | 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<Record<string, unknown>> | 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<Record<string, unknown>> | 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;
}

Loading…
Cancel
Save