Browse Source

fix: wrap dynamicInputs in arrays when schema expects array type

When providers like fal.ai or Replicate expect array parameters (e.g.,
image_urls), single values from dynamicInputs are now automatically
wrapped in arrays based on the OpenAPI schema's type property.

Changes:
- Add isArray field to ModelInput type for schema detection
- Update getFalInputMapping() to return schemaArrayParams
- Update getInputMappingFromSchema() to return schemaArrayParams
- Apply array wrapping in both fal.ai and Replicate handlers
- Add tests for array wrapping behavior

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
0706bc0888
  1. 248
      src/app/api/generate/__tests__/route.test.ts
  2. 66
      src/app/api/generate/route.ts
  3. 2
      src/app/api/models/[modelId]/route.ts
  4. 2
      src/lib/providers/types.ts

248
src/app/api/generate/__tests__/route.test.ts

@ -1365,6 +1365,72 @@ describe("/api/generate route", () => {
);
});
it("should wrap Replicate dynamicInputs in array when schema type is 'array'", async () => {
// Model info fetch - with schema showing image_urls has type: "array"
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
latest_version: {
id: "version123",
openapi_schema: {
components: {
schemas: {
Input: {
properties: {
image_urls: { type: "array", items: { type: "string" } },
prompt: { type: "string" },
},
},
},
},
},
},
}),
});
// Create prediction
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
id: "prediction123",
status: "succeeded",
output: ["https://replicate.delivery/output.png"],
}),
});
// Fetch output media
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers({ "content-type": "image/png" }),
arrayBuffer: () => Promise.resolve(new ArrayBuffer(1024)),
});
const request = createMockPostRequest(
{
prompt: "",
selectedModel: {
provider: "replicate",
modelId: "some-model/with-array-input",
displayName: "Array Input Model",
},
dynamicInputs: {
prompt: "Test prompt",
image_urls: "data:image/png;base64,singleImage", // Single string sent
},
},
{ "X-Replicate-API-Key": "test-replicate-key" }
);
const response = await POST(request);
expect(response.status).toBe(200);
// Verify image_urls was wrapped in array because schema says type: "array"
const createPredictionCall = mockFetch.mock.calls[1];
const requestBody = JSON.parse(createPredictionCall[1].body);
expect(requestBody.input.image_urls).toEqual(["data:image/png;base64,singleImage"]);
expect(requestBody.input.prompt).toBe("Test prompt");
});
it("should use env var API key when header not provided", async () => {
process.env.REPLICATE_API_KEY = "env-replicate-key";
@ -1897,6 +1963,12 @@ describe("/api/generate route", () => {
});
it("should filter empty dynamicInputs values", async () => {
// Schema fetch (for array type detection with dynamicInputs)
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ models: [] }),
});
// fal.run API call
mockFetch.mockResolvedValueOnce({
ok: true,
@ -1936,8 +2008,8 @@ describe("/api/generate route", () => {
expect(response.status).toBe(200);
expect(data.success).toBe(true);
// Verify request body only contains non-empty values
const falCall = mockFetch.mock.calls[0];
// Verify request body only contains non-empty values (2nd call after schema fetch)
const falCall = mockFetch.mock.calls[1];
const requestBody = JSON.parse(falCall[1].body);
expect(requestBody).toEqual({
prompt: "Valid prompt",
@ -1948,6 +2020,12 @@ describe("/api/generate route", () => {
});
it("should pass dynamicInputs to fal.ai request", async () => {
// Schema fetch (for array type detection with dynamicInputs)
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ models: [] }),
});
// fal.run API call
mockFetch.mockResolvedValueOnce({
ok: true,
@ -1986,8 +2064,8 @@ describe("/api/generate route", () => {
expect(response.status).toBe(200);
expect(data.success).toBe(true);
// Verify dynamicInputs were passed to fal.ai
const falCall = mockFetch.mock.calls[0];
// Verify dynamicInputs were passed to fal.ai (2nd call after schema fetch)
const falCall = mockFetch.mock.calls[1];
const requestBody = JSON.parse(falCall[1].body);
expect(requestBody).toEqual(
expect.objectContaining({
@ -2049,6 +2127,12 @@ describe("/api/generate route", () => {
});
it("should pass parameters to fal.ai request body", async () => {
// Schema fetch (for array type detection with dynamicInputs)
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ models: [] }),
});
// fal.run API call
mockFetch.mockResolvedValueOnce({
ok: true,
@ -2090,8 +2174,8 @@ describe("/api/generate route", () => {
expect(response.status).toBe(200);
expect(data.success).toBe(true);
// Verify parameters were passed to fal.ai
const falCall = mockFetch.mock.calls[0];
// Verify parameters were passed to fal.ai (2nd call after schema fetch)
const falCall = mockFetch.mock.calls[1];
const requestBody = JSON.parse(falCall[1].body);
expect(requestBody).toEqual(
expect.objectContaining({
@ -2103,6 +2187,12 @@ describe("/api/generate route", () => {
});
it("should merge parameters with dynamicInputs (dynamicInputs take precedence)", async () => {
// Schema fetch (for array type detection with dynamicInputs)
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ models: [] }),
});
// fal.run API call
mockFetch.mockResolvedValueOnce({
ok: true,
@ -2144,8 +2234,8 @@ describe("/api/generate route", () => {
expect(response.status).toBe(200);
expect(data.success).toBe(true);
// Verify dynamicInputs override parameters
const falCall = mockFetch.mock.calls[0];
// Verify dynamicInputs override parameters (2nd call after schema fetch)
const falCall = mockFetch.mock.calls[1];
const requestBody = JSON.parse(falCall[1].body);
expect(requestBody).toEqual(
expect.objectContaining({
@ -2156,6 +2246,148 @@ describe("/api/generate route", () => {
);
});
it("should wrap dynamicInputs in array when schema type is 'array'", async () => {
// Schema fetch - return schema showing image_urls has type: "array"
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
models: [{
openapi: {
paths: {
"/": {
post: {
requestBody: {
content: {
"application/json": {
schema: {
properties: {
image_urls: { type: "array", items: { type: "string" } },
prompt: { type: "string" },
},
},
},
},
},
},
},
},
},
}],
}),
});
// fal.run API call
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
images: [{ url: "https://fal.media/output.png" }],
}),
});
// Fetch output media
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers({ "content-type": "image/png" }),
arrayBuffer: () => Promise.resolve(new ArrayBuffer(1024)),
});
const request = createMockPostRequest(
{
prompt: "Test prompt",
selectedModel: {
provider: "fal",
modelId: "fal-ai/flux-2/turbo/edit",
displayName: "Flux 2 Turbo Edit",
},
dynamicInputs: {
prompt: "Edit this image",
image_urls: "data:image/png;base64,singleImage", // Single string sent
},
},
{ "X-Fal-API-Key": "test-fal-key" }
);
const response = await POST(request);
expect(response.status).toBe(200);
// Verify image_urls was wrapped in array because schema says type: "array"
const falCall = mockFetch.mock.calls[1];
const requestBody = JSON.parse(falCall[1].body);
expect(requestBody.image_urls).toEqual(["data:image/png;base64,singleImage"]);
expect(requestBody.prompt).toBe("Edit this image");
});
it("should NOT wrap dynamicInputs when schema type is NOT 'array'", async () => {
// Schema fetch - return schema showing image_url has type: "string" (NOT array)
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
models: [{
openapi: {
paths: {
"/": {
post: {
requestBody: {
content: {
"application/json": {
schema: {
properties: {
image_url: { type: "string" }, // Single string, not array
prompt: { type: "string" },
},
},
},
},
},
},
},
},
},
}],
}),
});
// fal.run API call
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
images: [{ url: "https://fal.media/output.png" }],
}),
});
// Fetch output media
mockFetch.mockResolvedValueOnce({
ok: true,
headers: new Headers({ "content-type": "image/png" }),
arrayBuffer: () => Promise.resolve(new ArrayBuffer(1024)),
});
const request = createMockPostRequest(
{
prompt: "Test prompt",
selectedModel: {
provider: "fal",
modelId: "fal-ai/flux/schnell",
displayName: "Flux Schnell",
},
dynamicInputs: {
prompt: "Test prompt",
image_url: "data:image/png;base64,singleImage", // Single string
},
},
{ "X-Fal-API-Key": "test-fal-key" }
);
const response = await POST(request);
expect(response.status).toBe(200);
// Verify image_url remains a string (not wrapped in array)
const falCall = mockFetch.mock.calls[1];
const requestBody = JSON.parse(falCall[1].body);
expect(requestBody.image_url).toBe("data:image/png;base64,singleImage");
expect(requestBody.prompt).toBe("Test prompt");
});
it("should handle error response with error.message format", async () => {
// Schema fetch (for input mapping when no dynamicInputs)
mockFetch.mockResolvedValueOnce({

66
src/app/api/generate/route.ts

@ -230,8 +230,10 @@ const INPUT_PATTERNS: Record<string, string[]> = {
interface InputMapping {
// Maps our generic names to model-specific parameter names
paramMap: Record<string, string>;
// Track which params expect array types
// 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>;
}
/**
@ -241,8 +243,9 @@ interface InputMapping {
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 };
if (!schema) return { paramMap, arrayParams, schemaArrayParams };
try {
// Navigate to input schema properties
@ -251,7 +254,15 @@ function getInputMappingFromSchema(schema: 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 };
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);
@ -289,7 +300,7 @@ function getInputMappingFromSchema(schema: Record<string, unknown> | undefined):
// Schema parsing failed
}
return { paramMap, arrayParams };
return { paramMap, arrayParams, schemaArrayParams };
}
/**
@ -345,7 +356,20 @@ async function generateWithReplicate(
// Add dynamic inputs if provided (these come from schema-mapped connections)
if (hasDynamicInputs) {
Object.assign(predictionInput, input.dynamicInputs);
// Get schema to detect array parameters
const schema = modelData.latest_version?.openapi_schema as Record<string, unknown> | undefined;
const { schemaArrayParams } = getInputMappingFromSchema(schema);
// Apply array wrapping based on schema type
for (const [key, value] of Object.entries(input.dynamicInputs!)) {
if (value !== null && value !== undefined && value !== '') {
if (schemaArrayParams.has(key) && !Array.isArray(value)) {
predictionInput[key] = [value]; // Wrap in array
} else {
predictionInput[key] = value;
}
}
}
} else {
// Fallback: use schema to map generic input names to model-specific parameter names
const schema = modelData.latest_version?.openapi_schema as Record<string, unknown> | undefined;
@ -554,6 +578,7 @@ async function generateWithReplicate(
async function getFalInputMapping(modelId: string, apiKey: string | null): Promise<InputMapping> {
const paramMap: Record<string, string> = {};
const arrayParams = new Set<string>();
const schemaArrayParams = new Set<string>();
try {
// Use fal.ai Model Search API with OpenAPI expansion
@ -566,13 +591,13 @@ async function getFalInputMapping(modelId: string, apiKey: string | null): Promi
const response = await fetch(url, { headers });
if (!response.ok) {
return { paramMap, arrayParams };
return { paramMap, arrayParams, schemaArrayParams };
}
const data = await response.json();
const modelData = data.models?.[0];
if (!modelData?.openapi) {
return { paramMap, arrayParams };
return { paramMap, arrayParams, schemaArrayParams };
}
// Extract input schema from OpenAPI spec (same logic as /api/models/[modelId])
@ -599,13 +624,22 @@ async function getFalInputMapping(modelId: string, apiKey: string | null): Promi
}
if (!inputSchema) {
return { paramMap, arrayParams };
return { paramMap, arrayParams, schemaArrayParams };
}
const properties = inputSchema.properties as Record<string, unknown> | undefined;
if (!properties) return { paramMap, arrayParams };
if (!properties) return { paramMap, arrayParams, schemaArrayParams };
// First pass: detect all array-typed properties by their actual schema name
// This is used for dynamicInputs which use schema names directly
for (const [propName, prop] of Object.entries(properties)) {
const property = prop as Record<string, unknown>;
if (property?.type === "array") {
schemaArrayParams.add(propName);
}
}
// Match properties to INPUT_PATTERNS and detect array types
// Second pass: match properties to INPUT_PATTERNS and detect array types
const propertyNames = Object.keys(properties);
for (const [genericName, patterns] of Object.entries(INPUT_PATTERNS)) {
for (const pattern of patterns) {
@ -640,7 +674,7 @@ async function getFalInputMapping(modelId: string, apiKey: string | null): Promi
// Schema parsing failed - continue with empty mapping
}
return { paramMap, arrayParams };
return { paramMap, arrayParams, schemaArrayParams };
}
/**
@ -667,10 +701,18 @@ async function generateWithFal(
// Add dynamic inputs if provided (these come from schema-mapped connections)
// Filter out empty/null/undefined values to avoid sending invalid inputs to fal.ai
if (hasDynamicInputs) {
// Fetch schema to know which params expect arrays
const { schemaArrayParams } = await getFalInputMapping(modelId, apiKey);
const filteredInputs: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input.dynamicInputs!)) {
if (value !== null && value !== undefined && value !== '') {
filteredInputs[key] = value;
// Wrap in array if schema expects array but we have a single value
if (schemaArrayParams.has(key) && !Array.isArray(value)) {
filteredInputs[key] = [value];
} else {
filteredInputs[key] = value;
}
}
}
Object.assign(requestBody, filteredInputs);

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

@ -383,6 +383,7 @@ function extractParametersFromSchema(
required: required.includes(name),
label: toLabel(name),
description: prop.description as string | undefined,
isArray: prop.type === "array",
});
continue;
}
@ -394,6 +395,7 @@ function extractParametersFromSchema(
required: required.includes(name),
label: toLabel(name),
description: prop.description as string | undefined,
isArray: prop.type === "array",
});
continue;
}

2
src/lib/providers/types.ts

@ -46,6 +46,8 @@ export interface ModelInput {
label: string;
/** Optional description from schema */
description?: string;
/** Whether schema expects array format (e.g., image_urls: string[] vs image_url: string) */
isArray?: boolean;
}
/**

Loading…
Cancel
Save