Route image API requests through templates

This commit is contained in:
Codex
2026-05-13 02:30:45 +00:00
parent ae6fd626b1
commit 8430b771e1
5 changed files with 271 additions and 114 deletions

View File

@@ -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,

View 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,
},
};
},
};

View 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';

View 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,
},
};
},
};

View 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;
};