Browse Source
Move generateWithReplicate() into providers/replicate.ts. It imports schema utilities from schemaUtils.ts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>handoff-20260429-1057
2 changed files with 281 additions and 268 deletions
@ -0,0 +1,280 @@ |
|||
/** |
|||
* Replicate Provider for Generate API Route |
|||
* |
|||
* Handles image/video generation using Replicate's prediction API. |
|||
*/ |
|||
|
|||
import { GenerationInput, GenerationOutput } from "@/lib/providers/types"; |
|||
import { |
|||
getParameterTypesFromSchema, |
|||
coerceParameterTypes, |
|||
getInputMappingFromSchema, |
|||
} from "../schemaUtils"; |
|||
|
|||
/** |
|||
* Generate image using Replicate API |
|||
*/ |
|||
export async function generateWithReplicate( |
|||
requestId: string, |
|||
apiKey: string, |
|||
input: GenerationInput |
|||
): Promise<GenerationOutput> { |
|||
console.log(`[API:${requestId}] Replicate generation - Model: ${input.model.id}, Images: ${input.images?.length || 0}, Prompt: ${input.prompt.length} chars`); |
|||
|
|||
const REPLICATE_API_BASE = "https://api.replicate.com/v1"; |
|||
|
|||
// Get the latest version of the model
|
|||
const modelId = input.model.id; |
|||
const [owner, name] = modelId.split("/"); |
|||
|
|||
// First, get the model to find the latest version
|
|||
const modelResponse = await fetch( |
|||
`${REPLICATE_API_BASE}/models/${owner}/${name}`, |
|||
{ |
|||
headers: { |
|||
Authorization: `Bearer ${apiKey}`, |
|||
}, |
|||
} |
|||
); |
|||
|
|||
if (!modelResponse.ok) { |
|||
return { |
|||
success: false, |
|||
error: `Failed to get model info: ${modelResponse.status}`, |
|||
}; |
|||
} |
|||
|
|||
const modelData = await modelResponse.json(); |
|||
const version = modelData.latest_version?.id; |
|||
|
|||
if (!version) { |
|||
return { |
|||
success: false, |
|||
error: "Model has no available version", |
|||
}; |
|||
} |
|||
|
|||
const hasDynamicInputs = input.dynamicInputs && Object.keys(input.dynamicInputs).length > 0; |
|||
console.log(`[API:${requestId}] Model version: ${version}, Dynamic inputs: ${hasDynamicInputs ? Object.keys(input.dynamicInputs!).join(", ") : "none"}`); |
|||
|
|||
// Get schema for type coercion and input mapping
|
|||
const schema = modelData.latest_version?.openapi_schema as Record<string, unknown> | undefined; |
|||
const parameterTypes = getParameterTypesFromSchema(schema); |
|||
|
|||
// Build input for the prediction, coercing parameter types from schema
|
|||
const predictionInput: Record<string, unknown> = { |
|||
...coerceParameterTypes(input.parameters, parameterTypes), |
|||
}; |
|||
|
|||
// Add dynamic inputs if provided (these come from schema-mapped connections)
|
|||
if (hasDynamicInputs) { |
|||
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 { paramMap, arrayParams } = getInputMappingFromSchema(schema); |
|||
|
|||
// Map prompt input
|
|||
if (input.prompt) { |
|||
const promptParam = paramMap.prompt || "prompt"; |
|||
predictionInput[promptParam] = input.prompt; |
|||
} |
|||
|
|||
// Map image input - use array or string format based on schema
|
|||
if (input.images && input.images.length > 0) { |
|||
const imageParam = paramMap.image || "image"; |
|||
if (arrayParams.has("image")) { |
|||
predictionInput[imageParam] = input.images; |
|||
} else { |
|||
predictionInput[imageParam] = input.images[0]; |
|||
} |
|||
} |
|||
|
|||
// Map any parameters that might need renaming (use coerced values)
|
|||
const coercedParams = coerceParameterTypes(input.parameters, parameterTypes); |
|||
for (const [key, value] of Object.entries(coercedParams)) { |
|||
const mappedKey = paramMap[key] || key; |
|||
predictionInput[mappedKey] = value; |
|||
} |
|||
} |
|||
|
|||
// Create a prediction
|
|||
const createResponse = await fetch(`${REPLICATE_API_BASE}/predictions`, { |
|||
method: "POST", |
|||
headers: { |
|||
Authorization: `Bearer ${apiKey}`, |
|||
"Content-Type": "application/json", |
|||
}, |
|||
body: JSON.stringify({ |
|||
version, |
|||
input: predictionInput, |
|||
}), |
|||
}); |
|||
|
|||
if (!createResponse.ok) { |
|||
const errorText = await createResponse.text(); |
|||
let errorDetail = errorText; |
|||
try { |
|||
const errorJson = JSON.parse(errorText); |
|||
errorDetail = errorJson.detail || errorJson.message || errorJson.error || errorText; |
|||
} catch { |
|||
// Keep original text if not JSON
|
|||
} |
|||
|
|||
// Handle rate limits
|
|||
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 prediction = await createResponse.json(); |
|||
console.log(`[API:${requestId}] Prediction created: ${prediction.id}`); |
|||
|
|||
// Poll for completion
|
|||
const maxWaitTime = 5 * 60 * 1000; // 5 minutes
|
|||
const pollInterval = 1000; // 1 second
|
|||
const startTime = Date.now(); |
|||
|
|||
let currentPrediction = prediction; |
|||
let lastStatus = ""; |
|||
|
|||
while ( |
|||
currentPrediction.status !== "succeeded" && |
|||
currentPrediction.status !== "failed" && |
|||
currentPrediction.status !== "canceled" |
|||
) { |
|||
if (Date.now() - startTime > maxWaitTime) { |
|||
return { |
|||
success: false, |
|||
error: `${input.model.name}: Generation timed out after 5 minutes. Video models may take longer - try again.`, |
|||
}; |
|||
} |
|||
|
|||
await new Promise((resolve) => setTimeout(resolve, pollInterval)); |
|||
|
|||
const pollResponse = await fetch( |
|||
`${REPLICATE_API_BASE}/predictions/${currentPrediction.id}`, |
|||
{ |
|||
headers: { |
|||
Authorization: `Bearer ${apiKey}`, |
|||
}, |
|||
} |
|||
); |
|||
|
|||
if (!pollResponse.ok) { |
|||
return { |
|||
success: false, |
|||
error: `Failed to poll prediction: ${pollResponse.status}`, |
|||
}; |
|||
} |
|||
|
|||
currentPrediction = await pollResponse.json(); |
|||
if (currentPrediction.status !== lastStatus) { |
|||
console.log(`[API:${requestId}] Prediction status: ${currentPrediction.status}`); |
|||
lastStatus = currentPrediction.status; |
|||
} |
|||
} |
|||
|
|||
if (currentPrediction.status === "failed") { |
|||
const failureReason = currentPrediction.error || "Prediction failed"; |
|||
return { |
|||
success: false, |
|||
error: `${input.model.name}: ${failureReason}`, |
|||
}; |
|||
} |
|||
|
|||
if (currentPrediction.status === "canceled") { |
|||
return { |
|||
success: false, |
|||
error: "Prediction was canceled", |
|||
}; |
|||
} |
|||
|
|||
// Extract output
|
|||
const output = currentPrediction.output; |
|||
if (!output) { |
|||
return { |
|||
success: false, |
|||
error: "No output from prediction", |
|||
}; |
|||
} |
|||
|
|||
// Output can be a single URL string or an array of URLs
|
|||
const outputUrls: string[] = Array.isArray(output) ? output : [output]; |
|||
|
|||
if (outputUrls.length === 0) { |
|||
return { |
|||
success: false, |
|||
error: "No output from prediction", |
|||
}; |
|||
} |
|||
|
|||
// Fetch the first output and convert to base64
|
|||
const mediaUrl = outputUrls[0]; |
|||
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}`, |
|||
}; |
|||
} |
|||
|
|||
// Determine MIME type from response
|
|||
const contentType = mediaResponse.headers.get("content-type") || "image/png"; |
|||
const isVideo = contentType.startsWith("video/"); |
|||
|
|||
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 instead of base64
|
|||
if (isVideo && mediaSizeMB > 20) { |
|||
console.log(`[API:${requestId}] SUCCESS - Returning URL for large video`); |
|||
return { |
|||
success: true, |
|||
outputs: [ |
|||
{ |
|||
type: "video", |
|||
data: mediaUrl, // Return URL directly for very large videos
|
|||
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, |
|||
}, |
|||
], |
|||
}; |
|||
} |
|||
Loading…
Reference in new issue