Browse Source

fix: unwrap array inputs for non-array API parameters

When multiple images are connected to a generation node, dynamicInputs
aggregates them into arrays. Providers wrapped single values into arrays
for array-typed params, but never unwrapped arrays for string-typed params,
causing API failures (e.g. fal's image_url receiving an array instead of
a string). Add symmetric unwrap logic across all four providers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
072246756e
  1. 149
      src/app/api/generate/__tests__/route.test.ts
  2. 3
      src/app/api/generate/providers/fal.ts
  3. 7
      src/app/api/generate/providers/kie.ts
  4. 2
      src/app/api/generate/providers/replicate.ts
  5. 3
      src/app/api/generate/providers/wavespeed.ts

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

@ -1431,6 +1431,73 @@ describe("/api/generate route", () => {
expect(requestBody.input.prompt).toBe("Test prompt");
});
it("should unwrap Replicate dynamicInputs array to single value when schema type is NOT 'array'", async () => {
// Model info fetch - with schema showing image_url has type: "string" (NOT array)
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
latest_version: {
id: "version123",
openapi_schema: {
components: {
schemas: {
Input: {
properties: {
image_url: { type: "string" }, // Single string, not array
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-string-input",
displayName: "String Input Model",
},
dynamicInputs: {
prompt: "Test prompt",
image_url: ["data:image/png;base64,image1", "data:image/png;base64,image2"], // Array sent for string param
},
},
{ "X-Replicate-API-Key": "test-replicate-key" }
);
const response = await POST(request);
expect(response.status).toBe(200);
// Verify image_url was unwrapped to first element because schema says type: "string"
const createPredictionCall = mockFetch.mock.calls[1];
const requestBody = JSON.parse(createPredictionCall[1].body);
expect(requestBody.input.image_url).toBe("data:image/png;base64,image1");
expect(Array.isArray(requestBody.input.image_url)).toBe(false);
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";
@ -2370,6 +2437,88 @@ describe("/api/generate route", () => {
expect(requestBody.prompt).toBe("Test prompt");
});
it("should unwrap array dynamicInputs to single value 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" },
},
},
},
},
},
},
},
},
},
}],
}),
});
// CDN uploads for both images in the array
// Promise.all fires both initiates concurrently before either PUT,
// so mock order is: initiate1, initiate2, PUT1, PUT2
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ upload_url: "https://fal.ai/cdn/put-target-1", file_url: "https://fal.ai/cdn/first.png" }),
});
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ upload_url: "https://fal.ai/cdn/put-target-2", file_url: "https://fal.ai/cdn/second.png" }),
});
mockFetch.mockResolvedValueOnce({ ok: true }); // PUT 1
mockFetch.mockResolvedValueOnce({ ok: true }); // PUT 2
// Queue flow: submit → poll → result → media
mockFalQueueSuccess(
{ images: [{ url: "https://fal.media/output.png" }] },
"image/png",
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,image1", "data:image/png;base64,image2"], // Array of images
},
},
{ "X-Fal-API-Key": "test-fal-key" }
);
const response = await POST(request);
expect(response.status).toBe(200);
// Find the queue submit call
const queueSubmitCall = mockFetch.mock.calls.find(
(call: [string, ...unknown[]]) => typeof call[0] === "string" && call[0].includes("queue.fal.run") && !call[0].includes("/requests/")
);
expect(queueSubmitCall).toBeDefined();
const requestBody = JSON.parse((queueSubmitCall![1] as { body: string }).body);
// image_url should be unwrapped to a single CDN URL string (first element), not an array
expect(requestBody.image_url).toBe("https://fal.ai/cdn/first.png");
expect(Array.isArray(requestBody.image_url)).toBe(false);
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({

3
src/app/api/generate/providers/fal.ts

@ -277,6 +277,9 @@ export async function generateWithFalQueue(
// Wrap in array if schema expects array but we have a single value
if (schemaArrayParams.has(key) && !Array.isArray(processedValue)) {
filteredInputs[key] = [processedValue];
} else if (!schemaArrayParams.has(key) && Array.isArray(processedValue)) {
// Unwrap array to single value if schema expects a string (e.g. image_url)
filteredInputs[key] = processedValue[0];
} else {
filteredInputs[key] = processedValue;
}

7
src/app/api/generate/providers/kie.ts

@ -436,7 +436,12 @@ export async function generateWithKie(
}
}
if (processedArray.length > 0) {
inputParams[key] = processedArray;
// Singular keys get first element, plural keys get full array
if (key === "image_url" || key === "video_url" || key === "tail_image_url") {
inputParams[key] = processedArray[0];
} else {
inputParams[key] = processedArray;
}
handledImageKeys.add(key);
}
} else {

2
src/app/api/generate/providers/replicate.ts

@ -83,6 +83,8 @@ export async function generateWithReplicate(
if (value !== null && value !== undefined && value !== '') {
if (schemaArrayParams.has(key) && !Array.isArray(value)) {
predictionInput[key] = [value]; // Wrap in array
} else if (!schemaArrayParams.has(key) && Array.isArray(value)) {
predictionInput[key] = value[0]; // Unwrap array to single value
} else {
predictionInput[key] = value;
}

3
src/app/api/generate/providers/wavespeed.ts

@ -106,6 +106,9 @@ export async function generateWithWaveSpeed(
// If the key is "images" and value is not an array, wrap it
if (key === "images" && !Array.isArray(value)) {
payload[key] = [value];
} else if (key !== "images" && Array.isArray(value)) {
// Unwrap array to single value for non-array params
payload[key] = value[0];
} else {
payload[key] = value;
}

Loading…
Cancel
Save