Browse Source
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
3 changed files with 457 additions and 180 deletions
@ -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({}); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -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…
Reference in new issue