import { appLogger } from '@/lib/app-logger'; import type { ChannelCapacityLease } from '@/lib/channel-capacity-queue'; import { isStreamingChannelRequestMode, resolveChannelRequestMode, type ChannelRequestMode } from '@/lib/channel-request-mode'; import { type ChannelCredential, resolveEffectiveCredential } from '@/lib/channel-router'; import { RequestValidationError, assertSafeApiOverride, readCount, readMode, readModel, readPlainHttpApiBaseUrlAllowlist, readRequiredText, readStorageMode, validateApiBaseUrl } from '@/lib/image-request-utils'; import { handleEditImageMode, handleGenerateImageMode } from '@/lib/image-route-mode-handlers'; import { assertResponsesImageBackendAllowed, attachAccessCookie, describeInvalidImagesResponse, ensureOutputDirExists, inspectInvalidImagesResponse, inspectUpstreamError, readClientRequestId, reportServerCredentialFailure, resolveRequestActualCostSafely, type AccessCookie, type RequestLogContext, type UpstreamResponseDiagnostics } from '@/lib/image-route-support'; import { InvalidOpenAiImagesResponseError, MissingOpenAiImageDataError, persistedImageToLegacyResponse, persistOpenAiImages } from '@/lib/image-service'; import { mergeUpstreamHeadersWithFixed, readImageUpstreamProfile, type PartialImagesCount } from '@/lib/image-upstream-profile'; import { readImageGenerationBackend, readImageStreamMode, readImageStreamingStrategy, resolveImageStreamEnabled, type ImageGenerationBackend, type ImageStreamMode, type ImageStreamingStrategy } from '@/lib/image-upstream-strategy'; import { createOpenAIImageClientOptions } from '@/lib/openai-image-transport'; import { PAGE_PASSWORD_AUTH_ERROR_CODES } from '@/lib/page-password-auth'; import { getServerChannelState } from '@/lib/server-channel-router'; import { buildAccessCookie, readAffinityKey, verifyPasswordHash } from '@/lib/server-runtime'; import type { StreamingAvailabilityKey, StreamingOperation } from '@/lib/streaming-availability'; import crypto from 'crypto'; import { NextRequest, NextResponse } from 'next/server'; import OpenAI from 'openai'; type StreamResolutionInput = { streamMode: ImageStreamMode; imageBackend: ImageGenerationBackend; streamingStrategy: ImageStreamingStrategy; operation: StreamingOperation; selectedCredential?: ChannelCredential; sourceId?: string; forceNonStream?: boolean; }; type StreamResolution = { availabilityKey: StreamingAvailabilityKey; streamEnabled: boolean; streamFallbackEnabled: boolean; streamingMarkedUnavailable: boolean; }; type ChannelRequestModePlan = { preferred: ChannelRequestMode; fallback?: ChannelRequestMode; candidates: readonly ChannelRequestMode[]; }; type PageChannelSelection = { selectedCredential?: ChannelCredential; requestMode: ChannelRequestMode; forcedNonStream: boolean; fallbackApplied: boolean; }; function readErrorStatus(error: unknown): number | undefined { if (typeof error !== 'object' || error === null) return undefined; if ('status' in error && typeof error.status === 'number') return error.status; if ('statusCode' in error && typeof error.statusCode === 'number') return error.statusCode; return undefined; } function readErrorCode(error: unknown): string | undefined { if (typeof error !== 'object' || error === null) return undefined; if ('code' in error && typeof error.code === 'string') return error.code; if ('error' in error && typeof error.error === 'object' && error.error !== null) { const nested = error.error as Record; return typeof nested.code === 'string' ? nested.code : undefined; } return undefined; } function toPartialImagesCount(value: number): PartialImagesCount { if (value === 0 || value === 1 || value === 2 || value === 3 || value === 4) return value; throw new RequestValidationError('partial_images 必须在 0 到 4 之间。'); } function createAvailabilityKey(input: StreamResolutionInput): StreamingAvailabilityKey { return { channelId: input.selectedCredential?.channelId, sourceId: input.selectedCredential ? undefined : input.sourceId, imageBackend: input.imageBackend, streamingStrategy: input.streamingStrategy, operation: input.operation }; } function createAvailabilitySourceId(input: { selectedCredential?: ChannelCredential; baseUrl?: string; }): string | undefined { if (input.selectedCredential) return undefined; const normalized = normalizeAvailabilityBaseUrl(input.baseUrl); const digest = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 16); return `upstream:${digest}`; } function normalizeAvailabilityBaseUrl(baseUrl: string | undefined): string { const rawValue = baseUrl && baseUrl.trim() ? baseUrl.trim() : 'https://api.openai.com/v1'; try { const parsed = new URL(rawValue); const pathname = parsed.pathname.replace(/\/+$/, '') || '/'; return `${parsed.protocol}//${parsed.host}${pathname}`.toLowerCase(); } catch { return 'invalid-upstream'; } } function resolvePageStream(input: StreamResolutionInput): StreamResolution { const availabilityKey = createAvailabilityKey(input); const streamingAvailability = getServerChannelState().streamingAvailability; if (input.forceNonStream) { return { availabilityKey, streamEnabled: false, streamFallbackEnabled: false, streamingMarkedUnavailable: streamingAvailability.isUnavailable(availabilityKey) }; } if (input.streamMode === 'non_stream') { return { availabilityKey, streamEnabled: false, streamFallbackEnabled: false, streamingMarkedUnavailable: streamingAvailability.isUnavailable(availabilityKey) }; } if (input.streamMode === 'auto' && input.streamingStrategy === 'off') { return { availabilityKey, streamEnabled: false, streamFallbackEnabled: false, streamingMarkedUnavailable: streamingAvailability.isUnavailable(availabilityKey) }; } if (input.streamMode === 'auto' && streamingAvailability.isUnavailable(availabilityKey)) { return { availabilityKey, streamEnabled: false, streamFallbackEnabled: false, streamingMarkedUnavailable: true }; } return { availabilityKey, streamEnabled: resolveImageStreamEnabled({ imageBackend: input.imageBackend, requestedStream: true, streamingStrategy: input.streamingStrategy }), streamFallbackEnabled: input.streamMode === 'auto', streamingMarkedUnavailable: false }; } function resolvePageChannelRequestModePlan(input: { streamMode: ImageStreamMode; imageBackend: ImageGenerationBackend; streamingStrategy: ImageStreamingStrategy; }): ChannelRequestModePlan { if (input.streamMode === 'non_stream') { const preferred = resolveChannelRequestMode({ imageBackend: input.imageBackend, streamEnabled: false }); return { preferred, candidates: [preferred] }; } if (input.streamMode === 'auto' && input.streamingStrategy === 'off') { const preferred = resolveChannelRequestMode({ imageBackend: input.imageBackend, streamEnabled: false }); return { preferred, candidates: [preferred] }; } const preferred = resolveChannelRequestMode({ imageBackend: input.imageBackend, streamEnabled: resolveImageStreamEnabled({ imageBackend: input.imageBackend, requestedStream: true, streamingStrategy: input.streamingStrategy }) }); if (input.streamMode !== 'auto' || !isStreamingChannelRequestMode(preferred)) { return { preferred, candidates: [preferred] }; } if (input.streamingStrategy !== 'auto') { return { preferred, candidates: [preferred] }; } const fallback = resolveChannelRequestMode({ imageBackend: input.imageBackend, streamEnabled: false }); return { preferred: fallback, fallback: preferred, candidates: [fallback, preferred] }; } function selectPageServerCredential(input: { router: NonNullable['router']>; affinityKey: string; plan: ChannelRequestModePlan; }): PageChannelSelection { if (input.plan.candidates.length > 1) { const selection = input.router.selectWithRequestModes({ affinityKey: input.affinityKey, requestModes: input.plan.candidates }); return { selectedCredential: selection.credential, requestMode: selection.requestMode, forcedNonStream: !isStreamingChannelRequestMode(selection.requestMode), fallbackApplied: selection.requestMode !== selection.preferredRequestMode }; } try { return { selectedCredential: input.router.select({ affinityKey: input.affinityKey, requestMode: input.plan.preferred }), requestMode: input.plan.preferred, forcedNonStream: !isStreamingChannelRequestMode(input.plan.preferred), fallbackApplied: false }; } catch (error) { if (!input.plan.fallback || !(error instanceof RequestValidationError)) { throw error; } return { selectedCredential: input.router.select({ affinityKey: input.affinityKey, requestMode: input.plan.fallback }), requestMode: input.plan.fallback, forcedNonStream: true, fallbackApplied: true }; } } function markStreamingUnavailable(input: { key: StreamingAvailabilityKey; error?: unknown; reason: string; status?: number; }) { const status = input.status ?? readErrorStatus(input.error); getServerChannelState().streamingAvailability.markUnavailable({ ...input.key, reason: input.reason, ...(status !== undefined ? { status } : {}), ...(readErrorCode(input.error) ? { code: readErrorCode(input.error) } : {}) }); } function appendChannelQueueHeaders(response: Response, lease: ChannelCapacityLease | undefined): Response { if (!lease) return response; const headers = new Headers(response.headers); headers.set('X-Channel-Queue-Wait-Ms', String(lease.waitMs)); headers.set('X-Channel-Queue-Queued', lease.queued ? 'true' : 'false'); headers.set('X-Channel-Queue-Capacity', String(lease.capacity)); return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); } function releaseChannelLeaseAfterResponse(response: Response, lease: ChannelCapacityLease): Response { if (!response.body) { const responseWithHeaders = appendChannelQueueHeaders(response, lease); lease.release(); return responseWithHeaders; } const reader = response.body.getReader(); const body = new ReadableStream({ async pull(controller) { try { const result = await reader.read(); if (result.done) { lease.release(); controller.close(); return; } controller.enqueue(result.value); } catch (error) { lease.release(); controller.error(error); } }, async cancel(reason) { try { await reader.cancel(reason); } finally { lease.release(); } } }); return appendChannelQueueHeaders( new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers }), lease ); } function reportPersistDiagnosticsFailure(input: { credential?: ChannelCredential; diagnostics: UpstreamResponseDiagnostics; requestMode?: ChannelRequestMode; }) { if (input.diagnostics.category !== 'responses_disabled') return; reportServerCredentialFailure( input.credential, { status: 403, error: { message: 'Image generation is not enabled for this group' } }, input.requestMode ); } function readBooleanAlias(formData: FormData, ...fields: string[]): boolean | undefined { for (const field of fields) { const value = formData.get(field); if (value === null || value === '') continue; if (typeof value !== 'string') { throw new RequestValidationError(`${field} 必须是 true 或 false。`); } if (value === 'true') return true; if (value === 'false') return false; throw new RequestValidationError(`${field} 必须是 true 或 false。`); } return undefined; } export async function POST(request: NextRequest) { let selectedServerCredential: ChannelCredential | undefined; let selectedServerRequestMode: ChannelRequestMode | undefined; let channelLease: ChannelCapacityLease | undefined; let clientRequestId: string | undefined; let requestLogContext: RequestLogContext | undefined; let accessCookie: AccessCookie | undefined; try { const serverChannelState = getServerChannelState(); const serverChannelRouter = serverChannelState.router; const contentType = request.headers.get('content-type') || ''; if ( !contentType.includes('multipart/form-data') && !contentType.includes('application/x-www-form-urlencoded') ) { return NextResponse.json({ error: '请求正文无效:必须是 multipart/form-data。' }, { status: 400 }); } const formData = await request.formData(); clientRequestId = readClientRequestId(formData); const upstreamIdempotencyKey = clientRequestId || crypto.randomUUID(); requestLogContext = clientRequestId ? { clientRequestId } : undefined; const requestApiKey = String(formData.get('apiKey') || '').trim(); const requestApiBaseUrl = String(formData.get('apiBaseUrl') || '').trim(); const allowedPlainHttpBaseUrls = readPlainHttpApiBaseUrlAllowlist( process.env.OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS ); assertSafeApiOverride(requestApiKey, requestApiBaseUrl); validateApiBaseUrl(requestApiBaseUrl, { allowedPlainHttpBaseUrls }); const streamMode = readImageStreamMode(formData, process.env); const imageBackend = readImageGenerationBackend(formData, process.env); const streamingStrategy = readImageStreamingStrategy(formData, process.env); const requestModePlan = resolvePageChannelRequestModePlan({ streamMode, imageBackend, streamingStrategy }); const channelSelection = requestApiKey || !serverChannelRouter ? { selectedCredential: undefined, requestMode: requestModePlan.preferred, forcedNonStream: !isStreamingChannelRequestMode(requestModePlan.preferred), fallbackApplied: false } : selectPageServerCredential({ router: serverChannelRouter, affinityKey: readAffinityKey(request.headers), plan: requestModePlan }); selectedServerCredential = channelSelection.selectedCredential; selectedServerRequestMode = channelSelection.requestMode; const { apiKey: effectiveApiKey, baseUrl: effectiveApiBaseUrl, upstreamProxyUrl: effectiveUpstreamProxyUrl, upstreamProfile: effectiveUpstreamProfileId, providerProfile, upstreamHeaders, selectedCredential } = resolveEffectiveCredential({ requestApiKey, requestApiBaseUrl, legacyBaseUrl: process.env.OPENAI_API_BASE_URL, legacyUpstreamProxyUrl: process.env.OPENAI_UPSTREAM_PROXY_URL, selectedCredential: selectedServerCredential }); validateApiBaseUrl(effectiveApiBaseUrl || '', { allowedPlainHttpBaseUrls }); if (!effectiveApiKey) { appLogger.error('未设置 OPENAI_API_KEY,且请求未提供 API Key。', requestLogContext); return NextResponse.json( { error: '请在 API 设置中填写 API Key,或配置 OPENAI_API_KEY 环境变量。' }, { status: 400 } ); } if (selectedCredential) { appLogger.info( `已选择 API 渠道:${selectedCredential.channelId},凭证:${selectedCredential.id},策略:server`, { ...requestLogContext, channelRequestMode: channelSelection.requestMode, channelRequestModeFallbackApplied: channelSelection.fallbackApplied } ); } const openai = new OpenAI( createOpenAIImageClientOptions({ apiKey: effectiveApiKey, baseURL: effectiveApiBaseUrl || undefined, upstreamProxyUrl: effectiveUpstreamProxyUrl, defaultHeaders: mergeUpstreamHeadersWithFixed(upstreamHeaders, {}) }) ); const upstreamProfile = providerProfile || readImageUpstreamProfile({ explicitProfile: effectiveUpstreamProfileId, channelId: selectedCredential?.channelId, baseUrl: effectiveApiBaseUrl }); const effectiveStorageMode = readStorageMode(process.env); appLogger.info(`实际图片存储模式:${effectiveStorageMode}`, requestLogContext); if (effectiveStorageMode === 'fs') { await ensureOutputDirExists(); } const appPassword = process.env.APP_PASSWORD?.trim(); if (appPassword) { const clientPasswordHash = formData.get('passwordHash'); if (typeof clientPasswordHash !== 'string' || !clientPasswordHash) { appLogger.error('缺少访问码哈希。', requestLogContext); return NextResponse.json( { error: '未授权:缺少访问码哈希。', code: PAGE_PASSWORD_AUTH_ERROR_CODES.missing }, { status: 401 } ); } if (!verifyPasswordHash(clientPasswordHash, appPassword)) { appLogger.error('访问码哈希无效。', requestLogContext); return NextResponse.json( { error: '未授权:访问码无效。', code: PAGE_PASSWORD_AUTH_ERROR_CODES.invalid }, { status: 401 } ); } accessCookie = buildAccessCookie(appPassword, request.headers); } const mode = readMode(formData); const prompt = readRequiredText(formData, 'prompt'); const model = readModel(formData); const forceRequest = readBooleanAlias(formData, 'force_request', 'forceRequest') === true; const upstreamStartedAtMs = Date.now(); appLogger.info(`开始处理图片请求。模式:${mode},模型:${model}`, requestLogContext); appLogger.debug( `模式:${mode},模型:${model},提示词:${prompt ? prompt.substring(0, 50) + '...' : 'N/A'}`, requestLogContext ); const partialImagesCount = toPartialImagesCount( readCount( formData, 'partial_images', 2, upstreamProfile.partialImages.min, upstreamProfile.partialImages.max ) ); const streamResolution = resolvePageStream({ streamMode, imageBackend, streamingStrategy, operation: mode, selectedCredential, sourceId: createAvailabilitySourceId({ selectedCredential, baseUrl: effectiveApiBaseUrl }), forceNonStream: channelSelection.forcedNonStream }); assertResponsesImageBackendAllowed({ imageBackend, mode }); appLogger.info('图片上游兼容策略。', { ...requestLogContext, imageBackend, streamingStrategy, streamMode, streamEnabled: streamResolution.streamEnabled, streamFallbackEnabled: streamResolution.streamFallbackEnabled, streamingMarkedUnavailable: streamResolution.streamingMarkedUnavailable, channelRequestMode: channelSelection.requestMode, channelRequestModeFallbackApplied: channelSelection.fallbackApplied, upstreamProfile: upstreamProfile.id, upstreamExtraHeaders: Boolean(upstreamHeaders) }); if (selectedCredential) { channelLease = await serverChannelState.channelCapacityQueue.acquire(selectedCredential.id, { signal: request.signal }); appLogger.info('渠道凭证并发容量已获取。', { ...requestLogContext, channelId: selectedCredential.channelId, credentialId: selectedCredential.id, queued: channelLease.queued, waitMs: channelLease.waitMs, queueCapacity: channelLease.capacity, queuedCount: channelLease.queuedCount }); } const modeResult = mode === 'generate' ? await handleGenerateImageMode({ formData, openai, model, prompt, streamEnabled: streamResolution.streamEnabled, partialImagesCount, upstreamProfile, upstreamHeaders, imageBackend, storageMode: effectiveStorageMode, apiBaseUrl: effectiveApiBaseUrl, apiKey: effectiveApiKey, upstreamProxyUrl: effectiveUpstreamProxyUrl, startedAtMs: upstreamStartedAtMs, upstreamIdempotencyKey, clientRequestId, requestLogContext, selectedCredential, channelRequestMode: channelSelection.requestMode, accessCookie, forceRequest, abortSignal: request.signal, streamFallbackEnabled: streamResolution.streamFallbackEnabled, onStreamUnavailable: (error, reason) => markStreamingUnavailable({ key: streamResolution.availabilityKey, error, reason }), onStreamingDegraded: (reason) => markStreamingUnavailable({ key: streamResolution.availabilityKey, reason, status: 200 }) }) : await handleEditImageMode({ formData, openai, model, prompt, streamEnabled: streamResolution.streamEnabled, partialImagesCount, upstreamProfile, upstreamHeaders, imageBackend, storageMode: effectiveStorageMode, apiBaseUrl: effectiveApiBaseUrl, apiKey: effectiveApiKey, upstreamProxyUrl: effectiveUpstreamProxyUrl, startedAtMs: upstreamStartedAtMs, upstreamIdempotencyKey, clientRequestId, requestLogContext, selectedCredential, channelRequestMode: channelSelection.requestMode, accessCookie, forceRequest, abortSignal: request.signal, streamFallbackEnabled: streamResolution.streamFallbackEnabled, onStreamUnavailable: (error, reason) => markStreamingUnavailable({ key: streamResolution.availabilityKey, error, reason }), onStreamingDegraded: (reason) => markStreamingUnavailable({ key: streamResolution.availabilityKey, reason, status: 200 }) }); if (modeResult instanceof Response) { if (!channelLease) return modeResult; const response = releaseChannelLeaseAfterResponse(modeResult, channelLease); channelLease = undefined; return response; } const { result, outputFormat: responseOutputFormat } = modeResult; appLogger.info('OpenAI API 调用成功。', requestLogContext); try { const savedImages = await persistOpenAiImages({ result, outputFormat: responseOutputFormat, storageMode: effectiveStorageMode, includeBase64: true, normalizeOutputFormat: true, apiBaseUrl: effectiveApiBaseUrl, apiKey: effectiveApiKey, upstreamProxyUrl: effectiveUpstreamProxyUrl, upstreamHeaders, abortSignal: request.signal }); const savedImagesData = savedImages.map((image) => ({ ...persistedImageToLegacyResponse(image), ...(clientRequestId ? { clientRequestId } : {}) })); const actualCost = await resolveRequestActualCostSafely({ apiBaseUrl: effectiveApiBaseUrl, apiKey: effectiveApiKey, upstreamProxyUrl: effectiveUpstreamProxyUrl, model, startedAtMs: upstreamStartedAtMs, expectedImageCount: savedImagesData.length, requestLogContext }); appLogger.info(`所有图片已处理。模式:${effectiveStorageMode}`, { ...requestLogContext, filenames: savedImagesData.map((image) => image.filename) }); const response = attachAccessCookie( NextResponse.json({ images: savedImagesData, usage: result.usage, actualCost, clientRequestId }), accessCookie ); const responseWithHeaders = appendChannelQueueHeaders(response, channelLease); channelLease?.release(); channelLease = undefined; return responseWithHeaders; } catch (persistError) { if (persistError instanceof MissingOpenAiImageDataError) { const invalidResult = persistError.result; const diagnostics = inspectInvalidImagesResponse(invalidResult); appLogger.error(`第 ${persistError.index} 个图片数据缺少 b64_json。`, { upstreamDiagnostics: diagnostics, ...requestLogContext }); reportPersistDiagnosticsFailure({ credential: selectedCredential, diagnostics, requestMode: selectedServerRequestMode }); const response = NextResponse.json( { error: describeInvalidImagesResponse(invalidResult), diagnostics }, { status: 502 } ); const responseWithHeaders = appendChannelQueueHeaders(response, channelLease); channelLease?.release(); channelLease = undefined; return responseWithHeaders; } if (!(persistError instanceof InvalidOpenAiImagesResponseError)) { throw persistError; } const invalidResult: unknown = persistError.result; const diagnostics = inspectInvalidImagesResponse(invalidResult); appLogger.error('OpenAI API 返回的数据无效或为空:', { type: typeof invalidResult, upstreamDiagnostics: diagnostics, ...requestLogContext }); reportPersistDiagnosticsFailure({ credential: selectedCredential, diagnostics, requestMode: selectedServerRequestMode }); const response = NextResponse.json( { error: describeInvalidImagesResponse(invalidResult), diagnostics }, { status: 502 } ); const responseWithHeaders = appendChannelQueueHeaders(response, channelLease); channelLease?.release(); channelLease = undefined; return responseWithHeaders; } } catch (error: unknown) { channelLease?.release(); channelLease = undefined; reportServerCredentialFailure(selectedServerCredential, error, selectedServerRequestMode); const upstreamDiagnostics = inspectUpstreamError(error); appLogger.error('/api/images 处理失败:', { ...requestLogContext, error: error instanceof Error ? error.message : String(error), ...(upstreamDiagnostics ? { upstreamDiagnostics } : {}) }); let errorMessage = '发生未知错误。'; let status = 500; if (error instanceof RequestValidationError) { errorMessage = error.message; status = error.status; } else if (error instanceof Error) { errorMessage = error.message; if (errorMessage.includes('