Browse Source

feat(ENHANCE-001): extract model inputs from schema

- Add ModelInput type for connectable inputs (image/text handles)
- Update model schema API to return both parameters and inputs
- Categorize properties: image inputs, text inputs, and parameters
- Sort inputs by required status and type

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
df505380c1
  1. 157
      src/app/api/models/[modelId]/route.ts
  2. 17
      src/lib/providers/types.ts

157
src/app/api/models/[modelId]/route.ts

@ -20,12 +20,30 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { ProviderType } from "@/types"; import { ProviderType } from "@/types";
import { ModelParameter } from "@/lib/providers/types"; import { ModelParameter, ModelInput } from "@/lib/providers/types";
// Cache for model schemas (10 minute TTL) // Cache for model schemas (10 minute TTL)
const schemaCache = new Map<string, { parameters: ModelParameter[]; timestamp: number }>(); const schemaCache = new Map<string, { parameters: ModelParameter[]; inputs: ModelInput[]; timestamp: number }>();
const CACHE_TTL = 10 * 60 * 1000; // 10 minutes const CACHE_TTL = 10 * 60 * 1000; // 10 minutes
// Image input property patterns
const IMAGE_INPUT_PATTERNS = [
"image_url",
"image",
"first_frame",
"last_frame",
"tail_image_url",
"start_image",
"end_image",
"reference_image",
"init_image",
"mask_image",
"control_image",
];
// Text input properties
const TEXT_INPUT_NAMES = ["prompt", "negative_prompt"];
// Parameters to filter out (internal/system params) // Parameters to filter out (internal/system params)
const EXCLUDED_PARAMS = new Set([ const EXCLUDED_PARAMS = new Set([
"webhook", "webhook",
@ -61,6 +79,7 @@ const PRIORITY_PARAMS = new Set([
interface SchemaSuccessResponse { interface SchemaSuccessResponse {
success: true; success: true;
parameters: ModelParameter[]; parameters: ModelParameter[];
inputs: ModelInput[];
cached: boolean; cached: boolean;
} }
@ -71,6 +90,32 @@ interface SchemaErrorResponse {
type SchemaResponse = SchemaSuccessResponse | SchemaErrorResponse; type SchemaResponse = SchemaSuccessResponse | SchemaErrorResponse;
/**
* Convert property name to human-readable label
*/
function toLabel(name: string): string {
return name
.replace(/_url$/, "")
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/**
* Check if property is an image input
*/
function isImageInput(name: string): boolean {
return IMAGE_INPUT_PATTERNS.some(
(pattern) => name === pattern || name.endsWith("_" + pattern) || name.includes("image")
);
}
/**
* Check if property is a text input
*/
function isTextInput(name: string): boolean {
return TEXT_INPUT_NAMES.includes(name);
}
/** /**
* Convert OpenAPI schema property to ModelParameter * Convert OpenAPI schema property to ModelParameter
*/ */
@ -124,13 +169,18 @@ function convertSchemaProperty(
return parameter; return parameter;
} }
interface ExtractedSchema {
parameters: ModelParameter[];
inputs: ModelInput[];
}
/** /**
* Fetch and parse schema from Replicate * Fetch and parse schema from Replicate
*/ */
async function fetchReplicateSchema( async function fetchReplicateSchema(
modelId: string, modelId: string,
apiKey: string apiKey: string
): Promise<ModelParameter[]> { ): Promise<ExtractedSchema> {
const [owner, name] = modelId.split("/"); const [owner, name] = modelId.split("/");
const response = await fetch( const response = await fetch(
@ -151,44 +201,16 @@ async function fetchReplicateSchema(
// Extract schema from latest_version.openapi_schema // Extract schema from latest_version.openapi_schema
const openApiSchema = data.latest_version?.openapi_schema; const openApiSchema = data.latest_version?.openapi_schema;
if (!openApiSchema) { if (!openApiSchema) {
return []; return { parameters: [], inputs: [] };
} }
// Navigate to Input schema // Navigate to Input schema
const inputSchema = openApiSchema.components?.schemas?.Input; const inputSchema = openApiSchema.components?.schemas?.Input;
if (!inputSchema || typeof inputSchema !== "object") { if (!inputSchema || typeof inputSchema !== "object") {
return []; return { parameters: [], inputs: [] };
} }
const properties = (inputSchema as Record<string, unknown>).properties as Record<string, Record<string, unknown>> | undefined; return extractParametersFromSchema(inputSchema as Record<string, unknown>);
const required = ((inputSchema as Record<string, unknown>).required as string[]) || [];
if (!properties) {
return [];
}
const parameters: ModelParameter[] = [];
for (const [name, prop] of Object.entries(properties)) {
// Skip prompt as it's handled separately
if (name === "prompt") continue;
const param = convertSchemaProperty(name, prop, required);
if (param) {
parameters.push(param);
}
}
// Sort: priority params first, then alphabetically
parameters.sort((a, b) => {
const aIsPriority = PRIORITY_PARAMS.has(a.name);
const bIsPriority = PRIORITY_PARAMS.has(b.name);
if (aIsPriority && !bIsPriority) return -1;
if (!aIsPriority && bIsPriority) return 1;
return a.name.localeCompare(b.name);
});
return parameters;
} }
/** /**
@ -198,7 +220,7 @@ async function fetchReplicateSchema(
async function fetchFalSchema( async function fetchFalSchema(
modelId: string, modelId: string,
apiKey: string | null apiKey: string | null
): Promise<ModelParameter[]> { ): Promise<ExtractedSchema> {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (apiKey) { if (apiKey) {
headers["Authorization"] = `Key ${apiKey}`; headers["Authorization"] = `Key ${apiKey}`;
@ -213,7 +235,7 @@ async function fetchFalSchema(
if (!response.ok) { if (!response.ok) {
// Return empty params if API fails so generation still works // Return empty params if API fails so generation still works
console.log(`[fetchFalSchema] Model Search API returned ${response.status}`); console.log(`[fetchFalSchema] Model Search API returned ${response.status}`);
return []; return { parameters: [], inputs: [] };
} }
const data = await response.json(); const data = await response.json();
@ -222,7 +244,7 @@ async function fetchFalSchema(
const modelData = data.models?.[0]; const modelData = data.models?.[0];
if (!modelData?.openapi) { if (!modelData?.openapi) {
console.log(`[fetchFalSchema] No OpenAPI schema in response for ${modelId}`); console.log(`[fetchFalSchema] No OpenAPI schema in response for ${modelId}`);
return []; return { parameters: [], inputs: [] };
} }
const spec = modelData.openapi; const spec = modelData.openapi;
@ -256,36 +278,58 @@ async function fetchFalSchema(
if (!inputSchema) { if (!inputSchema) {
console.log(`[fetchFalSchema] Could not find input schema in OpenAPI spec`); console.log(`[fetchFalSchema] Could not find input schema in OpenAPI spec`);
return []; return { parameters: [], inputs: [] };
} }
return extractParametersFromSchema(inputSchema); return extractParametersFromSchema(inputSchema);
} }
/** /**
* Extract ModelParameters from an OpenAPI schema object * Extract ModelParameters and ModelInputs from an OpenAPI schema object
*/ */
function extractParametersFromSchema(schema: Record<string, unknown>): ModelParameter[] { function extractParametersFromSchema(schema: Record<string, unknown>): ExtractedSchema {
const properties = schema.properties as Record<string, Record<string, unknown>> | undefined; const properties = schema.properties as Record<string, Record<string, unknown>> | undefined;
const required = (schema.required as string[]) || []; const required = (schema.required as string[]) || [];
if (!properties) { if (!properties) {
return []; return { parameters: [], inputs: [] };
} }
const parameters: ModelParameter[] = []; const parameters: ModelParameter[] = [];
const inputs: ModelInput[] = [];
for (const [name, prop] of Object.entries(properties)) { for (const [name, prop] of Object.entries(properties)) {
// Skip prompt and image_url as they're handled separately // Check if this is a connectable input (image or text)
if (name === "prompt" || name === "image_url") continue; if (isImageInput(name)) {
inputs.push({
name,
type: "image",
required: required.includes(name),
label: toLabel(name),
description: prop.description as string | undefined,
});
continue;
}
if (isTextInput(name)) {
inputs.push({
name,
type: "text",
required: required.includes(name),
label: toLabel(name),
description: prop.description as string | undefined,
});
continue;
}
// Otherwise it's a parameter
const param = convertSchemaProperty(name, prop, required); const param = convertSchemaProperty(name, prop, required);
if (param) { if (param) {
parameters.push(param); parameters.push(param);
} }
} }
// Sort: priority params first, then alphabetically // Sort parameters: priority params first, then alphabetically
parameters.sort((a, b) => { parameters.sort((a, b) => {
const aIsPriority = PRIORITY_PARAMS.has(a.name); const aIsPriority = PRIORITY_PARAMS.has(a.name);
const bIsPriority = PRIORITY_PARAMS.has(b.name); const bIsPriority = PRIORITY_PARAMS.has(b.name);
@ -294,7 +338,14 @@ function extractParametersFromSchema(schema: Record<string, unknown>): ModelPara
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}); });
return parameters; // Sort inputs: required first, then by type (image before text), then alphabetically
inputs.sort((a, b) => {
if (a.required !== b.required) return a.required ? -1 : 1;
if (a.type !== b.type) return a.type === "image" ? -1 : 1;
return a.name.localeCompare(b.name);
});
return { parameters, inputs };
} }
export async function GET( export async function GET(
@ -324,16 +375,17 @@ export async function GET(
const cacheKey = `${provider}:${decodedModelId}`; const cacheKey = `${provider}:${decodedModelId}`;
const cached = schemaCache.get(cacheKey); const cached = schemaCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) { if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
console.log(`[ModelSchema:${requestId}] Cache hit, returning ${cached.parameters.length} parameters`); console.log(`[ModelSchema:${requestId}] Cache hit, returning ${cached.parameters.length} parameters, ${cached.inputs.length} inputs`);
return NextResponse.json<SchemaSuccessResponse>({ return NextResponse.json<SchemaSuccessResponse>({
success: true, success: true,
parameters: cached.parameters, parameters: cached.parameters,
inputs: cached.inputs,
cached: true, cached: true,
}); });
} }
try { try {
let parameters: ModelParameter[]; let result: ExtractedSchema;
if (provider === "replicate") { if (provider === "replicate") {
const apiKey = request.headers.get("X-Replicate-Key"); const apiKey = request.headers.get("X-Replicate-Key");
@ -346,19 +398,20 @@ export async function GET(
{ status: 401 } { status: 401 }
); );
} }
parameters = await fetchReplicateSchema(decodedModelId, apiKey); result = await fetchReplicateSchema(decodedModelId, apiKey);
} else { } else {
const apiKey = request.headers.get("X-Fal-Key"); const apiKey = request.headers.get("X-Fal-Key");
parameters = await fetchFalSchema(decodedModelId, apiKey); result = await fetchFalSchema(decodedModelId, apiKey);
} }
// Cache the result // Cache the result
schemaCache.set(cacheKey, { parameters, timestamp: Date.now() }); schemaCache.set(cacheKey, { ...result, timestamp: Date.now() });
console.log(`[ModelSchema:${requestId}] Returning ${parameters.length} parameters`); console.log(`[ModelSchema:${requestId}] Returning ${result.parameters.length} parameters, ${result.inputs.length} inputs`);
return NextResponse.json<SchemaSuccessResponse>({ return NextResponse.json<SchemaSuccessResponse>({
success: true, success: true,
parameters, parameters: result.parameters,
inputs: result.inputs,
cached: false, cached: false,
}); });
} catch (error) { } catch (error) {

17
src/lib/providers/types.ts

@ -31,6 +31,23 @@ export interface ModelParameter {
required?: boolean; required?: boolean;
} }
/**
* Connectable model input - for dynamic handle generation
* These are inputs that can receive connections from other nodes
*/
export interface ModelInput {
/** Property name from schema (e.g., "image_url", "tail_image_url", "prompt") */
name: string;
/** Handle type for connections */
type: "image" | "text";
/** Whether this input is required */
required: boolean;
/** Human-readable label for the handle */
label: string;
/** Optional description from schema */
description?: string;
}
/** /**
* Represents a model from any provider with normalized metadata * Represents a model from any provider with normalized metadata
*/ */

Loading…
Cancel
Save