Files
openmaic/OpenMAIC/app/api/generate/image/route.ts
2026-08-16 14:58:47 +08:00

119 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Image Generation API
*
* Generates an image from a text prompt using the specified provider.
* Called by the client during media generation after slides are produced.
*
* POST /api/generate/image
*
* Headers:
* x-image-provider: ImageProviderId (default: 'seedream')
* x-api-key: string (optional, server fallback)
* x-base-url: string (optional, server fallback)
*
* Body: { prompt, negativePrompt?, width?, height?, aspectRatio?, style? }
* Response: { success: boolean, result?: ImageGenerationResult, error?: string }
*/
import { NextRequest } from 'next/server';
import { recordGenerationUsage } from '@/lib/server/usage-storage';
import {
generateImage,
aspectRatioToDimensions,
IMAGE_PROVIDERS,
} from '@/lib/media/image-providers';
import {
isServerConfiguredProvider,
resolveImageApiKey,
resolveImageBaseUrl,
} from '@/lib/server/provider-config';
import type { ImageProviderId, ImageGenerationOptions } from '@/lib/media/types';
import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';
import { providerAccessApiError } from '@/lib/server/provider-access-response';
const log = createLogger('ImageGeneration API');
// The ComfyUI adapter polls up to GENERATION_TIMEOUT_MS (5 min) and real
// workflows can take 3–5 min. 60s would let platforms that enforce maxDuration
// (e.g. Vercel) kill the request ~4 min before the adapter finishes. 300s is
// the practical ceiling on most managed platforms and matches the poll budget.
// (Self-hosted Node servers ignore this value entirely.)
export const maxDuration = 300;
export async function POST(request: NextRequest) {
try {
const body = (await request.json()) as ImageGenerationOptions;
if (!body.prompt) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'Missing prompt');
}
const providerId = (request.headers.get('x-image-provider') || 'seedream') as ImageProviderId;
// Managed providers are admin-owned: ignore any client-sent key/baseUrl.
const managed = isServerConfiguredProvider('image', providerId);
const clientApiKey = managed ? undefined : request.headers.get('x-api-key') || undefined;
const clientBaseUrl = managed ? undefined : request.headers.get('x-base-url') || undefined;
const clientModel = request.headers.get('x-image-model') || undefined;
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
const ssrfError = await validateUrlForSSRF(clientBaseUrl);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
}
}
const apiKey = resolveImageApiKey(providerId, clientApiKey);
const provider = IMAGE_PROVIDERS[providerId];
if (provider?.requiresApiKey && !apiKey) {
return apiError(
'MISSING_API_KEY',
401,
`No API key configured for image provider: ${providerId}`,
);
}
const baseUrl = resolveImageBaseUrl(providerId, clientBaseUrl);
// Resolve dimensions from aspect ratio if not explicitly set
if (!body.width && !body.height && body.aspectRatio) {
const dims = aspectRatioToDimensions(body.aspectRatio);
body.width = dims.width;
body.height = dims.height;
}
log.info(
`Generating image: provider=${providerId}, model=${clientModel || 'default'}, ` +
`prompt="${body.prompt.slice(0, 80)}...", size=${body.width ?? 'auto'}x${body.height ?? 'auto'}`,
);
const result = await generateImage({ providerId, apiKey, baseUrl, model: clientModel }, body);
void recordGenerationUsage({
kind: 'image',
unit: 'image',
providerId,
modelId: clientModel,
quantity: 1,
});
return apiSuccess({ result });
} catch (error) {
const providerAccessError = providerAccessApiError(error);
if (providerAccessError) return providerAccessError;
const message = error instanceof Error ? error.message : String(error);
// Detect content safety filter rejections (e.g. Seedream OutputImageSensitiveContentDetected)
if (message.includes('SensitiveContent') || message.includes('sensitive information')) {
log.warn(`Image blocked by content safety filter: ${message}`);
return apiError('CONTENT_SENSITIVE', 400, message);
}
log.error(
`Image generation failed [provider=${request.headers.get('x-image-provider') ?? 'seedream'}, model=${request.headers.get('x-image-model') ?? 'default'}]:`,
error,
);
return apiError('INTERNAL_ERROR', 500, message);
}
}