Browse Source

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 <noreply@anthropic.com>
handoff-20260429-1057
lapoaiscalers 6 months ago
parent
commit
a9cbb11d56
  1. 2
      CLAUDE.md
  2. 458
      src/app/api/generate/route.ts
  3. 182
      src/app/api/models/[modelId]/route.ts
  4. 207
      src/app/api/models/route.ts
  5. 3
      src/components/CostDialog.tsx
  6. 5
      src/components/ProjectSetupModal.tsx
  7. 13
      src/components/nodes/GenerateImageNode.tsx
  8. 13
      src/components/nodes/GenerateVideoNode.tsx
  9. 1
      src/store/utils/localStorage.ts
  10. 20
      src/store/workflowStore.ts
  11. 2
      src/types/providers.ts

2
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

458
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<string> {
// 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<string, unknown>; 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<GenerationOutput> {
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<string, unknown> = {
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<GenerateResponse>(
{
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<string, string> | 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<GenerateResponse>(
{
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<GenerateResponse>(
{
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<GenerateResponse>({
success: true,
video: isUrl ? undefined : output.data,
videoUrl: isUrl ? output.data : undefined,
contentType: "video",
});
}
return NextResponse.json<GenerateResponse>({
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;

182
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<string, ExtractedSchema> = {
// 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<SchemaErrorResponse>(
{
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;

207
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<ModelsErrorResponse>(
{
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

3
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<ProviderType, string> = {
@ -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;
}

5
src/components/ProjectSetupModal.tsx

@ -102,12 +102,14 @@ export function ProjectSetupModal({
openai: false,
replicate: false,
fal: false,
kie: false,
});
const [overrideActive, setOverrideActive] = useState<Record<ProviderType, boolean>>({
gemini: false,
openai: false,
replicate: false,
fal: false,
kie: false,
});
const [envStatus, setEnvStatus] = useState<EnvStatusResponse | null>(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);

13
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 (
<span className="text-neutral-500 shrink-0" title={providerName}>
@ -28,6 +28,10 @@ function ProviderBadge({ provider }: { provider: ProviderType }) {
<polygon points="1000,213.8 1000,327 364.8,327 364.8,1000 238.4,1000 238.4,213.8" />
<polygon points="1000,0 1000,113.2 126.4,113.2 126.4,1000 0,1000 0,0" />
</svg>
) : provider === "kie" ? (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
</svg>
) : (
<svg className="w-4 h-4" viewBox="0 0 1855 1855" fill="currentColor">
<path fillRule="evenodd" clipRule="evenodd" d="M1181.65 78C1212.05 78 1236.42 101.947 1239.32 131.261C1265.25 392.744 1480.07 600.836 1750.02 625.948C1780.28 628.764 1805 652.366 1805 681.816V1174.18C1805 1203.63 1780.28 1227.24 1750.02 1230.05C1480.07 1255.16 1265.25 1463.26 1239.32 1724.74C1236.42 1754.05 1212.05 1778 1181.65 1778H673.354C642.951 1778 618.585 1754.05 615.678 1724.74C589.754 1463.26 374.927 1255.16 104.984 1230.05C74.7212 1227.24 50 1203.63 50 1174.18V681.816C50 652.366 74.7213 628.764 104.984 625.948C374.927 600.836 589.754 392.744 615.678 131.261C618.585 101.946 642.951 78 673.353 78H1181.65ZM402.377 926.561C402.377 1209.41 638.826 1438.71 930.501 1438.71C1222.18 1438.71 1458.63 1209.41 1458.63 926.561C1458.63 643.709 1222.18 414.412 930.501 414.412C638.826 414.412 402.377 643.709 402.377 926.561Z" />
@ -80,6 +84,10 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
if (providerSettings.providers.replicate?.enabled && providerSettings.providers.replicate?.apiKey) {
providers.push({ id: "replicate", name: "Replicate" });
}
// Add Kie.ai if configured
if (providerSettings.providers.kie?.enabled && providerSettings.providers.kie?.apiKey) {
providers.push({ id: "kie", name: "Kie.ai" });
}
return providers;
}, [providerSettings]);
@ -126,6 +134,9 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
if (providerSettings.providers.fal?.apiKey) {
headers["X-Fal-Key"] = providerSettings.providers.fal.apiKey;
}
if (providerSettings.providers.kie?.apiKey) {
headers["X-Kie-Key"] = providerSettings.providers.kie.apiKey;
}
const response = await fetch(`/api/models?provider=${currentProvider}&capabilities=${capabilities}`, { headers });
if (response.ok) {
const data = await response.json();

13
src/components/nodes/GenerateVideoNode.tsx

@ -14,7 +14,7 @@ import { getVideoDimensions, 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 (
<span className="text-neutral-500 shrink-0" title={providerName}>
@ -28,6 +28,10 @@ function ProviderBadge({ provider }: { provider: ProviderType }) {
<polygon points="1000,213.8 1000,327 364.8,327 364.8,1000 238.4,1000 238.4,213.8" />
<polygon points="1000,0 1000,113.2 126.4,113.2 126.4,1000 0,1000 0,0" />
</svg>
) : provider === "kie" ? (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
</svg>
) : (
<svg className="w-4 h-4" viewBox="0 0 1855 1855" fill="currentColor">
<path fillRule="evenodd" clipRule="evenodd" d="M1181.65 78C1212.05 78 1236.42 101.947 1239.32 131.261C1265.25 392.744 1480.07 600.836 1750.02 625.948C1780.28 628.764 1805 652.366 1805 681.816V1174.18C1805 1203.63 1780.28 1227.24 1750.02 1230.05C1480.07 1255.16 1265.25 1463.26 1239.32 1724.74C1236.42 1754.05 1212.05 1778 1181.65 1778H673.354C642.951 1778 618.585 1754.05 615.678 1724.74C589.754 1463.26 374.927 1255.16 104.984 1230.05C74.7212 1227.24 50 1203.63 50 1174.18V681.816C50 652.366 74.7213 628.764 104.984 625.948C374.927 600.836 589.754 392.744 615.678 131.261C618.585 101.946 642.951 78 673.353 78H1181.65ZM402.377 926.561C402.377 1209.41 638.826 1438.71 930.501 1438.71C1222.18 1438.71 1458.63 1209.41 1458.63 926.561C1458.63 643.709 1222.18 414.412 930.501 414.412C638.826 414.412 402.377 643.709 402.377 926.561Z" />
@ -66,6 +70,10 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
if (providerSettings.providers.replicate?.enabled && providerSettings.providers.replicate?.apiKey) {
providers.push({ id: "replicate", name: "Replicate" });
}
// Add Kie.ai if configured
if (providerSettings.providers.kie?.enabled && providerSettings.providers.kie?.apiKey) {
providers.push({ id: "kie", name: "Kie.ai" });
}
return providers;
}, [providerSettings]);
@ -82,6 +90,9 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
if (providerSettings.providers.fal?.apiKey) {
headers["X-Fal-Key"] = providerSettings.providers.fal.apiKey;
}
if (providerSettings.providers.kie?.apiKey) {
headers["X-Kie-Key"] = providerSettings.providers.kie.apiKey;
}
const response = await fetch(`/api/models?provider=${currentProvider}&capabilities=${capabilities}`, { headers });
if (response.ok) {
const data = await response.json();

1
src/store/utils/localStorage.ts

@ -42,6 +42,7 @@ export const defaultProviderSettings: ProviderSettings = {
openai: { id: "openai", name: "OpenAI", enabled: true, apiKey: null, apiKeyEnvVar: "OPENAI_API_KEY" },
replicate: { id: "replicate", name: "Replicate", enabled: false, apiKey: null, apiKeyEnvVar: "REPLICATE_API_KEY" },
fal: { id: "fal", name: "fal.ai", enabled: false, apiKey: null, apiKeyEnvVar: "FAL_API_KEY" },
kie: { id: "kie", name: "Kie.ai", enabled: false, apiKey: null, apiKeyEnvVar: "KIE_API_KEY" },
}
};

20
src/store/workflowStore.ts

@ -1072,6 +1072,11 @@ export const useWorkflowStore = create<WorkflowStore>((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<WorkflowStore>((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<WorkflowStore>((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<WorkflowStore>((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,

2
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 {

Loading…
Cancel
Save