Route image API requests through templates
This commit is contained in:
@@ -12,6 +12,14 @@ import { localStorage } from '@/lib/local-storage';
|
||||
import { fetchPublicHttpUrl } from '@/lib/remote-fetch';
|
||||
import { isTrustedInternalGenerationRequest, isUuid, resolveServerApiConfig } from '@/lib/server-api-config';
|
||||
import { updateGenerationJobProgress } from '@/lib/generation-job-estimates';
|
||||
import {
|
||||
isDallE3Model,
|
||||
isGptImageModel,
|
||||
isOpenAICompatibleImageApi,
|
||||
normalizeOpenAICompatibleImageCount,
|
||||
normalizeOpenAICompatibleImageSize,
|
||||
resolveImageApiTemplate,
|
||||
} from '@/lib/image-api-templates';
|
||||
import {
|
||||
compressImageBufferForUpstream,
|
||||
dataUrlToImageBuffer,
|
||||
@@ -75,69 +83,6 @@ function resolveTargetImageSize(
|
||||
return parseImageSize(quality ? squareByQuality[quality] : undefined);
|
||||
}
|
||||
|
||||
function normalizeNewApiGptImageSize(size: string | undefined): string | undefined {
|
||||
if (!size) return undefined;
|
||||
const normalized = size.trim().toLowerCase();
|
||||
if (['auto', '1024x1024', '1536x1024', '1024x1536'].includes(normalized)) return normalized;
|
||||
const parsed = parseImageSize(normalized);
|
||||
if (!parsed) return undefined;
|
||||
const ratio = parsed.width / parsed.height;
|
||||
if (ratio > 1.12) return '1536x1024';
|
||||
if (ratio < 0.89) return '1024x1536';
|
||||
return '1024x1024';
|
||||
}
|
||||
|
||||
function isGptImageModel(modelName: string | undefined): boolean {
|
||||
return /^gpt-image-/i.test((modelName || '').trim());
|
||||
}
|
||||
|
||||
function isDallE3Model(modelName: string | undefined): boolean {
|
||||
return /^dall-e-3$/i.test((modelName || '').trim());
|
||||
}
|
||||
|
||||
function isDallE2Model(modelName: string | undefined): boolean {
|
||||
return /^dall-e-2$/i.test((modelName || '').trim());
|
||||
}
|
||||
|
||||
function normalizeDallE3Size(size: string | undefined): string {
|
||||
const normalized = (size || '').trim().toLowerCase();
|
||||
if (['1024x1024', '1792x1024', '1024x1792'].includes(normalized)) return normalized;
|
||||
const parsed = parseImageSize(normalized);
|
||||
if (!parsed) return '1024x1024';
|
||||
const ratio = parsed.width / parsed.height;
|
||||
if (ratio > 1.2) return '1792x1024';
|
||||
if (ratio < 0.84) return '1024x1792';
|
||||
return '1024x1024';
|
||||
}
|
||||
|
||||
function normalizeDallE2Size(size: string | undefined): string {
|
||||
const normalized = (size || '').trim().toLowerCase();
|
||||
if (['256x256', '512x512', '1024x1024'].includes(normalized)) return normalized;
|
||||
return '1024x1024';
|
||||
}
|
||||
|
||||
function normalizeNewApiImageSize(modelName: string | undefined, size: string | undefined): string {
|
||||
if (isDallE3Model(modelName)) return normalizeDallE3Size(size);
|
||||
if (isDallE2Model(modelName)) return normalizeDallE2Size(size);
|
||||
return normalizeNewApiGptImageSize(size) || 'auto';
|
||||
}
|
||||
|
||||
function normalizeNewApiImageCount(modelName: string | undefined, count: number): number {
|
||||
if (isDallE3Model(modelName)) return 1;
|
||||
return Math.min(10, Math.max(1, Math.floor(count)));
|
||||
}
|
||||
|
||||
function isNewApiConfig(config: Pick<CustomApiConfig, 'provider' | 'apiUrl' | 'modelName'> | undefined): boolean {
|
||||
const provider = (config?.provider || '').trim().toLowerCase();
|
||||
const apiUrl = (config?.apiUrl || '').trim().toLowerCase();
|
||||
return provider === 'newapi'
|
||||
|| provider === 'new api'
|
||||
|| isGptImageModel(config?.modelName)
|
||||
|| isDallE3Model(config?.modelName)
|
||||
|| isDallE2Model(config?.modelName)
|
||||
|| /\/v1\/images\/(generations|edits)\b/i.test(apiUrl);
|
||||
}
|
||||
|
||||
function normalizeImageOutputFormat(value: unknown): 'png' | 'jpeg' | 'webp' {
|
||||
return value === 'jpeg' || value === 'webp' || value === 'png' ? value : 'png';
|
||||
}
|
||||
@@ -1071,15 +1016,15 @@ export async function POST(request: NextRequest) {
|
||||
// ---- Custom API mode ----
|
||||
if (resolvedCustomApiConfig && resolvedCustomApiConfig.apiKey) {
|
||||
const resolvedApiKey = resolvedCustomApiConfig.apiKey;
|
||||
const useNewApi = isNewApiConfig(resolvedCustomApiConfig as CustomApiConfig);
|
||||
const useNewApi = isOpenAICompatibleImageApi(resolvedCustomApiConfig as CustomApiConfig);
|
||||
try {
|
||||
// Image-to-image: use multi-strategy approach
|
||||
if (image) {
|
||||
const customApiSize = useNewApi
|
||||
? normalizeNewApiImageSize(resolvedCustomApiConfig.modelName, requestedCustomSize)
|
||||
? normalizeOpenAICompatibleImageSize(resolvedCustomApiConfig.modelName, requestedCustomSize)
|
||||
: requestedCustomSize;
|
||||
const customApiCount = useNewApi
|
||||
? normalizeNewApiImageCount(resolvedCustomApiConfig.modelName, resolvedAutoParams.count)
|
||||
? normalizeOpenAICompatibleImageCount(resolvedCustomApiConfig.modelName, resolvedAutoParams.count)
|
||||
: resolvedAutoParams.count;
|
||||
const customTargetSize = resolveTargetImageSize(
|
||||
customApiSize,
|
||||
@@ -1113,67 +1058,50 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: '自定义API未配置模型名称,请在设置中填写模型名称(如 gpt-image-2)' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Ensure n is at least 1
|
||||
const n = useNewApi
|
||||
? normalizeNewApiImageCount(resolvedCustomApiConfig.modelName, resolvedAutoParams.count)
|
||||
: resolvedAutoParams.count;
|
||||
const customApiSize = useNewApi
|
||||
? normalizeNewApiImageSize(resolvedCustomApiConfig.modelName, requestedCustomSize)
|
||||
: (requestedCustomSize || '1024x1024');
|
||||
// Resolve the selected model's API template and let it build the upstream request.
|
||||
const ratioHint = getAspectRatioPromptHint(resolvedAutoParams.aspectRatio);
|
||||
const augmentedPrompt = ratioHint ? `${promptForGeneration}\n\n[${ratioHint}]` : promptForGeneration;
|
||||
const imageApiTemplate = resolveImageApiTemplate(resolvedCustomApiConfig as CustomApiConfig);
|
||||
const templatedRequest = imageApiTemplate.buildTextToImageRequest({
|
||||
apiUrl: endpoint,
|
||||
modelName: resolvedCustomApiConfig.modelName,
|
||||
prompt: augmentedPrompt,
|
||||
negativePrompt,
|
||||
aspectRatio: resolvedAutoParams.aspectRatio,
|
||||
size: requestedCustomSize,
|
||||
count: resolvedAutoParams.count,
|
||||
outputFormat: resolvedOutputFormat,
|
||||
imageQuality: resolvedImageQuality,
|
||||
guidanceScale,
|
||||
style,
|
||||
user,
|
||||
});
|
||||
const n = templatedRequest.requestCount;
|
||||
const customApiSize = templatedRequest.requestSize;
|
||||
const customTargetSize = resolveTargetImageSize(
|
||||
customApiSize,
|
||||
resolvedAutoParams.aspectRatio,
|
||||
resolvedAutoParams.resolution,
|
||||
quality,
|
||||
);
|
||||
|
||||
// Augment prompt with aspect ratio hint as fallback
|
||||
// Many APIs ignore size/aspect_ratio params, so embedding in prompt helps
|
||||
const ratioHint = getAspectRatioPromptHint(resolvedAutoParams.aspectRatio);
|
||||
const augmentedPrompt = ratioHint ? `${promptForGeneration}\n\n[${ratioHint}]` : promptForGeneration;
|
||||
const requestBody: Record<string, unknown> = {
|
||||
model: resolvedCustomApiConfig.modelName,
|
||||
prompt: useNewApi && negativePrompt
|
||||
? `${augmentedPrompt}\n\nNegative prompt: ${negativePrompt}`
|
||||
: augmentedPrompt,
|
||||
n,
|
||||
size: customApiSize,
|
||||
};
|
||||
if (useNewApi) {
|
||||
applyNewApiImageGenerationParams(requestBody, {
|
||||
modelName: resolvedCustomApiConfig.modelName,
|
||||
outputFormat: resolvedOutputFormat,
|
||||
imageQuality: resolvedImageQuality,
|
||||
stream: true,
|
||||
style,
|
||||
user,
|
||||
});
|
||||
} else {
|
||||
requestBody.response_format = 'b64_json';
|
||||
requestBody.stream = true;
|
||||
if (negativePrompt) requestBody.negative_prompt = negativePrompt;
|
||||
if (guidanceScale && guidanceScale !== 7) requestBody.guidance_scale = guidanceScale;
|
||||
if (resolvedAutoParams.aspectRatio && resolvedAutoParams.aspectRatio !== 'original') {
|
||||
requestBody.aspect_ratio = resolvedAutoParams.aspectRatio;
|
||||
}
|
||||
}
|
||||
console.log('[Custom API Image] Text-to-image, sending to:', endpoint,
|
||||
const requestBody = templatedRequest.body;
|
||||
console.log('[Custom API Image] Text-to-image, sending to:', templatedRequest.endpoint,
|
||||
'| model:', requestBody.model,
|
||||
'| adapter:', useNewApi ? 'newapi' : 'generic',
|
||||
'| size:', requestBody.size,
|
||||
'| n:', requestBody.n,
|
||||
'| output_format:', requestBody.output_format,
|
||||
'| quality:', requestBody.quality,
|
||||
'| aspect_ratio:', requestBody.aspect_ratio,
|
||||
'| stream:', requestBody.stream,
|
||||
'| guidance_scale:', requestBody.guidance_scale,
|
||||
'| template:', imageApiTemplate.id,
|
||||
'| size:', templatedRequest.logFields.size,
|
||||
'| n:', templatedRequest.logFields.n,
|
||||
'| output_format:', templatedRequest.logFields.output_format,
|
||||
'| quality:', templatedRequest.logFields.quality,
|
||||
'| aspect_ratio:', templatedRequest.logFields.aspect_ratio,
|
||||
'| stream:', templatedRequest.logFields.stream,
|
||||
'| guidance_scale:', templatedRequest.logFields.guidance_scale,
|
||||
'| prompt_length:', prompt.length,
|
||||
'| request_prompt_length:', String(requestBody.prompt || '').length);
|
||||
|
||||
let customGenerationResult: Awaited<ReturnType<typeof requestQualifiedCustomImages>>;
|
||||
try {
|
||||
customGenerationResult = await requestQualifiedCustomImages(
|
||||
endpoint,
|
||||
templatedRequest.endpoint,
|
||||
resolvedApiKey,
|
||||
requestBody,
|
||||
n,
|
||||
|
||||
39
src/lib/image-api-templates/generic-json.ts
Normal file
39
src/lib/image-api-templates/generic-json.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { ImageApiTemplate } from './types';
|
||||
|
||||
export const genericJsonImageTemplate: ImageApiTemplate = {
|
||||
id: 'generic-json',
|
||||
label: 'Generic JSON image generation',
|
||||
matches: () => true,
|
||||
buildTextToImageRequest(input) {
|
||||
const requestCount = Math.min(10, Math.max(1, Math.floor(input.count)));
|
||||
const requestSize = input.size || '1024x1024';
|
||||
const body: Record<string, unknown> = {
|
||||
model: input.modelName,
|
||||
prompt: input.prompt,
|
||||
n: requestCount,
|
||||
size: requestSize,
|
||||
response_format: 'b64_json',
|
||||
stream: true,
|
||||
};
|
||||
if (input.negativePrompt) body.negative_prompt = input.negativePrompt;
|
||||
if (input.guidanceScale && input.guidanceScale !== 7) body.guidance_scale = input.guidanceScale;
|
||||
if (input.aspectRatio && input.aspectRatio !== 'original') body.aspect_ratio = input.aspectRatio;
|
||||
|
||||
return {
|
||||
endpoint: input.apiUrl,
|
||||
body,
|
||||
requestCount,
|
||||
requestSize,
|
||||
logFields: {
|
||||
adapter: 'generic-json',
|
||||
size: body.size,
|
||||
n: body.n,
|
||||
output_format: body.output_format,
|
||||
quality: body.quality,
|
||||
aspect_ratio: body.aspect_ratio,
|
||||
stream: body.stream,
|
||||
guidance_scale: body.guidance_scale,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
33
src/lib/image-api-templates/index.ts
Normal file
33
src/lib/image-api-templates/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { genericJsonImageTemplate } from './generic-json';
|
||||
import { openAICompatibleImageTemplate } from './openai-compatible';
|
||||
import type { ImageApiConfigForTemplate, ImageApiTemplate } from './types';
|
||||
|
||||
const imageApiTemplates: ImageApiTemplate[] = [
|
||||
openAICompatibleImageTemplate,
|
||||
genericJsonImageTemplate,
|
||||
];
|
||||
|
||||
export function resolveImageApiTemplate(config: ImageApiConfigForTemplate): ImageApiTemplate {
|
||||
return imageApiTemplates.find(template => template.matches(config)) || genericJsonImageTemplate;
|
||||
}
|
||||
|
||||
export {
|
||||
genericJsonImageTemplate,
|
||||
openAICompatibleImageTemplate,
|
||||
};
|
||||
export {
|
||||
isDallE2Model,
|
||||
isDallE3Model,
|
||||
isGptImageModel,
|
||||
isOpenAICompatibleImageApi,
|
||||
normalizeOpenAICompatibleImageCount,
|
||||
normalizeOpenAICompatibleImageSize,
|
||||
} from './openai-compatible';
|
||||
export type {
|
||||
ImageApiConfigForTemplate,
|
||||
ImageApiTemplate,
|
||||
ImageOutputFormat,
|
||||
ImageQuality,
|
||||
TextToImageTemplateInput,
|
||||
TextToImageTemplateResult,
|
||||
} from './types';
|
||||
119
src/lib/image-api-templates/openai-compatible.ts
Normal file
119
src/lib/image-api-templates/openai-compatible.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { ImageApiConfigForTemplate, ImageApiTemplate, TextToImageTemplateInput } from './types';
|
||||
|
||||
export function isGptImageModel(modelName: string | undefined): boolean {
|
||||
return /^gpt-image-/i.test((modelName || '').trim());
|
||||
}
|
||||
|
||||
export function isDallE3Model(modelName: string | undefined): boolean {
|
||||
return /^dall-e-3$/i.test((modelName || '').trim());
|
||||
}
|
||||
|
||||
export function isDallE2Model(modelName: string | undefined): boolean {
|
||||
return /^dall-e-2$/i.test((modelName || '').trim());
|
||||
}
|
||||
|
||||
function normalizeGptImageSize(size: string | undefined): string | undefined {
|
||||
if (!size) return undefined;
|
||||
const normalized = size.trim().toLowerCase();
|
||||
if (['auto', '1024x1024', '1536x1024', '1024x1536'].includes(normalized)) return normalized;
|
||||
const parsed = normalized.match(/^(\d{2,5})x(\d{2,5})$/i);
|
||||
if (!parsed) return undefined;
|
||||
const width = Number(parsed[1]);
|
||||
const height = Number(parsed[2]);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return undefined;
|
||||
const ratio = width / height;
|
||||
if (ratio > 1.12) return '1536x1024';
|
||||
if (ratio < 0.89) return '1024x1536';
|
||||
return '1024x1024';
|
||||
}
|
||||
|
||||
function normalizeDallE3Size(size: string | undefined): string {
|
||||
const normalized = (size || '').trim().toLowerCase();
|
||||
if (['1024x1024', '1792x1024', '1024x1792'].includes(normalized)) return normalized;
|
||||
const parsed = normalized.match(/^(\d{2,5})x(\d{2,5})$/i);
|
||||
if (!parsed) return '1024x1024';
|
||||
const width = Number(parsed[1]);
|
||||
const height = Number(parsed[2]);
|
||||
const ratio = width / height;
|
||||
if (ratio > 1.2) return '1792x1024';
|
||||
if (ratio < 0.84) return '1024x1792';
|
||||
return '1024x1024';
|
||||
}
|
||||
|
||||
function normalizeDallE2Size(size: string | undefined): string {
|
||||
const normalized = (size || '').trim().toLowerCase();
|
||||
if (['256x256', '512x512', '1024x1024'].includes(normalized)) return normalized;
|
||||
return '1024x1024';
|
||||
}
|
||||
|
||||
export function normalizeOpenAICompatibleImageSize(modelName: string | undefined, size: string | undefined): string {
|
||||
if (isDallE3Model(modelName)) return normalizeDallE3Size(size);
|
||||
if (isDallE2Model(modelName)) return normalizeDallE2Size(size);
|
||||
return normalizeGptImageSize(size) || 'auto';
|
||||
}
|
||||
|
||||
export function normalizeOpenAICompatibleImageCount(modelName: string | undefined, count: number): number {
|
||||
if (isDallE3Model(modelName)) return 1;
|
||||
return Math.min(10, Math.max(1, Math.floor(count)));
|
||||
}
|
||||
|
||||
export function isOpenAICompatibleImageApi(config: ImageApiConfigForTemplate): boolean {
|
||||
const provider = (config.provider || '').trim().toLowerCase();
|
||||
const apiUrl = (config.apiUrl || '').trim().toLowerCase();
|
||||
return provider === 'newapi'
|
||||
|| provider === 'new api'
|
||||
|| provider === 'openai'
|
||||
|| provider === 'openai-compatible'
|
||||
|| isGptImageModel(config.modelName)
|
||||
|| isDallE3Model(config.modelName)
|
||||
|| isDallE2Model(config.modelName)
|
||||
|| /\/v1\/images\/(generations|edits)\b/i.test(apiUrl);
|
||||
}
|
||||
|
||||
function applyOpenAICompatibleExtras(body: Record<string, unknown>, input: TextToImageTemplateInput) {
|
||||
if (isGptImageModel(input.modelName)) {
|
||||
body.output_format = input.outputFormat;
|
||||
body.quality = input.imageQuality;
|
||||
body.stream = true;
|
||||
} else if (isDallE3Model(input.modelName)) {
|
||||
body.quality = input.imageQuality === 'high' ? 'hd' : 'standard';
|
||||
if (input.style === 'natural' || input.style === 'vivid') body.style = input.style;
|
||||
}
|
||||
if (typeof input.user === 'string' && input.user.trim()) body.user = input.user.trim();
|
||||
}
|
||||
|
||||
export const openAICompatibleImageTemplate: ImageApiTemplate = {
|
||||
id: 'openai-compatible',
|
||||
label: 'OpenAI/NewAPI compatible image generation',
|
||||
matches: isOpenAICompatibleImageApi,
|
||||
buildTextToImageRequest(input) {
|
||||
const requestCount = normalizeOpenAICompatibleImageCount(input.modelName, input.count);
|
||||
const requestSize = normalizeOpenAICompatibleImageSize(input.modelName, input.size);
|
||||
const prompt = input.negativePrompt
|
||||
? `${input.prompt}\n\nNegative prompt: ${input.negativePrompt}`
|
||||
: input.prompt;
|
||||
const body: Record<string, unknown> = {
|
||||
model: input.modelName,
|
||||
prompt,
|
||||
n: requestCount,
|
||||
size: requestSize,
|
||||
};
|
||||
applyOpenAICompatibleExtras(body, input);
|
||||
return {
|
||||
endpoint: input.apiUrl,
|
||||
body,
|
||||
requestCount,
|
||||
requestSize,
|
||||
logFields: {
|
||||
adapter: 'openai-compatible',
|
||||
size: body.size,
|
||||
n: body.n,
|
||||
output_format: body.output_format,
|
||||
quality: body.quality,
|
||||
aspect_ratio: body.aspect_ratio,
|
||||
stream: body.stream,
|
||||
guidance_scale: body.guidance_scale,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
38
src/lib/image-api-templates/types.ts
Normal file
38
src/lib/image-api-templates/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export type ImageOutputFormat = 'png' | 'jpeg' | 'webp';
|
||||
export type ImageQuality = 'auto' | 'high' | 'medium' | 'low';
|
||||
|
||||
export type ImageApiConfigForTemplate = {
|
||||
provider?: string;
|
||||
apiUrl?: string;
|
||||
modelName?: string;
|
||||
};
|
||||
|
||||
export type TextToImageTemplateInput = {
|
||||
apiUrl: string;
|
||||
modelName: string;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
aspectRatio?: string;
|
||||
size?: string;
|
||||
count: number;
|
||||
outputFormat: ImageOutputFormat;
|
||||
imageQuality: ImageQuality;
|
||||
guidanceScale?: number;
|
||||
style?: unknown;
|
||||
user?: unknown;
|
||||
};
|
||||
|
||||
export type TextToImageTemplateResult = {
|
||||
endpoint: string;
|
||||
body: Record<string, unknown>;
|
||||
requestCount: number;
|
||||
requestSize: string | undefined;
|
||||
logFields: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ImageApiTemplate = {
|
||||
id: string;
|
||||
label: string;
|
||||
matches: (config: ImageApiConfigForTemplate) => boolean;
|
||||
buildTextToImageRequest: (input: TextToImageTemplateInput) => TextToImageTemplateResult;
|
||||
};
|
||||
Reference in New Issue
Block a user