diff --git a/src/app/api/generate/__tests__/schemaUtils.test.ts b/src/app/api/generate/__tests__/schemaUtils.test.ts new file mode 100644 index 00000000..3cec95a6 --- /dev/null +++ b/src/app/api/generate/__tests__/schemaUtils.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect } from "vitest"; +import { + INPUT_PATTERNS, + getParameterTypesFromSchema, + coerceParameterTypes, + getInputMappingFromSchema, +} from "../schemaUtils"; +import type { ParameterTypeInfo } from "../schemaUtils"; + +describe("schemaUtils", () => { + describe("INPUT_PATTERNS", () => { + it("should contain all expected input categories", () => { + expect(INPUT_PATTERNS).toHaveProperty("prompt"); + expect(INPUT_PATTERNS).toHaveProperty("negativePrompt"); + expect(INPUT_PATTERNS).toHaveProperty("image"); + expect(INPUT_PATTERNS).toHaveProperty("aspectRatio"); + expect(INPUT_PATTERNS).toHaveProperty("duration"); + expect(INPUT_PATTERNS).toHaveProperty("fps"); + expect(INPUT_PATTERNS).toHaveProperty("audio"); + expect(INPUT_PATTERNS).toHaveProperty("seed"); + expect(INPUT_PATTERNS).toHaveProperty("steps"); + expect(INPUT_PATTERNS).toHaveProperty("guidance"); + expect(INPUT_PATTERNS).toHaveProperty("scheduler"); + expect(INPUT_PATTERNS).toHaveProperty("strength"); + }); + + it("should have 'prompt' as first pattern for prompt category", () => { + expect(INPUT_PATTERNS.prompt[0]).toBe("prompt"); + }); + + it("should include common image parameter names", () => { + expect(INPUT_PATTERNS.image).toContain("image_url"); + expect(INPUT_PATTERNS.image).toContain("image_urls"); + expect(INPUT_PATTERNS.image).toContain("first_frame"); + }); + }); + + describe("getParameterTypesFromSchema", () => { + it("should return empty object for undefined schema", () => { + expect(getParameterTypesFromSchema(undefined)).toEqual({}); + }); + + it("should return empty object for schema without components", () => { + expect(getParameterTypesFromSchema({})).toEqual({}); + }); + + it("should extract types from valid schema", () => { + const schema = { + components: { + schemas: { + Input: { + properties: { + prompt: { type: "string" }, + steps: { type: "integer" }, + guidance_scale: { type: "number" }, + enable_audio: { type: "boolean" }, + images: { type: "array" }, + }, + }, + }, + }, + }; + + const result = getParameterTypesFromSchema(schema); + expect(result).toEqual({ + prompt: "string", + steps: "integer", + guidance_scale: "number", + enable_audio: "boolean", + images: "array", + }); + }); + + it("should ignore unknown types", () => { + const schema = { + components: { + schemas: { + Input: { + properties: { + prompt: { type: "string" }, + custom: { type: "custom_type" }, + }, + }, + }, + }, + }; + + const result = getParameterTypesFromSchema(schema); + expect(result).toEqual({ prompt: "string" }); + }); + + it("should handle schema without properties", () => { + const schema = { + components: { + schemas: { + Input: {}, + }, + }, + }; + + expect(getParameterTypesFromSchema(schema)).toEqual({}); + }); + }); + + describe("coerceParameterTypes", () => { + it("should return empty object for undefined parameters", () => { + expect(coerceParameterTypes(undefined, {})).toEqual({}); + }); + + it("should coerce string to integer", () => { + const params = { steps: "20" }; + const types: ParameterTypeInfo = { steps: "integer" }; + expect(coerceParameterTypes(params, types)).toEqual({ steps: 20 }); + }); + + it("should coerce string to number", () => { + const params = { guidance_scale: "7.5" }; + const types: ParameterTypeInfo = { guidance_scale: "number" }; + expect(coerceParameterTypes(params, types)).toEqual({ guidance_scale: 7.5 }); + }); + + it("should coerce string to boolean", () => { + const params = { enable_audio: "true" }; + const types: ParameterTypeInfo = { enable_audio: "boolean" }; + expect(coerceParameterTypes(params, types)).toEqual({ enable_audio: true }); + }); + + it("should coerce 'false' string to false boolean", () => { + const params = { enable_audio: "false" }; + const types: ParameterTypeInfo = { enable_audio: "boolean" }; + expect(coerceParameterTypes(params, types)).toEqual({ enable_audio: false }); + }); + + it("should not coerce non-string values", () => { + const params = { steps: 20, guidance: 7.5 }; + const types: ParameterTypeInfo = { steps: "integer", guidance: "number" }; + expect(coerceParameterTypes(params, types)).toEqual({ steps: 20, guidance: 7.5 }); + }); + + it("should skip null and undefined values", () => { + const params = { steps: null, guidance: undefined, prompt: "hello" }; + const types: ParameterTypeInfo = { steps: "integer", guidance: "number", prompt: "string" }; + expect(coerceParameterTypes(params as Record, types)).toEqual({ + steps: null, + guidance: undefined, + prompt: "hello", + }); + }); + + it("should skip params with no type info", () => { + const params = { unknown_param: "42" }; + const types: ParameterTypeInfo = {}; + expect(coerceParameterTypes(params, types)).toEqual({ unknown_param: "42" }); + }); + + it("should not coerce invalid number strings", () => { + const params = { steps: "abc" }; + const types: ParameterTypeInfo = { steps: "integer" }; + const result = coerceParameterTypes(params, types); + expect(result.steps).toBe("abc"); // NaN check prevents coercion + }); + }); + + describe("getInputMappingFromSchema", () => { + it("should return empty mapping for undefined schema", () => { + const result = getInputMappingFromSchema(undefined); + expect(result.paramMap).toEqual({}); + expect(result.arrayParams.size).toBe(0); + expect(result.schemaArrayParams.size).toBe(0); + }); + + it("should map exact matches", () => { + const schema = { + components: { + schemas: { + Input: { + properties: { + prompt: { type: "string" }, + image_url: { type: "string" }, + aspect_ratio: { type: "string" }, + }, + }, + }, + }, + }; + + const result = getInputMappingFromSchema(schema); + expect(result.paramMap.prompt).toBe("prompt"); + expect(result.paramMap.image).toBe("image_url"); + expect(result.paramMap.aspectRatio).toBe("aspect_ratio"); + }); + + it("should detect array types", () => { + const schema = { + components: { + schemas: { + Input: { + properties: { + image_urls: { type: "array" }, + prompt: { type: "string" }, + }, + }, + }, + }, + }; + + const result = getInputMappingFromSchema(schema); + expect(result.paramMap.image).toBe("image_urls"); + expect(result.arrayParams.has("image")).toBe(true); + expect(result.schemaArrayParams.has("image_urls")).toBe(true); + }); + + it("should match case-insensitive partial matches", () => { + const schema = { + components: { + schemas: { + Input: { + properties: { + my_prompt_text: { type: "string" }, + }, + }, + }, + }, + }; + + const result = getInputMappingFromSchema(schema); + // "prompt" pattern should match "my_prompt_text" via partial match + expect(result.paramMap.prompt).toBe("my_prompt_text"); + }); + + it("should prefer exact matches over partial matches", () => { + const schema = { + components: { + schemas: { + Input: { + properties: { + prompt: { type: "string" }, + my_prompt_text: { type: "string" }, + }, + }, + }, + }, + }; + + const result = getInputMappingFromSchema(schema); + expect(result.paramMap.prompt).toBe("prompt"); + }); + + it("should handle schema with no Input schema", () => { + const schema = { + components: { + schemas: { + Output: { properties: {} }, + }, + }, + }; + + const result = getInputMappingFromSchema(schema); + expect(result.paramMap).toEqual({}); + }); + }); +}); diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index b72d48ae..122e03cf 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -16,6 +16,14 @@ import { GenerateRequest, GenerateResponse, ModelType, SelectedModel, ProviderTy import { GenerationInput, GenerationOutput, ProviderModel } from "@/lib/providers/types"; import { uploadImageForUrl, shouldUseImageUrl, deleteImages } from "@/lib/images"; import { validateMediaUrl } from "@/utils/urlValidation"; +import { + INPUT_PATTERNS, + InputMapping, + ParameterTypeInfo, + getParameterTypesFromSchema, + coerceParameterTypes, + getInputMappingFromSchema, +} from "./schemaUtils"; export const maxDuration = 300; // 5 minute timeout (Vercel hobby plan limit) export const dynamic = 'force-dynamic'; // Ensure this route is always dynamic @@ -207,186 +215,6 @@ async function generateWithGemini( ); } -/** - * Input parameter patterns - maps generic input types to possible schema parameter names - */ -const INPUT_PATTERNS: Record = { - // Text/prompt inputs - prompt: ["prompt", "text", "caption", "input_text", "description", "query"], - negativePrompt: ["negative_prompt", "negative", "neg_prompt", "negative_text"], - - // Image inputs - image: ["image_url", "image_urls", "image", "first_frame", "start_image", "init_image", - "reference_image", "input_image", "image_input", "source_image", "img", "photo"], - - // Video/media settings - aspectRatio: ["aspect_ratio", "ratio", "size", "dimensions", "output_size"], - duration: ["duration", "length", "num_frames", "seconds", "video_length"], - fps: ["fps", "frame_rate", "framerate", "frames_per_second"], - - // Audio settings - audio: ["audio_enabled", "with_audio", "enable_audio", "audio", "sound"], - - // Generation settings - seed: ["seed", "random_seed", "noise_seed"], - steps: ["steps", "num_steps", "num_inference_steps", "inference_steps"], - guidance: ["guidance_scale", "guidance", "cfg_scale", "cfg"], - - // Model-specific - scheduler: ["scheduler", "sampler", "sampler_name"], - strength: ["strength", "denoise", "denoising_strength"], -}; - -/** - * Input mapping result from schema parsing - */ -interface InputMapping { - // Maps our generic names to model-specific parameter names - paramMap: Record; - // Track which generic params expect array types (e.g., "image") - arrayParams: Set; - // Track actual schema param names that expect array types (e.g., "image_urls") - schemaArrayParams: Set; -} - -/** - * Parameter type information extracted from OpenAPI schema - */ -interface ParameterTypeInfo { - [paramName: string]: "string" | "integer" | "number" | "boolean" | "array" | "object"; -} - -/** - * Extract parameter types from OpenAPI schema - */ -function getParameterTypesFromSchema(schema: Record | undefined): ParameterTypeInfo { - const typeInfo: ParameterTypeInfo = {}; - - if (!schema) return typeInfo; - - try { - const components = schema.components as Record | undefined; - const schemas = components?.schemas as Record | undefined; - const input = schemas?.Input as Record | undefined; - const properties = input?.properties as Record | undefined; - - if (!properties) return typeInfo; - - for (const [propName, prop] of Object.entries(properties)) { - const property = prop as Record; - const type = property?.type as string | undefined; - if (type && ["string", "integer", "number", "boolean", "array", "object"].includes(type)) { - typeInfo[propName] = type as ParameterTypeInfo[string]; - } - } - } catch { - // Schema parsing failed - } - - return typeInfo; -} - -/** - * Coerce parameter values to their expected types based on schema - * This handles cases where values were incorrectly stored as strings (e.g., from UI enum selects) - */ -function coerceParameterTypes( - parameters: Record | undefined, - typeInfo: ParameterTypeInfo -): Record { - if (!parameters) return {}; - - const result = { ...parameters }; - - for (const [key, value] of Object.entries(result)) { - if (value === undefined || value === null) continue; - - const expectedType = typeInfo[key]; - if (!expectedType) continue; - - // Coerce string values to their expected types - if (typeof value === "string") { - if (expectedType === "integer") { - const parsed = parseInt(value, 10); - if (!isNaN(parsed)) result[key] = parsed; - } else if (expectedType === "number") { - const parsed = parseFloat(value); - if (!isNaN(parsed)) result[key] = parsed; - } else if (expectedType === "boolean") { - result[key] = value === "true"; - } - } - } - - return result; -} - -/** - * Extract input parameter mappings from OpenAPI schema - * Returns a mapping of generic parameter names to model-specific names - */ -function getInputMappingFromSchema(schema: Record | undefined): InputMapping { - const paramMap: Record = {}; - const arrayParams = new Set(); - const schemaArrayParams = new Set(); - - if (!schema) return { paramMap, arrayParams, schemaArrayParams }; - - try { - // Navigate to input schema properties - const components = schema.components as Record | undefined; - const schemas = components?.schemas as Record | undefined; - const input = schemas?.Input as Record | undefined; - const properties = input?.properties as Record | undefined; - - if (!properties) return { paramMap, arrayParams, schemaArrayParams }; - - // First pass: detect all array-typed properties by their actual schema name - for (const [propName, prop] of Object.entries(properties)) { - const property = prop as Record; - if (property?.type === "array") { - schemaArrayParams.add(propName); - } - } - - const propertyNames = Object.keys(properties); - - // For each input type pattern, find the matching schema property - for (const [genericName, patterns] of Object.entries(INPUT_PATTERNS)) { - for (const pattern of patterns) { - let matchedParam: string | null = null; - - // Check for exact match first - if (properties[pattern]) { - matchedParam = pattern; - } else { - // Check for case-insensitive partial match - const match = propertyNames.find(name => - name.toLowerCase().includes(pattern.toLowerCase()) || - pattern.toLowerCase().includes(name.toLowerCase()) - ); - if (match) { - matchedParam = match; - } - } - - if (matchedParam) { - paramMap[genericName] = matchedParam; - // Check if this property expects an array type - const property = properties[matchedParam] as Record; - if (property?.type === "array") { - arrayParams.add(genericName); - } - break; - } - } - } - } catch { - // Schema parsing failed - } - - return { paramMap, arrayParams, schemaArrayParams }; -} /** * Generate image using Replicate API diff --git a/src/app/api/generate/schemaUtils.ts b/src/app/api/generate/schemaUtils.ts new file mode 100644 index 00000000..6b00ca1a --- /dev/null +++ b/src/app/api/generate/schemaUtils.ts @@ -0,0 +1,187 @@ +/** + * Schema Utilities for Generate API Route + * + * Provides input parameter pattern matching, type extraction, and coercion + * from OpenAPI schemas used by multi-provider generation. + */ + +/** + * Input parameter patterns - maps generic input types to possible schema parameter names + */ +export const INPUT_PATTERNS: Record = { + // Text/prompt inputs + prompt: ["prompt", "text", "caption", "input_text", "description", "query"], + negativePrompt: ["negative_prompt", "negative", "neg_prompt", "negative_text"], + + // Image inputs + image: ["image_url", "image_urls", "image", "first_frame", "start_image", "init_image", + "reference_image", "input_image", "image_input", "source_image", "img", "photo"], + + // Video/media settings + aspectRatio: ["aspect_ratio", "ratio", "size", "dimensions", "output_size"], + duration: ["duration", "length", "num_frames", "seconds", "video_length"], + fps: ["fps", "frame_rate", "framerate", "frames_per_second"], + + // Audio settings + audio: ["audio_enabled", "with_audio", "enable_audio", "audio", "sound"], + + // Generation settings + seed: ["seed", "random_seed", "noise_seed"], + steps: ["steps", "num_steps", "num_inference_steps", "inference_steps"], + guidance: ["guidance_scale", "guidance", "cfg_scale", "cfg"], + + // Model-specific + scheduler: ["scheduler", "sampler", "sampler_name"], + strength: ["strength", "denoise", "denoising_strength"], +}; + +/** + * Input mapping result from schema parsing + */ +export interface InputMapping { + // Maps our generic names to model-specific parameter names + paramMap: Record; + // Track which generic params expect array types (e.g., "image") + arrayParams: Set; + // Track actual schema param names that expect array types (e.g., "image_urls") + schemaArrayParams: Set; +} + +/** + * Parameter type information extracted from OpenAPI schema + */ +export interface ParameterTypeInfo { + [paramName: string]: "string" | "integer" | "number" | "boolean" | "array" | "object"; +} + +/** + * Extract parameter types from OpenAPI schema + */ +export function getParameterTypesFromSchema(schema: Record | undefined): ParameterTypeInfo { + const typeInfo: ParameterTypeInfo = {}; + + if (!schema) return typeInfo; + + try { + const components = schema.components as Record | undefined; + const schemas = components?.schemas as Record | undefined; + const input = schemas?.Input as Record | undefined; + const properties = input?.properties as Record | undefined; + + if (!properties) return typeInfo; + + for (const [propName, prop] of Object.entries(properties)) { + const property = prop as Record; + const type = property?.type as string | undefined; + if (type && ["string", "integer", "number", "boolean", "array", "object"].includes(type)) { + typeInfo[propName] = type as ParameterTypeInfo[string]; + } + } + } catch { + // Schema parsing failed + } + + return typeInfo; +} + +/** + * Coerce parameter values to their expected types based on schema + * This handles cases where values were incorrectly stored as strings (e.g., from UI enum selects) + */ +export function coerceParameterTypes( + parameters: Record | undefined, + typeInfo: ParameterTypeInfo +): Record { + if (!parameters) return {}; + + const result = { ...parameters }; + + for (const [key, value] of Object.entries(result)) { + if (value === undefined || value === null) continue; + + const expectedType = typeInfo[key]; + if (!expectedType) continue; + + // Coerce string values to their expected types + if (typeof value === "string") { + if (expectedType === "integer") { + const parsed = parseInt(value, 10); + if (!isNaN(parsed)) result[key] = parsed; + } else if (expectedType === "number") { + const parsed = parseFloat(value); + if (!isNaN(parsed)) result[key] = parsed; + } else if (expectedType === "boolean") { + result[key] = value === "true"; + } + } + } + + return result; +} + +/** + * Extract input parameter mappings from OpenAPI schema + * Returns a mapping of generic parameter names to model-specific names + */ +export function getInputMappingFromSchema(schema: Record | undefined): InputMapping { + const paramMap: Record = {}; + const arrayParams = new Set(); + const schemaArrayParams = new Set(); + + if (!schema) return { paramMap, arrayParams, schemaArrayParams }; + + try { + // Navigate to input schema properties + const components = schema.components as Record | undefined; + const schemas = components?.schemas as Record | undefined; + const input = schemas?.Input as Record | undefined; + const properties = input?.properties as Record | undefined; + + if (!properties) return { paramMap, arrayParams, schemaArrayParams }; + + // First pass: detect all array-typed properties by their actual schema name + for (const [propName, prop] of Object.entries(properties)) { + const property = prop as Record; + if (property?.type === "array") { + schemaArrayParams.add(propName); + } + } + + const propertyNames = Object.keys(properties); + + // For each input type pattern, find the matching schema property + for (const [genericName, patterns] of Object.entries(INPUT_PATTERNS)) { + for (const pattern of patterns) { + let matchedParam: string | null = null; + + // Check for exact match first + if (properties[pattern]) { + matchedParam = pattern; + } else { + // Check for case-insensitive partial match + const match = propertyNames.find(name => + name.toLowerCase().includes(pattern.toLowerCase()) || + pattern.toLowerCase().includes(name.toLowerCase()) + ); + if (match) { + matchedParam = match; + } + } + + if (matchedParam) { + paramMap[genericName] = matchedParam; + // Check if this property expects an array type + const property = properties[matchedParam] as Record; + if (property?.type === "array") { + arrayParams.add(genericName); + } + break; + } + } + } + } catch { + // Schema parsing failed + } + + return { paramMap, arrayParams, schemaArrayParams }; +}