Browse Source

refactor: extract schema utilities from generate route

Extract INPUT_PATTERNS, InputMapping, ParameterTypeInfo, getParameterTypesFromSchema,
coerceParameterTypes, and getInputMappingFromSchema into schemaUtils.ts with 23 new tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
a242d3e281
  1. 262
      src/app/api/generate/__tests__/schemaUtils.test.ts
  2. 188
      src/app/api/generate/route.ts
  3. 187
      src/app/api/generate/schemaUtils.ts

262
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<string, unknown>, 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({});
});
});
});

188
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<string, string[]> = {
// 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<string, string>;
// Track which generic params expect array types (e.g., "image")
arrayParams: Set<string>;
// Track actual schema param names that expect array types (e.g., "image_urls")
schemaArrayParams: Set<string>;
}
/**
* 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<string, unknown> | undefined): ParameterTypeInfo {
const typeInfo: ParameterTypeInfo = {};
if (!schema) return typeInfo;
try {
const components = schema.components as Record<string, unknown> | undefined;
const schemas = components?.schemas as Record<string, unknown> | undefined;
const input = schemas?.Input as Record<string, unknown> | undefined;
const properties = input?.properties as Record<string, unknown> | undefined;
if (!properties) return typeInfo;
for (const [propName, prop] of Object.entries(properties)) {
const property = prop as Record<string, unknown>;
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<string, unknown> | undefined,
typeInfo: ParameterTypeInfo
): Record<string, unknown> {
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<string, unknown> | undefined): InputMapping {
const paramMap: Record<string, string> = {};
const arrayParams = new Set<string>();
const schemaArrayParams = new Set<string>();
if (!schema) return { paramMap, arrayParams, schemaArrayParams };
try {
// Navigate to input schema properties
const components = schema.components as Record<string, unknown> | undefined;
const schemas = components?.schemas as Record<string, unknown> | undefined;
const input = schemas?.Input as Record<string, unknown> | undefined;
const properties = input?.properties as Record<string, unknown> | 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<string, unknown>;
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<string, unknown>;
if (property?.type === "array") {
arrayParams.add(genericName);
}
break;
}
}
}
} catch {
// Schema parsing failed
}
return { paramMap, arrayParams, schemaArrayParams };
}
/**
* Generate image using Replicate API

187
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<string, string[]> = {
// 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<string, string>;
// Track which generic params expect array types (e.g., "image")
arrayParams: Set<string>;
// Track actual schema param names that expect array types (e.g., "image_urls")
schemaArrayParams: Set<string>;
}
/**
* 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<string, unknown> | undefined): ParameterTypeInfo {
const typeInfo: ParameterTypeInfo = {};
if (!schema) return typeInfo;
try {
const components = schema.components as Record<string, unknown> | undefined;
const schemas = components?.schemas as Record<string, unknown> | undefined;
const input = schemas?.Input as Record<string, unknown> | undefined;
const properties = input?.properties as Record<string, unknown> | undefined;
if (!properties) return typeInfo;
for (const [propName, prop] of Object.entries(properties)) {
const property = prop as Record<string, unknown>;
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<string, unknown> | undefined,
typeInfo: ParameterTypeInfo
): Record<string, unknown> {
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<string, unknown> | undefined): InputMapping {
const paramMap: Record<string, string> = {};
const arrayParams = new Set<string>();
const schemaArrayParams = new Set<string>();
if (!schema) return { paramMap, arrayParams, schemaArrayParams };
try {
// Navigate to input schema properties
const components = schema.components as Record<string, unknown> | undefined;
const schemas = components?.schemas as Record<string, unknown> | undefined;
const input = schemas?.Input as Record<string, unknown> | undefined;
const properties = input?.properties as Record<string, unknown> | 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<string, unknown>;
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<string, unknown>;
if (property?.type === "array") {
arrayParams.add(genericName);
}
break;
}
}
}
} catch {
// Schema parsing failed
}
return { paramMap, arrayParams, schemaArrayParams };
}
Loading…
Cancel
Save