From a9cbb11d566d5bad23018b910f0e2e65bd623fab Mon Sep 17 00:00:00 2001 From: lapoaiscalers Date: Thu, 29 Jan 2026 17:00:31 +0100 Subject: [PATCH] feat: add Kie.ai provider integration Add Kie.ai as a new provider for image and video generation: Image models (8): - z-image, seedream/4.5-text-to-image, seedream/4.5-edit - nano-banana-pro, gpt-image/1.5-text-to-image, gpt-image/1.5-image-to-image - google/nano-banana, google/nano-banana-edit Video models (11): - sora-2-text-to-video, sora-2-image-to-video - sora-2-pro-text-to-video, sora-2-pro-image-to-video - veo3_fast, veo3, bytedance/seedance-1.5-pro - kling-2.6/text-to-video, kling-2.6/image-to-video - wan/2-6-text-to-video, wan/2-6-image-to-video Key changes: - Add "kie" to ProviderType union - Add generateWithKie() with image upload and task polling - Add hardcoded model list and schemas (no discovery API) - Update workflow store for API key handling - Update UI components with Kie provider badge Co-Authored-By: Claude --- CLAUDE.md | 2 + src/app/api/generate/route.ts | 458 +++++++++++++++++++++ src/app/api/models/[modelId]/route.ts | 182 +++++++- src/app/api/models/route.ts | 207 +++++++++- src/components/CostDialog.tsx | 3 + src/components/ProjectSetupModal.tsx | 5 +- src/components/nodes/GenerateImageNode.tsx | 13 +- src/components/nodes/GenerateVideoNode.tsx | 13 +- src/store/utils/localStorage.ts | 1 + src/store/workflowStore.ts | 20 + src/types/providers.ts | 2 +- 11 files changed, 896 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 85be2de0..b3e028e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ Create `.env.local` in the root directory: ``` GEMINI_API_KEY=your_gemini_api_key OPENAI_API_KEY=your_openai_api_key # Optional, for OpenAI LLM provider +KIE_API_KEY=your_kie_api_key # Optional, for Kie.ai models (Sora, Veo, Kling, etc.) ``` ## Architecture Overview @@ -177,6 +178,7 @@ The app is deployed on Vercel from the `etailup/node-banana` fork. Pushes to `ma Environment variables required in Vercel dashboard: - `GEMINI_API_KEY` - `OPENAI_API_KEY` (optional) +- `KIE_API_KEY` (optional, for Kie.ai models) ### Fork + Upstream Sync diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index dfdbf9e5..3bdd5e0d 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -1250,6 +1250,373 @@ async function generateWithFalQueue( } } +// ============ Kie.ai Helpers ============ + +/** + * Get the correct image input parameter name for a Kie model + */ +function getKieImageInputKey(modelId: string): string { + // Model-specific parameter names + if (modelId === "nano-banana-pro") return "image_input"; + if (modelId === "seedream/4.5-edit") return "image_urls"; + if (modelId === "gpt-image/1.5-image-to-image") return "input_urls"; + if (modelId === "veo3" || modelId === "veo3_fast") return "imageUrls"; + // Default for most models + return "image_urls"; +} + +/** + * Check if a model is a Veo model (uses different API endpoint) + */ +function isKieVeoModel(modelId: string): boolean { + return modelId === "veo3" || modelId === "veo3_fast"; +} + +/** + * Upload a base64 image to Kie.ai and get a URL + * Required for image-to-image models since Kie doesn't accept base64 directly + */ +async function uploadImageToKie( + requestId: string, + apiKey: string, + base64Image: string +): Promise { + // Extract mime type and data from data URL + let mimeType = "image/png"; + let imageData = base64Image; + + if (base64Image.startsWith("data:")) { + const matches = base64Image.match(/^data:([^;]+);base64,(.+)$/); + if (matches) { + mimeType = matches[1]; + imageData = matches[2]; + } + } + + // Convert base64 to binary + const binaryData = Buffer.from(imageData, "base64"); + + // Determine file extension + const ext = mimeType.includes("png") ? "png" : mimeType.includes("webp") ? "webp" : "jpg"; + const filename = `upload_${Date.now()}.${ext}`; + + console.log(`[API:${requestId}] Uploading image to Kie.ai: ${filename} (${(binaryData.length / 1024).toFixed(1)}KB)`); + + const response = await fetch("https://kieai.redpandaai.co/api/file-stream-upload", { + method: "POST", + headers: { + "Authorization": `Bearer ${apiKey}`, + "Content-Type": mimeType, + "X-Filename": filename, + }, + body: binaryData, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to upload image: ${response.status} - ${errorText}`); + } + + const result = await response.json(); + const downloadUrl = result.downloadUrl || result.url; + + if (!downloadUrl) { + throw new Error("No download URL in upload response"); + } + + console.log(`[API:${requestId}] Image uploaded: ${downloadUrl.substring(0, 80)}...`); + return downloadUrl; +} + +/** + * Poll Kie.ai task status until completion + */ +async function pollKieTaskCompletion( + requestId: string, + apiKey: string, + taskId: string, + isVeo: boolean +): Promise<{ success: boolean; data?: Record; error?: string }> { + const maxWaitTime = 10 * 60 * 1000; // 10 minutes for video + const pollInterval = 2000; // 2 seconds + const startTime = Date.now(); + let lastStatus = ""; + + // Different endpoints for Veo vs standard models + const pollUrl = isVeo + ? `https://api.kie.ai/api/v1/veo/record-info?taskId=${taskId}` + : `https://api.kie.ai/api/v1/jobs/recordInfo?taskId=${taskId}`; + + while (true) { + if (Date.now() - startTime > maxWaitTime) { + return { success: false, error: "Generation timed out after 10 minutes" }; + } + + await new Promise(resolve => setTimeout(resolve, pollInterval)); + + const response = await fetch(pollUrl, { + headers: { + "Authorization": `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + return { success: false, error: `Failed to poll status: ${response.status}` }; + } + + const result = await response.json(); + const status = result.status || result.data?.status; + + if (status !== lastStatus) { + console.log(`[API:${requestId}] Kie task status: ${status}`); + lastStatus = status; + } + + if (status === "success" || status === "completed") { + return { success: true, data: result.data || result }; + } + + if (status === "fail" || status === "failed" || status === "error") { + const errorMessage = result.error || result.message || "Generation failed"; + return { success: false, error: errorMessage }; + } + + // Continue polling for: waiting, queuing, generating, processing, etc. + } +} + +/** + * Generate image/video using Kie.ai API + */ +async function generateWithKie( + requestId: string, + apiKey: string, + input: GenerationInput +): Promise { + const modelId = input.model.id; + const isVeo = isKieVeoModel(modelId); + + console.log(`[API:${requestId}] Kie.ai generation - Model: ${modelId}, Images: ${input.images?.length || 0}, Prompt: ${input.prompt.length} chars, Veo: ${isVeo}`); + + // Build request body + const requestBody: Record = { + model: modelId, + }; + + // Add prompt + if (input.prompt) { + requestBody.prompt = input.prompt; + } + + // Add model parameters + if (input.parameters) { + Object.assign(requestBody, input.parameters); + } + + // Handle image inputs + if (input.images && input.images.length > 0) { + // Upload images to get URLs (Kie requires URLs, not base64) + const imageUrls: string[] = []; + for (const image of input.images) { + if (image.startsWith("http")) { + imageUrls.push(image); + } else { + // Upload base64 image + const url = await uploadImageToKie(requestId, apiKey, image); + imageUrls.push(url); + } + } + + // Set the correct parameter name for this model + const imageKey = getKieImageInputKey(modelId); + if (isVeo) { + // Veo models use camelCase and array format + requestBody[imageKey] = imageUrls; + } else if (modelId === "nano-banana-pro") { + // nano-banana-pro uses single image + requestBody[imageKey] = imageUrls[0]; + } else { + // Most models use array format + requestBody[imageKey] = imageUrls; + } + } + + // Handle dynamic inputs (from schema-mapped connections) + if (input.dynamicInputs) { + for (const [key, value] of Object.entries(input.dynamicInputs)) { + if (value !== null && value !== undefined && value !== '') { + // Check if this is an image input that needs uploading + if (typeof value === 'string' && value.startsWith('data:image')) { + const url = await uploadImageToKie(requestId, apiKey, value); + requestBody[key] = url; + } else { + requestBody[key] = value; + } + } + } + } + + // Select endpoint based on model type + const createUrl = isVeo + ? "https://api.kie.ai/api/v1/veo/generate" + : "https://api.kie.ai/api/v1/jobs/createTask"; + + console.log(`[API:${requestId}] Calling Kie.ai API: ${createUrl}`); + console.log(`[API:${requestId}] Request body keys: ${Object.keys(requestBody).join(", ")}`); + + // Create task + const createResponse = await fetch(createUrl, { + method: "POST", + headers: { + "Authorization": `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + + if (!createResponse.ok) { + const errorText = await createResponse.text(); + let errorDetail = errorText; + try { + const errorJson = JSON.parse(errorText); + errorDetail = errorJson.message || errorJson.error || errorJson.detail || errorText; + } catch { + // Keep original text + } + + if (createResponse.status === 429) { + return { + success: false, + error: `${input.model.name}: Rate limit exceeded. Try again in a moment.`, + }; + } + + return { + success: false, + error: `${input.model.name}: ${errorDetail}`, + }; + } + + const createResult = await createResponse.json(); + const taskId = createResult.taskId || createResult.data?.taskId || createResult.id; + + if (!taskId) { + console.error(`[API:${requestId}] No taskId in Kie response:`, createResult); + return { + success: false, + error: "No task ID in response", + }; + } + + console.log(`[API:${requestId}] Kie task created: ${taskId}`); + + // Poll for completion + const pollResult = await pollKieTaskCompletion(requestId, apiKey, taskId, isVeo); + + if (!pollResult.success) { + return { + success: false, + error: `${input.model.name}: ${pollResult.error}`, + }; + } + + // Extract output URL from result + const data = pollResult.data; + let mediaUrl: string | null = null; + let isVideo = false; + + // Try various response formats + if (data) { + // Video outputs + if (data.videoUrl) { + mediaUrl = data.videoUrl as string; + isVideo = true; + } else if (data.video_url) { + mediaUrl = data.video_url as string; + isVideo = true; + } else if (data.output && typeof data.output === 'string' && (data.output as string).includes('.mp4')) { + mediaUrl = data.output as string; + isVideo = true; + } + // Image outputs + else if (data.imageUrl) { + mediaUrl = data.imageUrl as string; + } else if (data.image_url) { + mediaUrl = data.image_url as string; + } else if (data.output && typeof data.output === 'string') { + mediaUrl = data.output as string; + } else if (data.url) { + mediaUrl = data.url as string; + } else if (Array.isArray(data.images) && data.images.length > 0) { + mediaUrl = (data.images[0] as { url?: string })?.url || data.images[0] as string; + } + } + + if (!mediaUrl) { + console.error(`[API:${requestId}] No media URL found in Kie response:`, data); + return { + success: false, + error: "No output URL in response", + }; + } + + // Detect video from URL if not already detected + if (!isVideo && (mediaUrl.includes('.mp4') || mediaUrl.includes('.webm') || mediaUrl.includes('video'))) { + isVideo = true; + } + + // Fetch the media and convert to base64 + console.log(`[API:${requestId}] Fetching output from: ${mediaUrl.substring(0, 80)}...`); + const mediaResponse = await fetch(mediaUrl); + + if (!mediaResponse.ok) { + return { + success: false, + error: `Failed to fetch output: ${mediaResponse.status}`, + }; + } + + const contentType = mediaResponse.headers.get("content-type") || (isVideo ? "video/mp4" : "image/png"); + if (contentType.startsWith("video/")) { + isVideo = true; + } + + const mediaArrayBuffer = await mediaResponse.arrayBuffer(); + const mediaSizeBytes = mediaArrayBuffer.byteLength; + const mediaSizeMB = mediaSizeBytes / (1024 * 1024); + + console.log(`[API:${requestId}] Output: ${contentType}, ${mediaSizeMB.toFixed(2)}MB`); + + // For very large videos (>20MB), return URL directly + if (isVideo && mediaSizeMB > 20) { + console.log(`[API:${requestId}] SUCCESS - Returning URL for large video`); + return { + success: true, + outputs: [ + { + type: "video", + data: mediaUrl, + url: mediaUrl, + }, + ], + }; + } + + const mediaBase64 = Buffer.from(mediaArrayBuffer).toString("base64"); + console.log(`[API:${requestId}] SUCCESS - Returning ${isVideo ? "video" : "image"}`); + + return { + success: true, + outputs: [ + { + type: isVideo ? "video" : "image", + data: `data:${contentType};base64,${mediaBase64}`, + url: mediaUrl, + }, + ], + }; +} + export async function POST(request: NextRequest) { const requestId = Math.random().toString(36).substring(7); console.log(`\n[API:${requestId}] ========== NEW GENERATE REQUEST ==========`); @@ -1470,6 +1837,97 @@ export async function POST(request: NextRequest) { }); } + if (provider === "kie") { + // User-provided key takes precedence over env variable + const kieApiKey = request.headers.get("X-Kie-API-Key") || process.env.KIE_API_KEY; + if (!kieApiKey) { + return NextResponse.json( + { + success: false, + error: "Kie.ai API key not configured. Add KIE_API_KEY to .env.local or configure in Settings.", + }, + { status: 401 } + ); + } + + // Process images - Kie requires URLs, we'll upload base64 images in generateWithKie + const processedImages: string[] = images ? [...images] : []; + + // Process dynamicInputs: filter empty values + let processedDynamicInputs: Record | undefined = undefined; + + if (dynamicInputs) { + processedDynamicInputs = {}; + for (const key of Object.keys(dynamicInputs)) { + const value = dynamicInputs[key]; + + // Skip empty/null/undefined values + if (value === null || value === undefined || value === '') { + continue; + } + + processedDynamicInputs[key] = value; + } + } + + // Build generation input + const genInput: GenerationInput = { + model: { + id: selectedModel!.modelId, + name: selectedModel!.displayName, + provider: "kie", + capabilities: ["text-to-image"], + description: null, + }, + prompt: prompt || "", + images: processedImages, + parameters, + dynamicInputs: processedDynamicInputs, + }; + + const result = await generateWithKie(requestId, kieApiKey, genInput); + + if (!result.success) { + return NextResponse.json( + { + success: false, + error: result.error || "Generation failed", + }, + { status: 500 } + ); + } + + // Return first output (image or video) + const output = result.outputs?.[0]; + if (!output?.data) { + return NextResponse.json( + { + success: false, + error: "No output in generation result", + }, + { status: 500 } + ); + } + + // Return appropriate fields based on output type + if (output.type === "video") { + // Check if data is a URL (for large videos) or base64 + const isUrl = output.data.startsWith("http"); + return NextResponse.json({ + success: true, + video: isUrl ? undefined : output.data, + videoUrl: isUrl ? output.data : undefined, + contentType: "video", + }); + } + + return NextResponse.json({ + success: true, + image: output.data, + contentType: "image", + }); + } + // Default: Use Gemini // User-provided key (from settings) takes precedence over env variable const geminiApiKey = request.headers.get("X-Gemini-API-Key") || process.env.GEMINI_API_KEY; diff --git a/src/app/api/models/[modelId]/route.ts b/src/app/api/models/[modelId]/route.ts index ca08c6ce..df78fdd4 100644 --- a/src/app/api/models/[modelId]/route.ts +++ b/src/app/api/models/[modelId]/route.ts @@ -479,6 +479,181 @@ function extractParametersFromSchema( return { parameters, inputs }; } +/** + * Get hardcoded schema for Kie.ai models + * Kie.ai doesn't have a schema discovery API, so we define these manually + */ +function getKieSchema(modelId: string): ExtractedSchema { + // Common parameters for image models + const imageParams: ModelParameter[] = [ + { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "9:21"], default: "1:1" }, + { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, + ]; + + // Common parameters for video models + const videoParams: ModelParameter[] = [ + { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["16:9", "9:16", "1:1"], default: "16:9" }, + { name: "duration", type: "integer", description: "Video duration in seconds", minimum: 5, maximum: 20, default: 10 }, + { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, + ]; + + // Model-specific schemas + const schemas: Record = { + // Image models + "z-image": { + parameters: imageParams, + inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }], + }, + "seedream/4.5-text-to-image": { + parameters: imageParams, + inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }], + }, + "seedream/4.5-edit": { + parameters: imageParams, + inputs: [ + { name: "prompt", type: "text", required: true, label: "Prompt" }, + { name: "image_urls", type: "image", required: true, label: "Image", isArray: true }, + ], + }, + "nano-banana-pro": { + parameters: [ + ...imageParams, + { name: "resolution", type: "string", description: "Output resolution", enum: ["1K", "2K", "4K"], default: "1K" }, + ], + inputs: [ + { name: "prompt", type: "text", required: true, label: "Prompt" }, + { name: "image_input", type: "image", required: false, label: "Image" }, + ], + }, + "gpt-image/1.5-text-to-image": { + parameters: [ + { name: "size", type: "string", description: "Output size", enum: ["1024x1024", "1536x1024", "1024x1536", "auto"], default: "1024x1024" }, + { name: "quality", type: "string", description: "Output quality", enum: ["low", "medium", "high"], default: "medium" }, + { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, + ], + inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }], + }, + "gpt-image/1.5-image-to-image": { + parameters: [ + { name: "size", type: "string", description: "Output size", enum: ["1024x1024", "1536x1024", "1024x1536", "auto"], default: "auto" }, + { name: "quality", type: "string", description: "Output quality", enum: ["low", "medium", "high"], default: "medium" }, + { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, + ], + inputs: [ + { name: "prompt", type: "text", required: true, label: "Prompt" }, + { name: "input_urls", type: "image", required: true, label: "Image", isArray: true }, + ], + }, + "google/nano-banana": { + parameters: imageParams, + inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }], + }, + "google/nano-banana-edit": { + parameters: imageParams, + inputs: [ + { name: "prompt", type: "text", required: true, label: "Prompt" }, + { name: "image_urls", type: "image", required: true, label: "Image", isArray: true }, + ], + }, + // Video models + "sora-2-text-to-video": { + parameters: videoParams, + inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }], + }, + "sora-2-image-to-video": { + parameters: videoParams, + inputs: [ + { name: "prompt", type: "text", required: false, label: "Prompt" }, + { name: "image_urls", type: "image", required: true, label: "Image", isArray: true }, + ], + }, + "sora-2-pro-text-to-video": { + parameters: videoParams, + inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }], + }, + "sora-2-pro-image-to-video": { + parameters: videoParams, + inputs: [ + { name: "prompt", type: "text", required: false, label: "Prompt" }, + { name: "image_urls", type: "image", required: true, label: "Image", isArray: true }, + ], + }, + "veo3_fast": { + parameters: [ + { name: "aspectRatio", type: "string", description: "Output aspect ratio (camelCase for Veo)", enum: ["16:9", "9:16"], default: "16:9" }, + { name: "duration", type: "integer", description: "Video duration in seconds", minimum: 5, maximum: 8, default: 8 }, + { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, + ], + inputs: [ + { name: "prompt", type: "text", required: true, label: "Prompt" }, + { name: "imageUrls", type: "image", required: false, label: "Image", isArray: true }, + ], + }, + "veo3": { + parameters: [ + { name: "aspectRatio", type: "string", description: "Output aspect ratio (camelCase for Veo)", enum: ["16:9", "9:16"], default: "16:9" }, + { name: "duration", type: "integer", description: "Video duration in seconds", minimum: 5, maximum: 8, default: 8 }, + { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, + ], + inputs: [ + { name: "prompt", type: "text", required: true, label: "Prompt" }, + { name: "imageUrls", type: "image", required: false, label: "Image", isArray: true }, + ], + }, + "bytedance/seedance-1.5-pro": { + parameters: [ + { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["16:9", "9:16", "1:1"], default: "16:9" }, + { name: "duration", type: "number", description: "Video duration in seconds", minimum: 5, maximum: 10, default: 5 }, + { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, + ], + inputs: [ + { name: "prompt", type: "text", required: true, label: "Prompt" }, + { name: "image_urls", type: "image", required: false, label: "Image", isArray: true }, + ], + }, + "kling-2.6/text-to-video": { + parameters: [ + { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["16:9", "9:16", "1:1"], default: "16:9" }, + { name: "duration", type: "string", description: "Video duration", enum: ["5", "10"], default: "5" }, + { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, + ], + inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }], + }, + "kling-2.6/image-to-video": { + parameters: [ + { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["16:9", "9:16", "1:1"], default: "16:9" }, + { name: "duration", type: "string", description: "Video duration", enum: ["5", "10"], default: "5" }, + { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, + ], + inputs: [ + { name: "prompt", type: "text", required: false, label: "Prompt" }, + { name: "image_urls", type: "image", required: true, label: "Image", isArray: true }, + ], + }, + "wan/2-6-text-to-video": { + parameters: [ + { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["16:9", "9:16", "1:1"], default: "16:9" }, + { name: "duration", type: "integer", description: "Video duration in seconds", minimum: 5, maximum: 10, default: 5 }, + { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, + ], + inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }], + }, + "wan/2-6-image-to-video": { + parameters: [ + { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["16:9", "9:16", "1:1"], default: "16:9" }, + { name: "duration", type: "integer", description: "Video duration in seconds", minimum: 5, maximum: 10, default: 5 }, + { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, + ], + inputs: [ + { name: "prompt", type: "text", required: false, label: "Prompt" }, + { name: "image_urls", type: "image", required: true, label: "Image", isArray: true }, + ], + }, + }; + + return schemas[modelId] || { parameters: [], inputs: [] }; +} + export async function GET( request: NextRequest, { params }: { params: Promise<{ modelId: string }> } @@ -488,11 +663,11 @@ export async function GET( const decodedModelId = decodeURIComponent(modelId); const provider = request.nextUrl.searchParams.get("provider") as ProviderType | null; - if (!provider || (provider !== "replicate" && provider !== "fal")) { + if (!provider || (provider !== "replicate" && provider !== "fal" && provider !== "kie")) { return NextResponse.json( { success: false, - error: "Invalid or missing provider. Use ?provider=replicate or ?provider=fal", + error: "Invalid or missing provider. Use ?provider=replicate, ?provider=fal, or ?provider=kie", }, { status: 400 } ); @@ -526,6 +701,9 @@ export async function GET( ); } result = await fetchReplicateSchema(decodedModelId, apiKey); + } else if (provider === "kie") { + // Kie.ai uses hardcoded schemas (no schema discovery API) + result = getKieSchema(decodedModelId); } else { // User-provided key takes precedence over env variable const apiKey = request.headers.get("X-Fal-Key") || process.env.FAL_API_KEY || null; diff --git a/src/app/api/models/route.ts b/src/app/api/models/route.ts index b3840d48..0d0fb1ea 100644 --- a/src/app/api/models/route.ts +++ b/src/app/api/models/route.ts @@ -46,6 +46,183 @@ const RELEVANT_CATEGORIES = [ "image-to-video", ]; +// Kie.ai models (hardcoded - no discovery API available) +const KIE_MODELS: ProviderModel[] = [ + // Image Models + { + id: "z-image", + name: "Z-Image", + description: "Fast, affordable text-to-image generation. Great for quick iterations.", + provider: "kie", + capabilities: ["text-to-image"], + coverImage: undefined, + pricing: { type: "per-run", amount: 0.004, currency: "USD" }, + }, + { + id: "seedream/4.5-text-to-image", + name: "Seedream 4.5", + description: "High-quality text-to-image generation with excellent prompt following.", + provider: "kie", + capabilities: ["text-to-image"], + coverImage: undefined, + pricing: { type: "per-run", amount: 0.032, currency: "USD" }, + }, + { + id: "seedream/4.5-edit", + name: "Seedream 4.5 Edit", + description: "Image editing and transformation using Seedream 4.5.", + provider: "kie", + capabilities: ["image-to-image"], + coverImage: undefined, + pricing: { type: "per-run", amount: 0.032, currency: "USD" }, + }, + { + id: "nano-banana-pro", + name: "Nano Banana Pro (Kie)", + description: "High-quality image generation supporting both text-to-image and image-to-image.", + provider: "kie", + capabilities: ["text-to-image", "image-to-image"], + coverImage: undefined, + pricing: { type: "per-run", amount: 0.10, currency: "USD" }, + }, + { + id: "gpt-image/1.5-text-to-image", + name: "GPT Image 1.5", + description: "OpenAI-style image generation with excellent prompt understanding.", + provider: "kie", + capabilities: ["text-to-image"], + coverImage: undefined, + pricing: { type: "per-run", amount: 0.06, currency: "USD" }, + }, + { + id: "gpt-image/1.5-image-to-image", + name: "GPT Image 1.5 Edit", + description: "Image editing using GPT Image 1.5 model.", + provider: "kie", + capabilities: ["image-to-image"], + coverImage: undefined, + pricing: { type: "per-run", amount: 0.06, currency: "USD" }, + }, + { + id: "google/nano-banana", + name: "Google Nano Banana (Kie)", + description: "Google's image generation model via Kie.ai.", + provider: "kie", + capabilities: ["text-to-image"], + coverImage: undefined, + pricing: { type: "per-run", amount: 0.05, currency: "USD" }, + }, + { + id: "google/nano-banana-edit", + name: "Google Nano Banana Edit", + description: "Google's image editing model via Kie.ai.", + provider: "kie", + capabilities: ["image-to-image"], + coverImage: undefined, + pricing: { type: "per-run", amount: 0.05, currency: "USD" }, + }, + // Video Models + { + id: "sora-2-text-to-video", + name: "Sora 2", + description: "OpenAI Sora video generation from text prompts.", + provider: "kie", + capabilities: ["text-to-video"], + coverImage: undefined, + pricing: { type: "per-second", amount: 0.015, currency: "USD" }, + }, + { + id: "sora-2-image-to-video", + name: "Sora 2 Image-to-Video", + description: "OpenAI Sora video generation from images.", + provider: "kie", + capabilities: ["image-to-video"], + coverImage: undefined, + pricing: { type: "per-second", amount: 0.015, currency: "USD" }, + }, + { + id: "sora-2-pro-text-to-video", + name: "Sora 2 Pro", + description: "Premium Sora video generation with higher quality output.", + provider: "kie", + capabilities: ["text-to-video"], + coverImage: undefined, + pricing: { type: "per-second", amount: 0.10, currency: "USD" }, + }, + { + id: "sora-2-pro-image-to-video", + name: "Sora 2 Pro Image-to-Video", + description: "Premium Sora video generation from images.", + provider: "kie", + capabilities: ["image-to-video"], + coverImage: undefined, + pricing: { type: "per-second", amount: 0.10, currency: "USD" }, + }, + { + id: "veo3_fast", + name: "Veo 3 Fast", + description: "Google Veo 3 fast video generation. Supports text and image inputs.", + provider: "kie", + capabilities: ["text-to-video", "image-to-video"], + coverImage: undefined, + pricing: { type: "per-run", amount: 0.30, currency: "USD" }, + }, + { + id: "veo3", + name: "Veo 3", + description: "Google Veo 3 high-quality video generation. Supports text and image inputs.", + provider: "kie", + capabilities: ["text-to-video", "image-to-video"], + coverImage: undefined, + pricing: { type: "per-run", amount: 1.25, currency: "USD" }, + }, + { + id: "bytedance/seedance-1.5-pro", + name: "Seedance 1.5 Pro", + description: "ByteDance video generation model. Supports text and image inputs.", + provider: "kie", + capabilities: ["text-to-video", "image-to-video"], + coverImage: undefined, + pricing: { type: "per-run", amount: 0.20, currency: "USD" }, + }, + { + id: "kling-2.6/text-to-video", + name: "Kling 2.6", + description: "Kling 2.6 video generation from text.", + provider: "kie", + capabilities: ["text-to-video"], + coverImage: undefined, + pricing: { type: "per-run", amount: 0.60, currency: "USD" }, + }, + { + id: "kling-2.6/image-to-video", + name: "Kling 2.6 Image-to-Video", + description: "Kling 2.6 video generation from images.", + provider: "kie", + capabilities: ["image-to-video"], + coverImage: undefined, + pricing: { type: "per-run", amount: 0.60, currency: "USD" }, + }, + { + id: "wan/2-6-text-to-video", + name: "Wan 2.6", + description: "Wan 2.6 video generation from text.", + provider: "kie", + capabilities: ["text-to-video"], + coverImage: undefined, + pricing: { type: "per-run", amount: 0.90, currency: "USD" }, + }, + { + id: "wan/2-6-image-to-video", + name: "Wan 2.6 Image-to-Video", + description: "Wan 2.6 video generation from images.", + provider: "kie", + capabilities: ["image-to-video"], + coverImage: undefined, + pricing: { type: "per-run", amount: 0.90, currency: "USD" }, + }, +]; + // Gemini image models (hardcoded - these don't come from an external API) const GEMINI_IMAGE_MODELS: ProviderModel[] = [ { @@ -346,15 +523,20 @@ export async function GET( // Get API keys from headers, falling back to env variables const replicateKey = request.headers.get("X-Replicate-Key") || process.env.REPLICATE_API_KEY || null; const falKey = request.headers.get("X-Fal-Key") || process.env.FAL_API_KEY || null; + const kieKey = request.headers.get("X-Kie-Key") || process.env.KIE_API_KEY || null; - // Determine which providers to fetch from (excluding gemini - handled separately) + // Determine which providers to fetch from (excluding gemini/kie - handled separately as hardcoded) const providersToFetch: ProviderType[] = []; let includeGemini = false; + let includeKie = false; if (providerFilter) { if (providerFilter === "gemini") { // Only Gemini requested - no external API calls needed includeGemini = true; + } else if (providerFilter === "kie") { + // Only Kie requested - no external API calls needed (hardcoded models) + includeKie = true; } else if (providerFilter === "replicate" && replicateKey) { providersToFetch.push("replicate"); } else if (providerFilter === "fal") { @@ -364,6 +546,7 @@ export async function GET( } else { // Include all providers includeGemini = true; // Gemini always available + includeKie = kieKey ? true : false; // Kie only if API key is configured if (replicateKey) { providersToFetch.push("replicate"); } @@ -371,13 +554,13 @@ export async function GET( providersToFetch.push("fal"); } - // Gemini is always available, so we don't fail if no external providers - if (providersToFetch.length === 0 && !includeGemini) { + // Gemini and Kie are always available (with key for Kie), so we don't fail if no external providers + if (providersToFetch.length === 0 && !includeGemini && !includeKie) { return NextResponse.json( { success: false, error: - "No providers available. Add REPLICATE_API_KEY or FAL_API_KEY to .env.local or configure in Settings.", + "No providers available. Add REPLICATE_API_KEY, FAL_API_KEY, or KIE_API_KEY to .env.local or configure in Settings.", }, { status: 400 } ); @@ -405,6 +588,22 @@ export async function GET( anyFromCache = true; } + // Add Kie models if included (hardcoded, no API call needed) + if (includeKie) { + // Filter by search query if provided + let kieModels = KIE_MODELS; + if (searchQuery) { + kieModels = filterModelsBySearch(kieModels, searchQuery); + } + allModels.push(...kieModels); + providerResults["kie"] = { + success: true, + count: kieModels.length, + cached: true, // Hardcoded models are effectively "cached" + }; + anyFromCache = true; + } + // Fetch from each provider for (const provider of providersToFetch) { // For Replicate, always use base cache key since we filter client-side diff --git a/src/components/CostDialog.tsx b/src/components/CostDialog.tsx index 53c70763..021c01f9 100644 --- a/src/components/CostDialog.tsx +++ b/src/components/CostDialog.tsx @@ -20,6 +20,7 @@ function ProviderIcon({ provider }: { provider: ProviderType }) { fal: { bg: "bg-purple-500/20", text: "text-purple-300" }, replicate: { bg: "bg-blue-500/20", text: "text-blue-300" }, openai: { bg: "bg-teal-500/20", text: "text-teal-300" }, + kie: { bg: "bg-orange-500/20", text: "text-orange-300" }, }; const labels: Record = { @@ -27,6 +28,7 @@ function ProviderIcon({ provider }: { provider: ProviderType }) { fal: "f", replicate: "R", openai: "O", + kie: "K", }; const color = colors[provider] || colors.gemini; @@ -47,6 +49,7 @@ function getProviderDisplayName(provider: ProviderType): string { fal: "fal.ai", replicate: "Replicate", openai: "OpenAI", + kie: "Kie.ai", }; return names[provider] || provider; } diff --git a/src/components/ProjectSetupModal.tsx b/src/components/ProjectSetupModal.tsx index 0812ccfa..26bd89c8 100644 --- a/src/components/ProjectSetupModal.tsx +++ b/src/components/ProjectSetupModal.tsx @@ -102,12 +102,14 @@ export function ProjectSetupModal({ openai: false, replicate: false, fal: false, + kie: false, }); const [overrideActive, setOverrideActive] = useState>({ gemini: false, openai: false, replicate: false, fal: false, + kie: false, }); const [envStatus, setEnvStatus] = useState(null); @@ -136,13 +138,14 @@ export function ProjectSetupModal({ // Sync local providers state setLocalProviders(providerSettings); - setShowApiKey({ gemini: false, openai: false, replicate: false, fal: false }); + setShowApiKey({ gemini: false, openai: false, replicate: false, fal: false, kie: false }); // Initialize override as active if user already has a key set setOverrideActive({ gemini: !!providerSettings.providers.gemini?.apiKey, openai: !!providerSettings.providers.openai?.apiKey, replicate: !!providerSettings.providers.replicate?.apiKey, fal: !!providerSettings.providers.fal?.apiKey, + kie: !!providerSettings.providers.kie?.apiKey, }); setError(null); diff --git a/src/components/nodes/GenerateImageNode.tsx b/src/components/nodes/GenerateImageNode.tsx index 04e1b771..081c2ce2 100644 --- a/src/components/nodes/GenerateImageNode.tsx +++ b/src/components/nodes/GenerateImageNode.tsx @@ -14,7 +14,7 @@ import { getImageDimensions, calculateNodeSizePreservingHeight } from "@/utils/n // Provider badge component - shows provider icon for all providers function ProviderBadge({ provider }: { provider: ProviderType }) { - const providerName = provider === "gemini" ? "Gemini" : provider === "replicate" ? "Replicate" : "fal.ai"; + const providerName = provider === "gemini" ? "Gemini" : provider === "replicate" ? "Replicate" : provider === "kie" ? "Kie.ai" : "fal.ai"; return ( @@ -28,6 +28,10 @@ function ProviderBadge({ provider }: { provider: ProviderType }) { + ) : provider === "kie" ? ( + + + ) : ( @@ -80,6 +84,10 @@ export function GenerateImageNode({ id, data, selected }: NodeProps @@ -28,6 +28,10 @@ function ProviderBadge({ provider }: { provider: ProviderType }) { + ) : provider === "kie" ? ( + + + ) : ( @@ -66,6 +70,10 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps((set, get) => ({ if (falConfig?.apiKey) { headers["X-Fal-API-Key"] = falConfig.apiKey; } + } else if (provider === "kie") { + const kieConfig = providerSettingsState.providers.kie; + if (kieConfig?.apiKey) { + headers["X-Kie-API-Key"] = kieConfig.apiKey; + } } logger.info('node.execution', `Calling ${provider} API for image generation`, { @@ -1286,6 +1291,11 @@ export const useWorkflowStore = create((set, get) => ({ if (falConfig?.apiKey) { headers["X-Fal-API-Key"] = falConfig.apiKey; } + } else if (provider === "kie") { + const kieConfig = providerSettingsState.providers.kie; + if (kieConfig?.apiKey) { + headers["X-Kie-API-Key"] = kieConfig.apiKey; + } } logger.info('node.execution', `Calling ${provider} API for video generation`, { nodeId: node.id, @@ -1839,6 +1849,11 @@ export const useWorkflowStore = create((set, get) => ({ if (falConfig?.apiKey) { headers["X-Fal-API-Key"] = falConfig.apiKey; } + } else if (provider === "kie") { + const kieConfig = providerSettingsState.providers.kie; + if (kieConfig?.apiKey) { + headers["X-Kie-API-Key"] = kieConfig.apiKey; + } } logger.info('node.execution', `Calling ${provider} API for node regeneration`, { @@ -2108,6 +2123,11 @@ export const useWorkflowStore = create((set, get) => ({ if (falConfig?.apiKey) { headers["X-Fal-API-Key"] = falConfig.apiKey; } + } else if (provider === "kie") { + const kieConfig = providerSettingsState.providers.kie; + if (kieConfig?.apiKey) { + headers["X-Kie-API-Key"] = kieConfig.apiKey; + } } logger.info('node.execution', `Calling ${provider} API for video regeneration`, { nodeId, diff --git a/src/types/providers.ts b/src/types/providers.ts index 46aba5b0..af764957 100644 --- a/src/types/providers.ts +++ b/src/types/providers.ts @@ -6,7 +6,7 @@ */ // Provider Types for multi-provider support (image/video generation) -export type ProviderType = "gemini" | "openai" | "replicate" | "fal"; +export type ProviderType = "gemini" | "openai" | "replicate" | "fal" | "kie"; // Model pricing info (stored when model is selected) export interface SelectedModelPricing {