From 072246756e995c99948e40bea0116a2ed6fba9b1 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Sun, 15 Feb 2026 00:00:56 +1300 Subject: [PATCH] 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 --- src/app/api/generate/__tests__/route.test.ts | 149 +++++++++++++++++++ src/app/api/generate/providers/fal.ts | 3 + src/app/api/generate/providers/kie.ts | 7 +- src/app/api/generate/providers/replicate.ts | 2 + src/app/api/generate/providers/wavespeed.ts | 3 + 5 files changed, 163 insertions(+), 1 deletion(-) diff --git a/src/app/api/generate/__tests__/route.test.ts b/src/app/api/generate/__tests__/route.test.ts index 974d2fd6..318388b2 100644 --- a/src/app/api/generate/__tests__/route.test.ts +++ b/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({ diff --git a/src/app/api/generate/providers/fal.ts b/src/app/api/generate/providers/fal.ts index 9d4f19b7..5fcbe0c1 100644 --- a/src/app/api/generate/providers/fal.ts +++ b/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; } diff --git a/src/app/api/generate/providers/kie.ts b/src/app/api/generate/providers/kie.ts index 1c8d790a..9ed5be40 100644 --- a/src/app/api/generate/providers/kie.ts +++ b/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 { diff --git a/src/app/api/generate/providers/replicate.ts b/src/app/api/generate/providers/replicate.ts index c35edcc3..482d4d78 100644 --- a/src/app/api/generate/providers/replicate.ts +++ b/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; } diff --git a/src/app/api/generate/providers/wavespeed.ts b/src/app/api/generate/providers/wavespeed.ts index fe443b1d..82febf22 100644 --- a/src/app/api/generate/providers/wavespeed.ts +++ b/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; }