diff --git a/src/app/api/generate/__tests__/route.test.ts b/src/app/api/generate/__tests__/route.test.ts index 01537ec7..dece6408 100644 --- a/src/app/api/generate/__tests__/route.test.ts +++ b/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({ diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index b7a8d103..f38ac5e7 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -230,8 +230,10 @@ const INPUT_PATTERNS: Record = { interface InputMapping { // Maps our generic names to model-specific parameter names paramMap: Record; - // Track which params expect array types + // Track which generic params expect array types (e.g., "image") arrayParams: Set; + // Track actual schema param names that expect array types (e.g., "image_urls") + schemaArrayParams: Set; } /** @@ -241,8 +243,9 @@ interface InputMapping { function getInputMappingFromSchema(schema: Record | undefined): InputMapping { const paramMap: Record = {}; const arrayParams = new Set(); + const schemaArrayParams = new Set(); - 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 | undefined): const input = schemas?.Input as Record | undefined; const properties = input?.properties as Record | 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; + if (property?.type === "array") { + schemaArrayParams.add(propName); + } + } const propertyNames = Object.keys(properties); @@ -289,7 +300,7 @@ function getInputMappingFromSchema(schema: Record | 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 | 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 | undefined; @@ -554,6 +578,7 @@ async function generateWithReplicate( async function getFalInputMapping(modelId: string, apiKey: string | null): Promise { const paramMap: Record = {}; const arrayParams = new Set(); + const schemaArrayParams = new Set(); 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 | 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; + 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 = {}; 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); diff --git a/src/app/api/models/[modelId]/route.ts b/src/app/api/models/[modelId]/route.ts index 2036ed15..d3ece35c 100644 --- a/src/app/api/models/[modelId]/route.ts +++ b/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; } diff --git a/src/lib/providers/types.ts b/src/lib/providers/types.ts index 6d6baae9..ff0f67a6 100644 --- a/src/lib/providers/types.ts +++ b/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; } /**