Spaces:
Running
Running
| import { | |
| type AgentGenerateRequest, | |
| type AgentImageResponse, | |
| type AgentImageResponseExecution, | |
| type AgentImageResponseItem, | |
| type AgentResponseMode, | |
| validateAgentGenerateRequest | |
| } from './agent-api-contracts'; | |
| import { AGENT_ENDPOINTS } from './agent-api-paths.mjs'; | |
| import { | |
| buildAgentChannelRequestModeDecision, | |
| createAgentChannelRequestModePlan, | |
| selectAgentChannelCredential, | |
| type AgentChannelRequestModePlan | |
| } from './agent-channel-request-mode'; | |
| import { assertArtifactFilepathAllowed, deleteArtifactFileIfAllowed } from './agent-file-utils'; | |
| import { | |
| artifactRecordToResponseItem, | |
| computeRetryAfterSeconds, | |
| createArtifactId, | |
| hashAgentPayload, | |
| hashText, | |
| isoDate, | |
| type AgentArtifactRecord, | |
| type AgentRequestRecord, | |
| type BeginAgentRequestResult, | |
| type AgentStateStore | |
| } from './agent-state-store'; | |
| import { | |
| AgentApiError, | |
| normalizeAgentError, | |
| storedAgentErrorResponse, | |
| type AgentErrorBody | |
| } from './api-error-response'; | |
| import type { AgentErrorDiagnostics } from './api-error-response'; | |
| import { appLogger } from './app-logger'; | |
| import type { ChannelCapacityLease } from './channel-capacity-queue'; | |
| import { | |
| isStreamingChannelRequestMode, | |
| type ChannelRequestMode, | |
| type ChannelRequestModeDecision | |
| } from './channel-request-mode'; | |
| import { | |
| type ChannelCredential, | |
| type ChannelFailureReport, | |
| describeChannelFailure, | |
| isChannelFailure, | |
| isChannelRequestModeFailure, | |
| isCredentialFailure, | |
| resolveEffectiveCredential | |
| } from './channel-router'; | |
| import { | |
| RequestValidationError, | |
| assertMaskCompatibility, | |
| assertImageFilesPresent, | |
| readCount, | |
| readEditQuality, | |
| readImageFiles, | |
| readMaskFile, | |
| readModel, | |
| readPlainHttpApiBaseUrlAllowlist, | |
| readRequiredText, | |
| readSize, | |
| validateApiBaseUrl, | |
| type GptImageModel, | |
| type ValidOutputFormat | |
| } from './image-request-utils'; | |
| import { | |
| InvalidOpenAiImagesResponseError, | |
| MissingOpenAiImageDataError, | |
| readAcceptedImageTaskDetails, | |
| resolveAcceptedImageTaskResponse, | |
| type AcceptedImageTaskResponseError, | |
| persistOpenAiImages as persistSharedOpenAiImages | |
| } from './image-service'; | |
| import { collectOpenAiImagesFromStream } from './image-stream-collector'; | |
| import { | |
| readImageUpstreamProfile, | |
| mergeUpstreamHeadersWithFixed, | |
| summarizeUpstreamRequestHeaders, | |
| type ImageUpstreamProfile, | |
| type PartialImagesCount, | |
| type UpstreamRequestHeaders | |
| } from './image-upstream-profile'; | |
| import { | |
| parseImageStreamModeValue, | |
| parseImageStreamingStrategyValue, | |
| resolveImageStreamEnabled, | |
| type ImageStreamMode, | |
| type ImageStreamingStrategy | |
| } from './image-upstream-strategy'; | |
| import { createImagesApiGenerateStream } from './images-api-stream'; | |
| import { buildOpenAIImageRequestOptions, createOpenAIImageClientOptions } from './openai-image-transport'; | |
| import { | |
| createResponsesImageStream, | |
| generateImageWithResponsesBackend, | |
| type ResponsesImageGenerateInput | |
| } from './responses-image-backend'; | |
| import { getServerChannelState } from './server-channel-router'; | |
| import { readBooleanEnv } from './server-runtime'; | |
| import type { StreamingAvailabilityKey } from './streaming-availability'; | |
| import crypto from 'crypto'; | |
| import fs from 'fs/promises'; | |
| import { NextResponse } from 'next/server'; | |
| import OpenAI from 'openai'; | |
| export type AgentRequestExecutionResult = { | |
| response: AgentImageResponse; | |
| stateResponse: AgentImageResponse; | |
| artifacts: AgentArtifactRecord[]; | |
| }; | |
| type CredentialContext = { | |
| openai: OpenAI; | |
| selectedCredential?: ChannelCredential; | |
| channelRequestMode?: ChannelRequestMode; | |
| channelRequestModeFallbackApplied: boolean; | |
| channelRequestModeDecision: ChannelRequestModeDecision; | |
| baseUrl?: string; | |
| apiKey: string; | |
| upstreamProxyUrl?: string; | |
| upstreamProfile: ImageUpstreamProfile; | |
| upstreamHeaders?: UpstreamRequestHeaders; | |
| }; | |
| export type AgentGeneratePreparation = { | |
| credentialContext: CredentialContext; | |
| }; | |
| export type AgentEditPreparation = { | |
| credentialContext: CredentialContext; | |
| prompt: string; | |
| model: GptImageModel; | |
| n: number; | |
| size: OpenAI.Images.ImageEditParams['size']; | |
| quality: OpenAI.Images.ImageEditParams['quality']; | |
| responseMode: AgentResponseMode; | |
| streamRequest: AgentEditStreamRequest; | |
| imageFiles: File[]; | |
| maskFile?: File; | |
| }; | |
| type AgentStreamOptions = { | |
| mode: 'generate' | 'edit'; | |
| imageBackend: AgentGenerateRequest['image_backend']; | |
| streamMode: ImageStreamMode; | |
| streamingStrategy: ImageStreamingStrategy; | |
| partialImages: PartialImagesCount; | |
| selectedCredential?: ChannelCredential; | |
| channelRequestMode?: ChannelRequestMode; | |
| }; | |
| type AgentEditStreamRequest = { | |
| streamMode: ImageStreamMode; | |
| streamingStrategy: ImageStreamingStrategy; | |
| partialImages: PartialImagesCount; | |
| }; | |
| type AgentExecutionTransportContext = Pick<AgentImageResponseExecution, 'transport' | 'endpoint' | 'route_mode'>; | |
| type AgentExecutionMetadata = { | |
| startedAtMs: number; | |
| startedAt: string; | |
| transport: AgentExecutionTransportContext; | |
| operation: AgentImageResponseExecution['operation']; | |
| imageBackend: AgentImageResponseExecution['image_backend']; | |
| streamMode: ImageStreamMode; | |
| streamingStrategy: ImageStreamingStrategy; | |
| channelRequestMode?: ChannelRequestMode; | |
| channelRequestModeFallbackApplied: boolean; | |
| channelRequestModeDecision: ChannelRequestModeDecision; | |
| selectedCredential?: ChannelCredential; | |
| }; | |
| const AGENT_EDIT_UNSUPPORTED_FIELDS = [ | |
| 'image_backend', | |
| 'imageBackend', | |
| 'output_format', | |
| 'outputFormat', | |
| 'format', | |
| 'output_compression', | |
| 'outputCompression', | |
| 'responses_model', | |
| 'responsesModel', | |
| 'image_streaming_strategy', | |
| 'imageStreamingStrategy', | |
| 'background', | |
| 'moderation' | |
| ] as const; | |
| const AGENT_EDIT_OUTPUT_FORMAT = 'webp' satisfies ValidOutputFormat; | |
| const HTTP_HEADER_VALUE_CONTROL_CHAR_PATTERN = /[\u0000-\u001f\u007f]/; | |
| type ExistingAgentRequestResult = Exclude<BeginAgentRequestResult, { type: 'acquired' }>; | |
| export function readIdempotencyKey(headers: Headers): string { | |
| const value = headers.get('idempotency-key')?.trim(); | |
| if (!value) { | |
| throw new AgentApiError({ | |
| code: 'idempotency_key_required', | |
| message: 'Agent 图片请求必须提供 Idempotency-Key header。', | |
| status: 400, | |
| retryable: false | |
| }); | |
| } | |
| if (value.length > 200) { | |
| throw new AgentApiError({ | |
| code: 'validation_error', | |
| message: 'Idempotency-Key is too long.', | |
| status: 422, | |
| retryable: false, | |
| details: { fields: { 'Idempotency-Key': '长度不能超过 200 个字符' } } | |
| }); | |
| } | |
| if (HTTP_HEADER_VALUE_CONTROL_CHAR_PATTERN.test(value)) { | |
| throw new AgentApiError({ | |
| code: 'validation_error', | |
| message: 'Idempotency-Key 不能包含控制字符。', | |
| status: 422, | |
| retryable: false, | |
| details: { fields: { 'Idempotency-Key': '不能包含控制字符' } } | |
| }); | |
| } | |
| return value; | |
| } | |
| export function buildGenerateRequestHash(request: AgentGenerateRequest): string { | |
| return hashAgentPayload({ mode: 'generate', request }); | |
| } | |
| export function resolveExistingAgentRequest( | |
| record: AgentRequestRecord | undefined, | |
| requestHash: string, | |
| now = new Date() | |
| ): ExistingAgentRequestResult | undefined { | |
| if (!record) return undefined; | |
| if (record.requestHash !== requestHash) return { type: 'conflict', record }; | |
| if (record.status === 'succeeded' && record.responseJson) { | |
| return { type: 'replay', record, response: record.responseJson }; | |
| } | |
| if (record.status === 'failed' && record.errorJson) { | |
| return { type: 'failed', record, error: record.errorJson }; | |
| } | |
| const isActive = record.status === 'running' || record.status === 'pending'; | |
| if (isActive && record.lockedUntil && record.lockedUntil > isoDate(now)) { | |
| return { type: 'in_progress', record, retryAfterSeconds: computeRetryAfterSeconds(record.lockedUntil, now) }; | |
| } | |
| return undefined; | |
| } | |
| export function agentBeginResultResponse( | |
| beginResult: ExistingAgentRequestResult, | |
| store: AgentStateStore | |
| ): Promise<NextResponse>; | |
| export function agentBeginResultResponse( | |
| beginResult: BeginAgentRequestResult | undefined, | |
| store: AgentStateStore | |
| ): Promise<NextResponse | undefined>; | |
| export async function agentBeginResultResponse( | |
| beginResult: BeginAgentRequestResult | undefined, | |
| store: AgentStateStore | |
| ): Promise<NextResponse | undefined> { | |
| if (!beginResult) return undefined; | |
| if (beginResult.type === 'acquired') return undefined; | |
| if (beginResult.type === 'replay') { | |
| const response = await hydrateAgentReplayResponse(store, beginResult.record, beginResult.response); | |
| return NextResponse.json(response, { | |
| headers: { 'X-Idempotent-Replay': 'true', 'X-Request-Id': beginResult.record.requestId } | |
| }); | |
| } | |
| if (beginResult.type === 'failed') { | |
| return storedAgentErrorResponse(beginResult.error, { | |
| 'X-Idempotent-Replay': 'true', | |
| 'X-Request-Id': beginResult.record.requestId | |
| }); | |
| } | |
| if (beginResult.type === 'conflict') { | |
| throw new AgentApiError({ | |
| code: 'idempotency_conflict', | |
| message: 'Idempotency-Key 已被不同请求正文使用。', | |
| status: 409, | |
| retryable: false | |
| }); | |
| } | |
| throw new AgentApiError({ | |
| code: 'request_in_progress', | |
| message: '使用该 Idempotency-Key 的请求仍在运行。', | |
| status: 409, | |
| retryable: true, | |
| retryAfterSeconds: beginResult.retryAfterSeconds | |
| }); | |
| } | |
| export function prepareAgentGenerate(request: AgentGenerateRequest, headers: Headers): AgentGeneratePreparation { | |
| const credentialContext = createOpenAiClient(headers, resolveAgentGenerateChannelRequestModePlan(request)); | |
| validateAgentGenerateAgainstUpstreamProfile(request, credentialContext.upstreamProfile, { | |
| forceRequest: request.force_request === true | |
| }); | |
| return { credentialContext }; | |
| } | |
| function resolveAgentGenerateChannelRequestModePlan(request: AgentGenerateRequest): AgentChannelRequestModePlan { | |
| return createAgentChannelRequestModePlan({ | |
| imageBackend: request.image_backend, | |
| streamMode: request.stream_mode, | |
| streamingStrategy: request.streaming_strategy | |
| }); | |
| } | |
| function resolveAgentEditChannelRequestModePlan(formData: FormData): AgentChannelRequestModePlan { | |
| return createAgentChannelRequestModePlan({ | |
| imageBackend: 'images-api', | |
| streamMode: readAgentEditStreamMode(formData), | |
| streamingStrategy: readAgentEditStreamingStrategy(formData) | |
| }); | |
| } | |
| export async function prepareAgentEdit(formData: FormData, headers: Headers): Promise<AgentEditPreparation> { | |
| const prompt = readRequiredText(formData, 'prompt'); | |
| const model = readModel(formData); | |
| const forceRequest = readAgentEditForceRequest(formData); | |
| assertImageFilesPresent(formData); | |
| const credentialContext = createOpenAiClient(headers, resolveAgentEditChannelRequestModePlan(formData)); | |
| const n = readCount( | |
| formData, | |
| 'n', | |
| 1, | |
| credentialContext.upstreamProfile.editCount.min, | |
| credentialContext.upstreamProfile.editCount.max | |
| ); | |
| const size = readSize(formData, 'size', 'auto', model, credentialContext.upstreamProfile, { | |
| forceRequest | |
| }) as OpenAI.Images.ImageEditParams['size']; | |
| const quality = readEditQuality(formData) as OpenAI.Images.ImageEditParams['quality']; | |
| const responseMode = readAgentResponseModeFromForm(formData); | |
| const streamRequest = readAgentEditStreamRequest(formData, credentialContext.upstreamProfile); | |
| const imageFiles = readImageFiles(formData, credentialContext.upstreamProfile); | |
| const maskFile = readMaskFile(formData, credentialContext.upstreamProfile); | |
| await assertMaskCompatibility(maskFile, imageFiles); | |
| return { credentialContext, prompt, model, n, size, quality, responseMode, streamRequest, imageFiles, maskFile }; | |
| } | |
| function readAgentEditForceRequest(formData: FormData): boolean { | |
| const value = formData.get('force_request') ?? formData.get('forceRequest'); | |
| if (value === null || value === '') return false; | |
| if (typeof value !== 'string') { | |
| throw new RequestValidationError('force_request 必须是 true 或 false。'); | |
| } | |
| if (value === 'true') return true; | |
| if (value === 'false') return false; | |
| throw new RequestValidationError('force_request 必须是 true 或 false。'); | |
| } | |
| function buildOpenAiRequestOptions( | |
| context: CredentialContext, | |
| input: { abortSignal?: AbortSignal; idempotencyKey: string } | |
| ): OpenAI.RequestOptions { | |
| return buildOpenAIImageRequestOptions({ | |
| abortSignal: input.abortSignal, | |
| idempotencyKey: input.idempotencyKey, | |
| headers: mergeUpstreamHeadersWithFixed(context.upstreamHeaders, {}) | |
| }); | |
| } | |
| function onAgentAcceptedImageTask(context: CredentialContext, modeLabel: string, idempotencyKey: string) { | |
| return (details: AcceptedImageTaskResponseError, attempt: number, delayMs: number) => { | |
| appLogger.warn(`Agent ${modeLabel} 上游返回异步图片任务,等待后重试同步结果。`, { | |
| idempotencyKey, | |
| channelId: context.selectedCredential?.channelId, | |
| requestMode: context.channelRequestMode, | |
| attempt, | |
| delayMs, | |
| taskId: details.taskId, | |
| hasPollUrl: Boolean(details.pollUrl) | |
| }); | |
| }; | |
| } | |
| async function acquireAgentChannelCapacity( | |
| context: CredentialContext, | |
| abortSignal?: AbortSignal | |
| ): Promise<ChannelCapacityLease | undefined> { | |
| if (!context.selectedCredential) return undefined; | |
| const lease = await getServerChannelState().channelCapacityQueue.acquire(context.selectedCredential.id, { | |
| signal: abortSignal | |
| }); | |
| appLogger.info('Agent 渠道凭证并发容量已获取。', { | |
| channelId: context.selectedCredential.channelId, | |
| credentialId: context.selectedCredential.id, | |
| queued: lease.queued, | |
| waitMs: lease.waitMs, | |
| queueCapacity: lease.capacity, | |
| queuedCount: lease.queuedCount | |
| }); | |
| return lease; | |
| } | |
| export async function buildEditRequestHash(formData: FormData): Promise<string> { | |
| return buildEditRequestHashFromSnapshot(await snapshotAgentEditFormData(formData)); | |
| } | |
| export function buildEditRequestHashFromSnapshot(snapshot: Record<string, unknown>): string { | |
| return hashAgentPayload(snapshot); | |
| } | |
| export async function snapshotAgentEditFormData(formData: FormData): Promise<Record<string, unknown>> { | |
| const fields: Record<string, unknown> = {}; | |
| const fileFields: Array<Promise<{ key: string; name: string; size: number; type: string; sha256: string }>> = []; | |
| for (const [key, value] of formData.entries()) { | |
| if (value instanceof File) { | |
| fileFields.push(snapshotFileField(key, value)); | |
| } else { | |
| fields[key] = value; | |
| } | |
| } | |
| const files = await Promise.all(fileFields); | |
| return { mode: 'edit', fields, files: files.sort((a, b) => a.key.localeCompare(b.key)) }; | |
| } | |
| async function snapshotFileField( | |
| key: string, | |
| file: File | |
| ): Promise<{ key: string; name: string; size: number; type: string; sha256: string }> { | |
| const buffer = Buffer.from(await file.arrayBuffer()); | |
| return { | |
| key, | |
| name: file.name, | |
| size: file.size, | |
| type: file.type, | |
| sha256: crypto.createHash('sha256').update(buffer).digest('hex') | |
| }; | |
| } | |
| export async function executeAgentGenerate(options: { | |
| request: AgentGenerateRequest; | |
| headers: Headers; | |
| requestId: string; | |
| idempotencyKey: string; | |
| cached: boolean; | |
| preparation?: AgentGeneratePreparation; | |
| transport?: AgentExecutionTransportContext; | |
| abortSignal?: AbortSignal; | |
| }): Promise<AgentRequestExecutionResult> { | |
| const credentialContext = | |
| options.preparation?.credentialContext ?? | |
| prepareAgentGenerate(options.request, options.headers).credentialContext; | |
| const startedAtMs = Date.now(); | |
| const startedAt = isoDate(new Date(startedAtMs)); | |
| let channelLease: ChannelCapacityLease | undefined; | |
| try { | |
| channelLease = await acquireAgentChannelCapacity(credentialContext, options.abortSignal); | |
| const result = await executeAgentGenerateUpstream( | |
| options.request, | |
| credentialContext, | |
| options.idempotencyKey, | |
| options.abortSignal | |
| ); | |
| channelLease?.release(); | |
| channelLease = undefined; | |
| return await persistOpenAiImages({ | |
| result, | |
| mode: 'generate', | |
| model: options.request.model, | |
| prompt: options.request.prompt, | |
| outputFormat: options.request.output_format, | |
| responseMode: options.request.response_mode, | |
| normalizeOutputFormat: true, | |
| requestId: options.requestId, | |
| idempotencyKey: options.idempotencyKey, | |
| cached: options.cached, | |
| apiBaseUrl: credentialContext.baseUrl, | |
| apiKey: credentialContext.apiKey, | |
| upstreamProxyUrl: credentialContext.upstreamProxyUrl, | |
| upstreamHeaders: credentialContext.upstreamHeaders, | |
| execution: { | |
| startedAtMs, | |
| startedAt, | |
| transport: options.transport ?? { | |
| transport: 'agent_json', | |
| endpoint: AGENT_ENDPOINTS.generate, | |
| route_mode: 'agent' | |
| }, | |
| operation: 'generate', | |
| imageBackend: options.request.image_backend, | |
| streamMode: options.request.stream_mode, | |
| streamingStrategy: options.request.streaming_strategy, | |
| channelRequestMode: credentialContext.channelRequestMode, | |
| channelRequestModeFallbackApplied: credentialContext.channelRequestModeFallbackApplied, | |
| channelRequestModeDecision: credentialContext.channelRequestModeDecision, | |
| selectedCredential: credentialContext.selectedCredential | |
| }, | |
| abortSignal: options.abortSignal | |
| }); | |
| } catch (error) { | |
| const failureReport = reportServerCredentialFailure( | |
| credentialContext.selectedCredential, | |
| error, | |
| credentialContext.channelRequestMode | |
| ); | |
| throw normalizeAgentError(error, buildAgentExecutionDiagnostics(credentialContext, startedAtMs, failureReport)); | |
| } finally { | |
| channelLease?.release(); | |
| } | |
| } | |
| async function executeAgentGenerateUpstream( | |
| request: AgentGenerateRequest, | |
| credentialContext: CredentialContext, | |
| idempotencyKey: string, | |
| abortSignal?: AbortSignal | |
| ): Promise<OpenAI.Images.ImagesResponse> { | |
| const { openai } = credentialContext; | |
| const streamOptions: AgentStreamOptions = { | |
| mode: 'generate', | |
| imageBackend: request.image_backend, | |
| streamMode: request.stream_mode, | |
| streamingStrategy: request.streaming_strategy, | |
| partialImages: request.partial_images, | |
| selectedCredential: credentialContext.selectedCredential, | |
| channelRequestMode: credentialContext.channelRequestMode | |
| }; | |
| if (request.image_backend === 'responses-image-generation') { | |
| return executeAgentResponsesGenerate(request, credentialContext, idempotencyKey, abortSignal); | |
| } | |
| const baseParams = { | |
| model: request.model, | |
| prompt: request.prompt, | |
| n: request.n, | |
| size: request.size as OpenAI.Images.ImageGenerateParams['size'], | |
| quality: request.quality as OpenAI.Images.ImageGenerateParams['quality'], | |
| output_format: request.output_format, | |
| background: request.background as OpenAI.Images.ImageGenerateParams['background'], | |
| moderation: request.moderation as OpenAI.Images.ImageGenerateParams['moderation'], | |
| ...(request.output_compression !== undefined ? { output_compression: request.output_compression } : {}), | |
| ...(request.force_web !== undefined ? { force_web: request.force_web } : {}) | |
| }; | |
| if (!shouldUseAgentUpstreamStream(streamOptions)) { | |
| return resolveAcceptedImageTaskResponse( | |
| () => | |
| openai.images | |
| .generate( | |
| { ...baseParams, stream: false }, | |
| buildOpenAiRequestOptions(credentialContext, { abortSignal, idempotencyKey }) | |
| ) | |
| .withResponse(), | |
| { abortSignal, onAcceptedTask: onAgentAcceptedImageTask(credentialContext, 'generate', idempotencyKey) } | |
| ); | |
| } | |
| const fallback = () => | |
| resolveAcceptedImageTaskResponse( | |
| () => | |
| openai.images | |
| .generate( | |
| { ...baseParams, stream: false }, | |
| buildOpenAiRequestOptions(credentialContext, { abortSignal, idempotencyKey }) | |
| ) | |
| .withResponse(), | |
| { abortSignal, onAcceptedTask: onAgentAcceptedImageTask(credentialContext, 'generate', idempotencyKey) } | |
| ); | |
| try { | |
| const stream = await createImagesApiGenerateStream({ | |
| apiBaseUrl: credentialContext.baseUrl, | |
| apiKey: credentialContext.apiKey, | |
| upstreamProxyUrl: credentialContext.upstreamProxyUrl, | |
| upstreamHeaders: credentialContext.upstreamHeaders, | |
| idempotencyKey, | |
| abortSignal, | |
| params: { | |
| ...baseParams, | |
| stream: true, | |
| partial_images: request.partial_images | |
| } | |
| }); | |
| return await collectOpenAiImagesFromStream(stream, { | |
| apiBaseUrl: credentialContext.baseUrl, | |
| apiKey: credentialContext.apiKey, | |
| upstreamProxyUrl: credentialContext.upstreamProxyUrl, | |
| upstreamHeaders: credentialContext.upstreamHeaders, | |
| abortSignal, | |
| onStreamingDegraded: (reason) => markAgentStreamingUnavailable(streamOptions, reason, 200) | |
| }); | |
| } catch (error) { | |
| if (request.stream_mode === 'stream' || isAbortLikeError(error, abortSignal)) throw error; | |
| markAgentStreamingUnavailable(streamOptions, 'stream_error_without_final_image', undefined, error); | |
| return fallback(); | |
| } | |
| } | |
| function validateAgentGenerateAgainstUpstreamProfile( | |
| request: AgentGenerateRequest, | |
| upstreamProfile: ImageUpstreamProfile, | |
| options: { forceRequest?: boolean } = {} | |
| ): void { | |
| if (request.n < upstreamProfile.generateCount.min || request.n > upstreamProfile.generateCount.max) { | |
| throw new RequestValidationError( | |
| `n 必须在 ${upstreamProfile.generateCount.min} 到 ${upstreamProfile.generateCount.max} 之间。`, | |
| 422 | |
| ); | |
| } | |
| if ( | |
| request.partial_images < upstreamProfile.partialImages.min || | |
| request.partial_images > upstreamProfile.partialImages.max | |
| ) { | |
| throw new RequestValidationError( | |
| `partial_images 必须在 ${upstreamProfile.partialImages.min} 到 ${upstreamProfile.partialImages.max} 之间。`, | |
| 422 | |
| ); | |
| } | |
| if (options.forceRequest) return; | |
| if ( | |
| request.model === 'gpt-image-2' && | |
| request.background === 'transparent' && | |
| !upstreamProfile.gptImage2.allowTransparentBackground | |
| ) { | |
| throw new RequestValidationError('gpt-image-2 不支持 transparent 背景。', 422); | |
| } | |
| if (request.model !== 'gpt-image-2' || request.size === 'auto') return; | |
| const formData = new FormData(); | |
| formData.set('size', request.size); | |
| readSize(formData, 'size', '1024x1024', request.model, upstreamProfile); | |
| } | |
| async function executeAgentResponsesGenerate( | |
| request: AgentGenerateRequest, | |
| credentialContext: CredentialContext, | |
| idempotencyKey: string, | |
| abortSignal?: AbortSignal | |
| ): Promise<OpenAI.Images.ImagesResponse> { | |
| const { openai } = credentialContext; | |
| if (!readBooleanEnv(process.env, 'ENABLE_RESPONSES_IMAGE_BACKEND')) { | |
| throw new RequestValidationError( | |
| 'Responses API 图片后端仍是实验能力,必须设置 ENABLE_RESPONSES_IMAGE_BACKEND=true 后才能使用。', | |
| 400 | |
| ); | |
| } | |
| if (request.n !== 1) { | |
| throw new RequestValidationError('Responses API 图片后端当前只支持单张生成。', 400); | |
| } | |
| const input: ResponsesImageGenerateInput = { | |
| responses: openai.responses, | |
| prompt: request.prompt, | |
| responsesModel: readAgentResponsesApiModel(request.responsesModel), | |
| imageModel: request.model, | |
| size: readAgentResponsesImageSize(request.size), | |
| quality: request.quality, | |
| outputFormat: request.output_format, | |
| background: request.background, | |
| moderation: request.moderation, | |
| idempotencyKey, | |
| abortSignal, | |
| ...(request.output_compression !== undefined ? { outputCompression: request.output_compression } : {}), | |
| ...(request.promptOptimization !== undefined ? { promptOptimization: request.promptOptimization } : {}), | |
| ...(request.thinking ? { thinking: request.thinking } : {}) | |
| }; | |
| const streamOptions: AgentStreamOptions = { | |
| mode: 'generate', | |
| imageBackend: request.image_backend, | |
| streamMode: request.stream_mode, | |
| streamingStrategy: request.streaming_strategy, | |
| partialImages: request.partial_images, | |
| selectedCredential: credentialContext.selectedCredential, | |
| channelRequestMode: credentialContext.channelRequestMode | |
| }; | |
| if (!shouldUseAgentUpstreamStream(streamOptions)) { | |
| return generateImageWithResponsesBackend(input); | |
| } | |
| const responsesPartialImages = readAgentResponsesPartialImagesCount(request.partial_images); | |
| try { | |
| return await collectOpenAiImagesFromStream( | |
| await createResponsesImageStream({ ...input, partialImagesCount: responsesPartialImages }), | |
| { | |
| apiBaseUrl: credentialContext.baseUrl, | |
| apiKey: credentialContext.apiKey, | |
| upstreamProxyUrl: credentialContext.upstreamProxyUrl, | |
| upstreamHeaders: credentialContext.upstreamHeaders, | |
| abortSignal, | |
| onStreamingDegraded: (reason) => markAgentStreamingUnavailable(streamOptions, reason, 200) | |
| } | |
| ); | |
| } catch (error) { | |
| if (request.stream_mode === 'stream' || isAbortLikeError(error, abortSignal)) throw error; | |
| markAgentStreamingUnavailable(streamOptions, 'stream_error_without_final_image', undefined, error); | |
| return generateImageWithResponsesBackend(input); | |
| } | |
| } | |
| function readAgentResponsesPartialImagesCount(value: PartialImagesCount): 1 | 2 | 3 { | |
| if (value === 1 || value === 2 || value === 3) return value; | |
| throw new RequestValidationError('Responses API 图片后端的 partial_images 必须在 1 到 3 之间。', 400); | |
| } | |
| function shouldUseAgentUpstreamStream(input: AgentStreamOptions): boolean { | |
| const key = createAgentStreamingAvailabilityKey(input); | |
| const availability = getServerChannelState().streamingAvailability; | |
| if (input.streamMode === 'non_stream') return false; | |
| if (input.channelRequestMode && !isStreamingChannelRequestMode(input.channelRequestMode)) return false; | |
| if (input.streamMode === 'auto' && availability.isUnavailable(key)) return false; | |
| return resolveImageStreamEnabled({ | |
| imageBackend: input.imageBackend, | |
| requestedStream: true, | |
| streamingStrategy: input.streamingStrategy | |
| }); | |
| } | |
| function createAgentStreamingAvailabilityKey(input: AgentStreamOptions): StreamingAvailabilityKey { | |
| return { | |
| channelId: input.selectedCredential?.channelId, | |
| imageBackend: input.imageBackend, | |
| streamingStrategy: input.streamingStrategy, | |
| operation: input.mode | |
| }; | |
| } | |
| 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; | |
| return undefined; | |
| } | |
| function markAgentStreamingUnavailable( | |
| input: AgentStreamOptions, | |
| reason: string, | |
| status?: number, | |
| error?: unknown | |
| ): void { | |
| const finalStatus = status ?? readErrorStatus(error); | |
| getServerChannelState().streamingAvailability.markUnavailable({ | |
| ...createAgentStreamingAvailabilityKey(input), | |
| reason, | |
| ...(finalStatus !== undefined ? { status: finalStatus } : {}), | |
| ...(readErrorCode(error) ? { code: readErrorCode(error) } : {}) | |
| }); | |
| } | |
| function isAbortLikeError(error: unknown, abortSignal?: AbortSignal): boolean { | |
| if (abortSignal?.aborted) return true; | |
| if (typeof error !== 'object' || error === null) return false; | |
| const name = 'name' in error ? error.name : undefined; | |
| return name === 'AbortError' || name === 'CanceledError'; | |
| } | |
| function readAgentResponsesApiModel(requestModel?: string): string { | |
| const model = requestModel?.trim() || process.env.OPENAI_RESPONSES_API_MODEL?.trim(); | |
| if (!model) { | |
| throw new RequestValidationError( | |
| 'Responses API 图片后端必须配置 OPENAI_RESPONSES_API_MODEL 或请求字段 responsesModel,作为 /responses 顶层模型。', | |
| 500 | |
| ); | |
| } | |
| if (model.length > 128) { | |
| throw new RequestValidationError('Responses API 顶层模型名称不能超过 128 个字符。', 400); | |
| } | |
| return model; | |
| } | |
| function readAgentResponsesImageSize(size: string): ResponsesImageGenerateInput['size'] { | |
| return size; | |
| } | |
| export async function executeAgentEdit(options: { | |
| formData: FormData; | |
| headers: Headers; | |
| requestId: string; | |
| idempotencyKey: string; | |
| cached: boolean; | |
| preparation?: AgentEditPreparation; | |
| transport?: AgentExecutionTransportContext; | |
| abortSignal?: AbortSignal; | |
| }): Promise<AgentRequestExecutionResult> { | |
| let credentialContext: CredentialContext | undefined; | |
| const startedAtMs = Date.now(); | |
| const startedAt = isoDate(new Date(startedAtMs)); | |
| let channelLease: ChannelCapacityLease | undefined; | |
| try { | |
| const preparation = options.preparation ?? (await prepareAgentEdit(options.formData, options.headers)); | |
| credentialContext = preparation.credentialContext; | |
| // Keep a const alias so TypeScript preserves the narrowed credential context below. | |
| const activeCredentialContext = credentialContext; | |
| const editParams: OpenAI.Images.ImageEditParamsNonStreaming = { | |
| model: preparation.model, | |
| prompt: preparation.prompt, | |
| image: preparation.imageFiles, | |
| n: preparation.n, | |
| size: preparation.size === 'auto' ? undefined : preparation.size, | |
| quality: preparation.quality === 'auto' ? undefined : preparation.quality, | |
| ...(preparation.maskFile ? { mask: preparation.maskFile } : {}) | |
| }; | |
| const streamOptions: AgentStreamOptions = { | |
| mode: 'edit', | |
| imageBackend: 'images-api', | |
| streamMode: preparation.streamRequest.streamMode, | |
| streamingStrategy: preparation.streamRequest.streamingStrategy, | |
| partialImages: preparation.streamRequest.partialImages, | |
| selectedCredential: activeCredentialContext.selectedCredential, | |
| channelRequestMode: activeCredentialContext.channelRequestMode | |
| }; | |
| channelLease = await acquireAgentChannelCapacity(activeCredentialContext, options.abortSignal); | |
| const result = shouldUseAgentUpstreamStream(streamOptions) | |
| ? await executeAgentEditStream({ | |
| credentialContext: activeCredentialContext, | |
| params: editParams, | |
| streamOptions, | |
| idempotencyKey: options.idempotencyKey, | |
| abortSignal: options.abortSignal | |
| }) | |
| : await resolveAcceptedImageTaskResponse( | |
| () => | |
| activeCredentialContext.openai.images | |
| .edit( | |
| editParams, | |
| buildOpenAiRequestOptions(activeCredentialContext, { | |
| abortSignal: options.abortSignal, | |
| idempotencyKey: options.idempotencyKey | |
| }) | |
| ) | |
| .withResponse(), | |
| { | |
| abortSignal: options.abortSignal, | |
| onAcceptedTask: onAgentAcceptedImageTask(activeCredentialContext, 'edit', options.idempotencyKey) | |
| } | |
| ); | |
| channelLease?.release(); | |
| channelLease = undefined; | |
| return await persistOpenAiImages({ | |
| result, | |
| mode: 'edit', | |
| model: preparation.model, | |
| prompt: preparation.prompt, | |
| outputFormat: AGENT_EDIT_OUTPUT_FORMAT, | |
| responseMode: preparation.responseMode, | |
| normalizeOutputFormat: true, | |
| requestId: options.requestId, | |
| idempotencyKey: options.idempotencyKey, | |
| cached: options.cached, | |
| apiBaseUrl: activeCredentialContext.baseUrl, | |
| apiKey: activeCredentialContext.apiKey, | |
| upstreamProxyUrl: activeCredentialContext.upstreamProxyUrl, | |
| upstreamHeaders: activeCredentialContext.upstreamHeaders, | |
| execution: { | |
| startedAtMs, | |
| startedAt, | |
| transport: options.transport ?? { | |
| transport: 'agent_json', | |
| endpoint: AGENT_ENDPOINTS.edit, | |
| route_mode: 'agent' | |
| }, | |
| operation: 'edit', | |
| imageBackend: 'images-api', | |
| streamMode: preparation.streamRequest.streamMode, | |
| streamingStrategy: preparation.streamRequest.streamingStrategy, | |
| channelRequestMode: activeCredentialContext.channelRequestMode, | |
| channelRequestModeFallbackApplied: activeCredentialContext.channelRequestModeFallbackApplied, | |
| channelRequestModeDecision: activeCredentialContext.channelRequestModeDecision, | |
| selectedCredential: activeCredentialContext.selectedCredential | |
| }, | |
| abortSignal: options.abortSignal | |
| }); | |
| } catch (error) { | |
| const failureReport = reportServerCredentialFailure( | |
| credentialContext?.selectedCredential, | |
| error, | |
| credentialContext?.channelRequestMode | |
| ); | |
| throw normalizeAgentError(error, buildAgentExecutionDiagnostics(credentialContext, startedAtMs, failureReport)); | |
| } finally { | |
| channelLease?.release(); | |
| } | |
| } | |
| async function executeAgentEditStream(input: { | |
| credentialContext: CredentialContext; | |
| params: OpenAI.Images.ImageEditParamsNonStreaming; | |
| streamOptions: AgentStreamOptions; | |
| idempotencyKey: string; | |
| abortSignal?: AbortSignal; | |
| }): Promise<OpenAI.Images.ImagesResponse> { | |
| const requestOptions = buildOpenAiRequestOptions(input.credentialContext, { | |
| abortSignal: input.abortSignal, | |
| idempotencyKey: input.idempotencyKey | |
| }); | |
| const fallback = () => | |
| resolveAcceptedImageTaskResponse( | |
| () => input.credentialContext.openai.images.edit(input.params, requestOptions).withResponse(), | |
| { | |
| abortSignal: input.abortSignal, | |
| onAcceptedTask: onAgentAcceptedImageTask(input.credentialContext, 'edit', input.idempotencyKey) | |
| } | |
| ); | |
| try { | |
| const stream = await input.credentialContext.openai.images.edit( | |
| { | |
| ...input.params, | |
| stream: true, | |
| partial_images: input.streamOptions.partialImages | |
| }, | |
| requestOptions | |
| ); | |
| return await collectOpenAiImagesFromStream(stream, { | |
| apiBaseUrl: input.credentialContext.baseUrl, | |
| apiKey: input.credentialContext.apiKey, | |
| upstreamProxyUrl: input.credentialContext.upstreamProxyUrl, | |
| upstreamHeaders: input.credentialContext.upstreamHeaders, | |
| abortSignal: input.abortSignal, | |
| onStreamingDegraded: (reason) => markAgentStreamingUnavailable(input.streamOptions, reason, 200) | |
| }); | |
| } catch (error) { | |
| if (input.streamOptions.streamMode === 'stream' || isAbortLikeError(error, input.abortSignal)) throw error; | |
| markAgentStreamingUnavailable(input.streamOptions, 'stream_error_without_final_image', undefined, error); | |
| return fallback(); | |
| } | |
| } | |
| export async function parseAgentGenerateRequest(request: Request): Promise<AgentGenerateRequest> { | |
| const contentType = request.headers.get('content-type') || ''; | |
| if (!contentType.includes('application/json')) { | |
| throw new RequestValidationError('Agent 生成端点要求使用 application/json。', 415); | |
| } | |
| let body: unknown; | |
| try { | |
| body = await request.json(); | |
| } catch { | |
| throw new RequestValidationError('请求正文必须是有效 JSON。', 422); | |
| } | |
| return validateAgentGenerateRequest(body); | |
| } | |
| export async function parseAgentEditFormData(request: Request): Promise<FormData> { | |
| const contentType = request.headers.get('content-type') || ''; | |
| if (!contentType.includes('multipart/form-data')) { | |
| throw new RequestValidationError('Agent 编辑端点要求使用 multipart/form-data。', 415); | |
| } | |
| let formData: FormData; | |
| try { | |
| formData = await request.formData(); | |
| } catch { | |
| throw new RequestValidationError('请求正文必须是有效的 multipart/form-data。', 422); | |
| } | |
| validateAgentEditUnsupportedFields(formData); | |
| return formData; | |
| } | |
| function validateAgentEditUnsupportedFields(formData: FormData): void { | |
| const fields: Record<string, string> = {}; | |
| for (const field of AGENT_EDIT_UNSUPPORTED_FIELDS) { | |
| if (formData.has(field)) { | |
| fields[field] = | |
| field === 'image_streaming_strategy' || field === 'imageStreamingStrategy' | |
| ? 'Agent edit 不接受页面专用字段,请使用 streaming_strategy。' | |
| : 'Agent edit 不接受该字段。'; | |
| } | |
| } | |
| if (Object.keys(fields).length > 0) { | |
| throw new RequestValidationError('Agent edit 请求包含不支持的字段。', 422, { fields }); | |
| } | |
| } | |
| export async function deleteAgentArtifactFiles(store: AgentStateStore, id: string): Promise<boolean> { | |
| const artifact = await store.getArtifact(id); | |
| if (!artifact) return false; | |
| await deleteArtifactFileIfAllowed(artifact.filepath); | |
| await store.deleteArtifact(id); | |
| await store.failRequest({ | |
| requestId: artifact.requestId, | |
| error: { | |
| error: { | |
| code: 'artifact_not_found', | |
| message: '产物已删除。', | |
| retryable: false, | |
| request_id: artifact.requestId | |
| } | |
| } | |
| }); | |
| return true; | |
| } | |
| export async function deleteAgentExecutionFiles(execution: AgentRequestExecutionResult): Promise<void> { | |
| const results = await Promise.allSettled( | |
| execution.artifacts.map((artifact) => deleteArtifactFileIfAllowed(artifact.filepath)) | |
| ); | |
| const failed = results.find((result) => result.status === 'rejected'); | |
| if (failed?.status === 'rejected') { | |
| throw failed.reason; | |
| } | |
| } | |
| export async function saveAgentExecutionArtifacts( | |
| store: AgentStateStore, | |
| execution: AgentRequestExecutionResult | |
| ): Promise<void> { | |
| await store.saveArtifacts(execution.artifacts); | |
| } | |
| export async function completeAgentExecutionState( | |
| store: AgentStateStore, | |
| execution: AgentRequestExecutionResult | |
| ): Promise<void> { | |
| await store.completeRequest({ | |
| requestId: execution.response.request_id, | |
| response: execution.stateResponse, | |
| artifacts: [] | |
| }); | |
| } | |
| export function createArtifactPersistenceError(): AgentApiError { | |
| return new AgentApiError({ | |
| code: 'unexpected_error', | |
| message: '保存产物元数据失败。', | |
| status: 500, | |
| retryable: true | |
| }); | |
| } | |
| export function createCompletionPersistenceError(): AgentApiError { | |
| return new AgentApiError({ | |
| code: 'unexpected_error', | |
| message: '保存请求完成状态失败。', | |
| status: 500, | |
| retryable: true | |
| }); | |
| } | |
| export function errorToAgentErrorBody(error: unknown, requestId: string): AgentErrorBody { | |
| const normalized = normalizeAgentError(error); | |
| return { | |
| error: { | |
| code: normalized.code, | |
| message: normalized.message, | |
| retryable: normalized.retryable, | |
| ...(normalized.details ? { details: normalized.details } : {}), | |
| ...(normalized.upstreamStatus ? { upstream_status: normalized.upstreamStatus } : {}), | |
| ...(normalized.diagnostics ? { diagnostics: normalized.diagnostics } : {}), | |
| request_id: requestId | |
| } | |
| }; | |
| } | |
| export async function hydrateAgentReplayResponse( | |
| store: AgentStateStore, | |
| record: { requestId: string; requestJson: unknown }, | |
| response: AgentImageResponse, | |
| cached = true | |
| ): Promise<AgentImageResponse> { | |
| const responseMode = readAgentResponseModeFromRequestJson(record.requestJson); | |
| if (!shouldIncludeBase64(responseMode)) { | |
| return { ...response, cached }; | |
| } | |
| const artifacts = await store.listArtifactsForRequest(record.requestId); | |
| const encodedById = new Map<string, string>(); | |
| for (const artifact of artifacts) { | |
| assertArtifactFilepathAllowed(artifact.filepath); | |
| encodedById.set(artifact.id, await readArtifactBase64(artifact.filepath)); | |
| } | |
| return { | |
| ...response, | |
| cached, | |
| images: response.images.map((image) => ({ | |
| ...image, | |
| ...(encodedById.has(image.id) ? { b64_json: encodedById.get(image.id) } : {}) | |
| })) | |
| }; | |
| } | |
| function createOpenAiClient(headers: Headers, requestModePlan: AgentChannelRequestModePlan): CredentialContext { | |
| const serverChannelRouter = getServerChannelState().router; | |
| const selection = selectAgentChannelCredential({ | |
| router: serverChannelRouter, | |
| headers, | |
| requestModePlan | |
| }); | |
| const selectedCredential = selection.selectedCredential; | |
| const { | |
| apiKey, | |
| baseUrl, | |
| upstreamProxyUrl, | |
| providerProfile, | |
| selectedCredential: effectiveSelectedCredential | |
| } = resolveEffectiveCredential({ | |
| requestApiKey: '', | |
| requestApiBaseUrl: '', | |
| legacyBaseUrl: process.env.OPENAI_API_BASE_URL, | |
| legacyUpstreamProxyUrl: process.env.OPENAI_UPSTREAM_PROXY_URL, | |
| selectedCredential | |
| }); | |
| validateApiBaseUrl(baseUrl || '', { | |
| allowedPlainHttpBaseUrls: readPlainHttpApiBaseUrlAllowlist(process.env.OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS) | |
| }); | |
| const channelRequestModeDecision = buildAgentChannelRequestModeDecision({ | |
| requestModePlan, | |
| selection, | |
| selectedCredential: effectiveSelectedCredential, | |
| upstreamHost: baseUrl ? readUrlHost(baseUrl) : undefined | |
| }); | |
| if (!apiKey) { | |
| throw new AgentApiError({ | |
| code: 'configuration_error', | |
| message: '未配置服务端 API Key。请设置 OPENAI_API_KEY 或 OPENAI_CHANNEL_N_API_KEYS。', | |
| status: 500, | |
| retryable: false, | |
| diagnostics: { | |
| route_decision: channelRequestModeDecision | |
| } | |
| }); | |
| } | |
| return { | |
| openai: new OpenAI( | |
| createOpenAIImageClientOptions({ | |
| apiKey, | |
| baseURL: baseUrl || undefined, | |
| upstreamProxyUrl, | |
| defaultHeaders: mergeUpstreamHeadersWithFixed(effectiveSelectedCredential?.upstreamHeaders, {}) | |
| }) | |
| ), | |
| selectedCredential: effectiveSelectedCredential, | |
| channelRequestMode: selection.requestMode, | |
| channelRequestModeFallbackApplied: selection.fallbackApplied, | |
| channelRequestModeDecision, | |
| baseUrl, | |
| apiKey, | |
| upstreamProxyUrl, | |
| upstreamProfile: | |
| providerProfile || | |
| readImageUpstreamProfile({ | |
| explicitProfile: effectiveSelectedCredential?.upstreamProfile, | |
| channelId: effectiveSelectedCredential?.channelId, | |
| baseUrl | |
| }), | |
| upstreamHeaders: effectiveSelectedCredential?.upstreamHeaders | |
| }; | |
| } | |
| function buildAgentExecutionDiagnostics( | |
| context: CredentialContext | undefined, | |
| startedAtMs: number, | |
| failureReport?: ChannelFailureReport | |
| ): AgentErrorDiagnostics { | |
| const upstreamHost = context?.baseUrl ? readUrlHost(context.baseUrl) : undefined; | |
| return { | |
| elapsed_ms: Date.now() - startedAtMs, | |
| ...(context?.channelRequestMode ? { channel_request_mode: context.channelRequestMode } : {}), | |
| ...(context ? { channel_request_mode_fallback_applied: context.channelRequestModeFallbackApplied } : {}), | |
| ...(context?.channelRequestModeDecision ? { route_decision: context.channelRequestModeDecision } : {}), | |
| ...(context?.selectedCredential?.channelId | |
| ? { selected_channel_id: context.selectedCredential.channelId } | |
| : {}), | |
| ...(upstreamHost ? { upstream_host: upstreamHost } : {}), | |
| ...(failureReport?.cooldownApplied | |
| ? { | |
| retry_after_ms: failureReport.retryAfterMs, | |
| cooldown_until: isoDate(new Date(failureReport.cooldownUntil)), | |
| cooldown_target: { | |
| channel_id: failureReport.target.channelId, | |
| ...(failureReport.target.credentialId | |
| ? { credential_id: failureReport.target.credentialId } | |
| : {}), | |
| ...(failureReport.target.requestMode ? { request_mode: failureReport.target.requestMode } : {}) | |
| }, | |
| channel_cooldown_scope: failureReport.scope | |
| } | |
| : {}) | |
| }; | |
| } | |
| function readUrlHost(value: string): string | undefined { | |
| try { | |
| return new URL(value).host; | |
| } catch { | |
| return undefined; | |
| } | |
| } | |
| function reportServerCredentialFailure( | |
| credential: ChannelCredential | undefined, | |
| error: unknown, | |
| requestMode?: ChannelRequestMode | |
| ): ChannelFailureReport | undefined { | |
| const serverChannelRouter = getServerChannelState().router; | |
| if (!credential || !serverChannelRouter) return undefined; | |
| if (isChannelRequestModeFailure(error, requestMode)) { | |
| const reason = { | |
| ...describeChannelFailure(error, 'channel'), | |
| ...(requestMode ? { requestMode } : {}) | |
| }; | |
| const report = serverChannelRouter.reportFailure(credential, { scope: 'channel', requestMode, reason }); | |
| appLogger.warn( | |
| report.cooldownApplied | |
| ? `Temporarily cooling down API channel request mode: ${credential.channelId}/${requestMode}` | |
| : `Recording API channel request mode failure without cooldown: ${credential.channelId}/${requestMode}`, | |
| reason | |
| ); | |
| return report; | |
| } | |
| if (isChannelFailure(error)) { | |
| const reason = { | |
| ...describeChannelFailure(error, 'channel'), | |
| ...(requestMode ? { requestMode } : {}) | |
| }; | |
| const report = serverChannelRouter.reportFailure(credential, { scope: 'channel', requestMode, reason }); | |
| appLogger.warn( | |
| report.cooldownApplied | |
| ? `Temporarily cooling down API channel: ${credential.channelId}` | |
| : `Recording API channel failure without cooldown: ${credential.channelId}`, | |
| reason | |
| ); | |
| return report; | |
| } | |
| if (isCredentialFailure(error)) { | |
| const reason = { | |
| ...describeChannelFailure(error, 'credential'), | |
| ...(requestMode ? { requestMode } : {}) | |
| }; | |
| const report = serverChannelRouter.reportFailure(credential, { requestMode, reason }); | |
| appLogger.warn( | |
| report.cooldownApplied | |
| ? `Temporarily cooling down API channel credential: ${credential.channelId}/${credential.id}` | |
| : `Recording API channel credential failure without cooldown: ${credential.channelId}/${credential.id}`, | |
| reason | |
| ); | |
| return report; | |
| } | |
| return undefined; | |
| } | |
| async function persistOpenAiImages(options: { | |
| result: OpenAI.Images.ImagesResponse; | |
| mode: 'generate' | 'edit'; | |
| model: GptImageModel; | |
| prompt: string; | |
| outputFormat: ValidOutputFormat; | |
| responseMode: AgentResponseMode; | |
| normalizeOutputFormat?: boolean; | |
| requestId: string; | |
| idempotencyKey: string; | |
| cached: boolean; | |
| apiBaseUrl?: string; | |
| apiKey?: string; | |
| upstreamProxyUrl?: string; | |
| upstreamHeaders?: UpstreamRequestHeaders; | |
| execution: AgentExecutionMetadata; | |
| abortSignal?: AbortSignal; | |
| }): Promise<AgentRequestExecutionResult> { | |
| let persistedImages; | |
| try { | |
| persistedImages = await persistSharedOpenAiImages({ | |
| result: options.result, | |
| outputFormat: options.outputFormat, | |
| storageMode: 'fs', | |
| includeBase64: shouldIncludeBase64(options.responseMode), | |
| normalizeOutputFormat: options.normalizeOutputFormat, | |
| apiBaseUrl: options.apiBaseUrl, | |
| apiKey: options.apiKey, | |
| upstreamProxyUrl: options.upstreamProxyUrl, | |
| upstreamHeaders: options.upstreamHeaders, | |
| abortSignal: options.abortSignal | |
| }); | |
| } catch (error) { | |
| if (!(error instanceof InvalidOpenAiImagesResponseError) && !(error instanceof MissingOpenAiImageDataError)) { | |
| throw error; | |
| } | |
| const acceptedTask = | |
| error instanceof InvalidOpenAiImagesResponseError ? readAcceptedImageTaskDetails(error.result) : undefined; | |
| throw new AgentApiError({ | |
| code: 'upstream_unavailable', | |
| message: | |
| error instanceof MissingOpenAiImageDataError | |
| ? `索引 ${error.index} 的图片数据缺少 base64 数据。` | |
| : acceptedTask | |
| ? '上游返回了异步图片任务元数据,而不是最终图片结果;当前渠道不兼容该请求方式。' | |
| : '上游返回了空的 Images 响应。', | |
| status: 502, | |
| retryable: !acceptedTask, | |
| upstreamStatus: 502, | |
| ...(acceptedTask ? {} : { retryAfterSeconds: 15 }) | |
| }); | |
| } | |
| const completedAtMs = Date.now(); | |
| const createdAt = isoDate(new Date(completedAtMs)); | |
| const execution = buildAgentImageExecution(options.execution, options.apiBaseUrl, options.upstreamHeaders); | |
| const timing = { | |
| started_at: options.execution.startedAt, | |
| completed_at: createdAt, | |
| elapsed_ms: Math.max(0, completedAtMs - options.execution.startedAtMs), | |
| server_elapsed_ms: Math.max(0, completedAtMs - options.execution.startedAtMs) | |
| }; | |
| const images: AgentImageResponseItem[] = []; | |
| const stateImages: AgentImageResponseItem[] = []; | |
| const artifacts: AgentArtifactRecord[] = []; | |
| const promptHash = hashText(options.prompt); | |
| for (const persistedImage of persistedImages) { | |
| const artifactId = createArtifactId(); | |
| const contentUrl = `/api/agent/artifacts/${artifactId}/content`; | |
| const metadataUrl = `/api/agent/artifacts/${artifactId}`; | |
| const artifact: AgentArtifactRecord = { | |
| id: artifactId, | |
| requestId: options.requestId, | |
| filename: persistedImage.filename, | |
| filepath: persistedImage.filepath, | |
| contentUrl, | |
| metadataUrl, | |
| outputFormat: persistedImage.outputFormat, | |
| mimeType: persistedImage.mimeType, | |
| sizeBytes: persistedImage.sizeBytes, | |
| width: persistedImage.width, | |
| height: persistedImage.height, | |
| model: options.model, | |
| promptHash, | |
| createdAt | |
| }; | |
| artifacts.push(artifact); | |
| images.push(artifactRecordToResponseItem(artifact, persistedImage.responseJson)); | |
| stateImages.push(artifactRecordToResponseItem(artifact)); | |
| } | |
| return { | |
| artifacts, | |
| response: { | |
| request_id: options.requestId, | |
| idempotency_key: options.idempotencyKey, | |
| cached: options.cached, | |
| images, | |
| usage: options.result.usage, | |
| created_at: createdAt, | |
| timing, | |
| execution | |
| }, | |
| stateResponse: { | |
| request_id: options.requestId, | |
| idempotency_key: options.idempotencyKey, | |
| cached: options.cached, | |
| images: stateImages, | |
| usage: options.result.usage, | |
| created_at: createdAt, | |
| timing, | |
| execution | |
| } | |
| }; | |
| } | |
| function buildAgentImageExecution( | |
| metadata: AgentExecutionMetadata, | |
| apiBaseUrl: string | undefined, | |
| upstreamHeaders: UpstreamRequestHeaders | undefined | |
| ): AgentImageResponseExecution { | |
| const upstreamHost = apiBaseUrl ? readUrlHost(apiBaseUrl) : undefined; | |
| return { | |
| ...metadata.transport, | |
| operation: metadata.operation, | |
| image_backend: metadata.imageBackend, | |
| stream_mode: metadata.streamMode, | |
| streaming_strategy: metadata.streamingStrategy, | |
| ...(metadata.channelRequestMode ? { channel_request_mode: metadata.channelRequestMode } : {}), | |
| channel_request_mode_fallback_applied: metadata.channelRequestModeFallbackApplied, | |
| route_decision: { | |
| ...metadata.channelRequestModeDecision, | |
| ...(upstreamHost ? { upstream_host: upstreamHost } : {}) | |
| }, | |
| ...(metadata.selectedCredential?.channelId | |
| ? { selected_channel_id: metadata.selectedCredential.channelId } | |
| : {}), | |
| ...(upstreamHost ? { upstream_host: upstreamHost } : {}), | |
| request_headers: summarizeUpstreamRequestHeaders(upstreamHeaders) | |
| }; | |
| } | |
| function shouldIncludeBase64(responseMode: AgentResponseMode): boolean { | |
| return responseMode === 'base64' || responseMode === 'both'; | |
| } | |
| function readAgentResponseModeFromForm(formData: FormData): AgentResponseMode { | |
| const value = formData.get('response_mode'); | |
| if (value === null) return 'path'; | |
| if (value === 'path' || value === 'base64' || value === 'both') return value; | |
| throw new RequestValidationError('response_mode 必须是 path、base64 或 both。', 422); | |
| } | |
| function readAgentEditStreamRequest(formData: FormData, upstreamProfile: ImageUpstreamProfile): AgentEditStreamRequest { | |
| const streamMode = readAgentEditStreamMode(formData); | |
| const streamingStrategy = readAgentEditStreamingStrategy(formData); | |
| if (streamMode !== 'non_stream') { | |
| resolveImageStreamEnabled({ | |
| imageBackend: 'images-api', | |
| requestedStream: true, | |
| streamingStrategy | |
| }); | |
| } | |
| return { | |
| streamMode, | |
| streamingStrategy, | |
| partialImages: readCount( | |
| formData, | |
| 'partial_images', | |
| 2, | |
| upstreamProfile.partialImages.min, | |
| upstreamProfile.partialImages.max | |
| ) as PartialImagesCount | |
| }; | |
| } | |
| function readAgentEditStreamMode(formData: FormData): ImageStreamMode { | |
| const value = formData.get('stream_mode'); | |
| if (value === null && formData.get('streaming_strategy') === 'off') return 'non_stream'; | |
| if (value === null) return 'auto'; | |
| if (typeof value !== 'string') { | |
| throw new RequestValidationError('stream_mode 必须是 auto、stream 或 non_stream。', 422); | |
| } | |
| try { | |
| return parseImageStreamModeValue(value); | |
| } catch (error) { | |
| throw new RequestValidationError(error instanceof Error ? error.message : 'stream_mode 无效。', 422); | |
| } | |
| } | |
| function readAgentEditStreamingStrategy(formData: FormData): ImageStreamingStrategy { | |
| const value = formData.get('streaming_strategy'); | |
| if (value === null) return 'auto'; | |
| if (typeof value !== 'string') { | |
| throw new RequestValidationError( | |
| 'streaming_strategy 必须是 off、auto、openai-sse、newapi-keepalive-sse、responses-sse 或 force-sse。', | |
| 422 | |
| ); | |
| } | |
| try { | |
| return parseImageStreamingStrategyValue(value); | |
| } catch (error) { | |
| throw new RequestValidationError(error instanceof Error ? error.message : 'streaming_strategy 无效。', 422); | |
| } | |
| } | |
| function readAgentResponseModeFromRequestJson(requestJson: unknown): AgentResponseMode { | |
| if (typeof requestJson !== 'object' || requestJson === null) return 'path'; | |
| const body = requestJson as Record<string, unknown>; | |
| const directValue = body.response_mode; | |
| if (directValue === 'path' || directValue === 'base64' || directValue === 'both') return directValue; | |
| const fields = body.fields; | |
| if (typeof fields !== 'object' || fields === null) return 'path'; | |
| const formValue = (fields as Record<string, unknown>).response_mode; | |
| return formValue === 'path' || formValue === 'base64' || formValue === 'both' ? formValue : 'path'; | |
| } | |
| async function readArtifactBase64(filepath: string): Promise<string> { | |
| const buffer = await fs.readFile(filepath); | |
| return buffer.toString('base64'); | |
| } | |