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