import { MAX_IMAGE_COUNT, MAX_PROMPT_LENGTH, MAX_UPLOAD_BYTES, RequestValidationError, validateApiBaseUrl, type GptImageModel, type ValidOutputFormat } from './image-request-utils'; import { GPT_IMAGE_2_EDGE_MULTIPLE, GPT_IMAGE_2_MAX_ASPECT, GPT_IMAGE_2_MAX_EDGE, GPT_IMAGE_2_MAX_PIXELS, GPT_IMAGE_2_MIN_PIXELS, validateGptImage2Size } from './size-utils'; import type { AgentErrorDiagnostics } from './api-error-response'; export const AGENT_API_VERSION = '1.0.0'; export const AGENT_SCHEMA_VERSION = '2026-05-20'; export const AGENT_DEFAULT_SQLITE_PATH = 'generated-images/.agent-state/agent.sqlite'; export const AGENT_DEFAULT_LEASE_MS = 10 * 60 * 1000; export const AGENT_DEFAULT_REQUEST_TTL_SECONDS = 24 * 60 * 60; export const AGENT_DEFAULT_RECOVERY_INTERVAL_MS = 30 * 1000; export const AGENT_MODELS = ['gpt-image-1', 'gpt-image-1-mini', 'gpt-image-1.5', 'gpt-image-2'] as const; export const AGENT_OUTPUT_FORMATS = ['png', 'jpeg', 'webp'] as const; export const AGENT_RESPONSE_MODES = ['path', 'base64', 'both'] as const; export const AGENT_QUALITIES = ['low', 'medium', 'high', 'auto'] as const; export const AGENT_BACKGROUNDS = ['transparent', 'opaque', 'auto'] as const; export const AGENT_MODERATIONS = ['low', 'auto'] as const; export const AGENT_LEGACY_SIZES = ['auto', '1024x1024', '1536x1024', '1024x1536'] as const; export const AGENT_JOB_STATES = ['queued', 'running', 'succeeded', 'failed', 'expired'] as const; export type AgentStateBackend = 'memory' | 'sqlite' | 'postgres'; export type AgentAuthScheme = 'bearer' | 'x-app-password-hash'; export type AgentResponseMode = (typeof AGENT_RESPONSE_MODES)[number]; export type AgentQuality = (typeof AGENT_QUALITIES)[number]; export type AgentBackground = (typeof AGENT_BACKGROUNDS)[number]; export type AgentModeration = (typeof AGENT_MODERATIONS)[number]; export type AgentJobState = (typeof AGENT_JOB_STATES)[number]; export type AgentGenerateRequest = { model: GptImageModel; prompt: string; n: number; size: string; quality: AgentQuality; output_format: ValidOutputFormat; output_compression?: number; background: AgentBackground; moderation: AgentModeration; response_mode: AgentResponseMode; }; export type AgentImageResponseItem = { id: string; filename: string; content_url: string; metadata_url: string; output_format: string; mime_type: string; size_bytes: number; width: number | null; height: number | null; b64_json?: string; }; export type AgentImageResponse = { request_id: string; idempotency_key: string; cached: boolean; images: AgentImageResponseItem[]; usage?: unknown; created_at: string; }; export type AgentJobStatusResponse = { job: { id: string; request_id: string; idempotency_key: string; mode: 'generate' | 'edit'; state: AgentJobState; created_at: string; updated_at: string; expires_at: string; result_url?: string; retry_after_seconds?: number; error?: { code: string; message: string; retryable: boolean; details?: Record; upstream_status?: number; diagnostics?: AgentErrorDiagnostics; }; }; }; export type AgentCapabilities = { api_version: string; schema_version: string; auth: { required: boolean; schemes: AgentAuthScheme[]; }; endpoints: Record; defaults: { model: GptImageModel; response_mode: AgentResponseMode; state_backend: AgentStateBackend; }; limits: { max_prompt_length: number; max_images: number; max_upload_mb: number; partial_images: { min: number; max: number }; }; model_limits: { 'gpt-image-2': { max_edge: number; max_pixels: number; edge_multiple: number; max_aspect: number; min_pixels: number; recommended_presets: Array<{ name: string; size: string; purpose: string }>; high_4k_risk: { applies_to: string[]; guidance: string; }; }; }; agent_streaming: { generate: { supported: false; mode: 'non_streaming_only'; endpoint: string; }; edit: { supported: false; mode: 'non_streaming_only'; endpoint: string; }; page_sse: { supported: true; mode: 'form_data_sse'; endpoint: string; contract: 'page_ui_only'; }; }; agent_jobs: { supported: true; mode: 'job_polling'; intended_for: string[]; endpoints: { create_generate_job: string; get_job: string; get_job_result: string; }; states: readonly string[]; current_guidance: string; }; supported: { models: readonly string[]; output_formats: readonly string[]; response_modes: readonly string[]; qualities: readonly string[]; backgrounds: readonly string[]; moderations: readonly string[]; legacy_sizes: readonly string[]; }; storage: { image_storage_mode: string; postgres_configured: boolean; }; idempotency: { required: boolean; header: string; ttl_seconds: number; }; }; type FieldErrors = Record; function isOneOf(value: string, allowed: T): value is T[number] { return (allowed as readonly string[]).includes(value); } function readStringField(body: Record, field: string, fallback?: string): string | undefined { const value = body[field]; if (value === undefined || value === null) return fallback; return typeof value === 'string' ? value : undefined; } function readIntegerField( body: Record, field: string, fallback: number, min: number, max: number, fields: FieldErrors ): number { const value = body[field]; if (value === undefined || value === null || value === '') return fallback; const parsed = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN; if (!Number.isInteger(parsed) || parsed < min || parsed > max) { fields[field] = `必须是 ${min} 到 ${max} 之间的整数`; return fallback; } return parsed; } function validatePrompt(value: unknown, fields: FieldErrors): string { if (typeof value !== 'string' || value.trim().length === 0) { fields.prompt = '必填'; return ''; } if (value.length > MAX_PROMPT_LENGTH) { fields.prompt = `长度不能超过 ${MAX_PROMPT_LENGTH} 个字符`; } return value; } function readModel(body: Record, fields: FieldErrors): GptImageModel { const value = readStringField(body, 'model', 'gpt-image-2'); if (!value || !isOneOf(value, AGENT_MODELS)) { fields.model = `必须是以下值之一:${AGENT_MODELS.join(', ')}`; return 'gpt-image-2'; } return value; } function readSize(body: Record, model: GptImageModel, fields: FieldErrors, fallback: string): string { const value = readStringField(body, 'size', fallback); if (!value) { fields.size = '必须是字符串'; return fallback; } if (model !== 'gpt-image-2' && !isOneOf(value, AGENT_LEGACY_SIZES)) { fields.size = `${model} 的 size 必须是以下值之一:${AGENT_LEGACY_SIZES.join(', ')}`; return fallback; } if (model === 'gpt-image-2' && value !== 'auto') { const match = /^(\d+)x(\d+)$/.exec(value); if (!match) { fields.size = '必须是 auto 或 WxH 格式的尺寸值'; return value; } const validation = validateGptImage2Size(Number(match[1]), Number(match[2])); if (!validation.valid) { fields.size = validation.reason; } } return value; } function readOutputFormat(body: Record, fields: FieldErrors): ValidOutputFormat { const rawValue = readStringField(body, 'output_format', 'png'); const normalized = rawValue?.toLowerCase() === 'jpg' ? 'jpeg' : rawValue?.toLowerCase(); if (!normalized || !isOneOf(normalized, AGENT_OUTPUT_FORMATS)) { fields.output_format = `必须是以下值之一:${AGENT_OUTPUT_FORMATS.join(', ')}`; return 'png'; } return normalized; } function readResponseMode(body: Record, fields: FieldErrors): AgentResponseMode { const value = readStringField(body, 'response_mode', 'path'); if (!value || !isOneOf(value, AGENT_RESPONSE_MODES)) { fields.response_mode = `必须是以下值之一:${AGENT_RESPONSE_MODES.join(', ')}`; return 'path'; } return value; } function readQuality(body: Record, fields: FieldErrors): AgentQuality { const value = readStringField(body, 'quality', 'high'); if (!value || !isOneOf(value, AGENT_QUALITIES)) { fields.quality = `必须是以下值之一:${AGENT_QUALITIES.join(', ')}`; return 'high'; } return value; } function readBackground(body: Record, model: GptImageModel, fields: FieldErrors): AgentBackground { const value = readStringField(body, 'background', 'auto'); if (!value || !isOneOf(value, AGENT_BACKGROUNDS)) { fields.background = `必须是以下值之一:${AGENT_BACKGROUNDS.join(', ')}`; return 'auto'; } if (model === 'gpt-image-2' && value === 'transparent') { fields.background = 'gpt-image-2 不支持 transparent 背景'; } return value; } function readModeration(body: Record, fields: FieldErrors): AgentModeration { const value = readStringField(body, 'moderation', 'auto'); if (!value || !isOneOf(value, AGENT_MODERATIONS)) { fields.moderation = `必须是以下值之一:${AGENT_MODERATIONS.join(', ')}`; return 'auto'; } return value; } function readOutputCompression( body: Record, outputFormat: ValidOutputFormat, fields: FieldErrors ): number | undefined { const value = body.output_compression; if (value === undefined || value === null || value === '') return undefined; if (outputFormat === 'png') { fields.output_compression = '仅适用于 jpeg 或 webp 输出'; return undefined; } const parsed = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN; if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) { fields.output_compression = '必须是 0 到 100 之间的整数'; return undefined; } return parsed; } export function validateAgentGenerateRequest(body: unknown): AgentGenerateRequest { if (typeof body !== 'object' || body === null || Array.isArray(body)) { throw new RequestValidationError('请求正文必须是 JSON 对象。'); } const objectBody = body as Record; const fields: FieldErrors = {}; const model = readModel(objectBody, fields); const prompt = validatePrompt(objectBody.prompt, fields); const n = readIntegerField(objectBody, 'n', 1, 1, MAX_IMAGE_COUNT, fields); const size = readSize(objectBody, model, fields, '1024x1024'); const quality = readQuality(objectBody, fields); const outputFormat = readOutputFormat(objectBody, fields); const outputCompression = readOutputCompression(objectBody, outputFormat, fields); const background = readBackground(objectBody, model, fields); const moderation = readModeration(objectBody, fields); const responseMode = readResponseMode(objectBody, fields); if (Object.keys(fields).length > 0) { throw new RequestValidationError(JSON.stringify({ fields }), 422); } return { model, prompt, n, size, quality, output_format: outputFormat, ...(outputCompression !== undefined ? { output_compression: outputCompression } : {}), background, moderation, response_mode: responseMode }; } export function readAgentStateBackend(env: Record): AgentStateBackend { const backend = env.AGENT_STATE_BACKEND?.trim().toLowerCase(); if (!backend) return 'sqlite'; if (backend === 'memory' || backend === 'sqlite' || backend === 'postgres') return backend; throw new RequestValidationError('AGENT_STATE_BACKEND 必须是 memory、sqlite 或 postgres。', 500); } export function readAgentRequestTtlSeconds(env: Record): number { return readPositiveIntegerEnv(env, 'AGENT_REQUEST_TTL_SECONDS', AGENT_DEFAULT_REQUEST_TTL_SECONDS); } export function readAgentLeaseMs(env: Record): number { return readPositiveIntegerEnv(env, 'AGENT_REQUEST_LEASE_MS', AGENT_DEFAULT_LEASE_MS); } export function readAgentRecoveryIntervalMs(env: Record): number { return readPositiveIntegerEnv(env, 'AGENT_RECOVERY_INTERVAL_MS', AGENT_DEFAULT_RECOVERY_INTERVAL_MS); } export function readAgentSqlitePath(env: Record): string { return env.AGENT_SQLITE_PATH?.trim() || AGENT_DEFAULT_SQLITE_PATH; } export function readAgentPublicBaseUrl(env: Record): string { const value = env.AGENT_PUBLIC_BASE_URL?.trim(); if (!value) return '/'; let parsed: URL; try { parsed = new URL(value); } catch { throw new RequestValidationError('AGENT_PUBLIC_BASE_URL 格式无效。', 500); } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new RequestValidationError('AGENT_PUBLIC_BASE_URL 必须是 http 或 https URL。', 500); } if (parsed.username || parsed.password || parsed.search || parsed.hash) { throw new RequestValidationError('AGENT_PUBLIC_BASE_URL 不能包含凭据、查询参数或片段。', 500); } return parsed.toString().replace(/\/$/, ''); } export function validateOptionalAgentApiBaseUrl(baseUrl: string | undefined): void { if (baseUrl) validateApiBaseUrl(baseUrl); } export function buildAgentAuthCapabilities(env: Record): AgentCapabilities['auth'] { if (env.AGENT_API_TOKEN?.trim()) { return { required: true, schemes: ['bearer'] }; } if (env.APP_PASSWORD?.trim()) { return { required: true, schemes: ['x-app-password-hash'] }; } return { required: false, schemes: [] }; } function readPositiveIntegerEnv(env: Record, fieldName: string, fallback: number): number { const value = env[fieldName]?.trim(); if (!value) return fallback; if (!/^\d+$/.test(value)) { throw new RequestValidationError(`${fieldName} 必须是正整数。`, 500); } const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed < 1) { throw new RequestValidationError(`${fieldName} 必须是正整数。`, 500); } return parsed; } export function buildAgentCapabilities(env: Record): AgentCapabilities { return { api_version: AGENT_API_VERSION, schema_version: AGENT_SCHEMA_VERSION, auth: buildAgentAuthCapabilities(env), endpoints: { capabilities: '/api/agent/capabilities', openapi: '/api/agent/openapi.json', generate: '/api/agent/images/generate', edit: '/api/agent/images/edit', create_generate_job: '/api/agent/jobs/images/generate', job: '/api/agent/jobs/{id}', job_result: '/api/agent/jobs/{id}/result', artifact_metadata: '/api/agent/artifacts/{id}', artifact_content: '/api/agent/artifacts/{id}/content', artifact_delete: '/api/agent/artifacts/{id}' }, defaults: { model: 'gpt-image-2', response_mode: 'path', state_backend: readAgentStateBackend(env) }, limits: { max_prompt_length: MAX_PROMPT_LENGTH, max_images: MAX_IMAGE_COUNT, max_upload_mb: MAX_UPLOAD_BYTES / 1024 / 1024, partial_images: { min: 1, max: 3 } }, model_limits: { 'gpt-image-2': { max_edge: GPT_IMAGE_2_MAX_EDGE, max_pixels: GPT_IMAGE_2_MAX_PIXELS, edge_multiple: GPT_IMAGE_2_EDGE_MULTIPLE, max_aspect: GPT_IMAGE_2_MAX_ASPECT, min_pixels: GPT_IMAGE_2_MIN_PIXELS, recommended_presets: [ { name: 'square', size: '2048x2048', purpose: '通用正方形构图' }, { name: 'landscape', size: '3072x2048', purpose: '横向宽幅构图' }, { name: 'portrait', size: '2048x3072', purpose: '纵向主体构图' } ], high_4k_risk: { applies_to: ['quality=high', 'max_edge>=3072', 'long_running_upstream'], guidance: '高质量 4K 级请求可能耗时数分钟;失败应归类为上游长耗时风险,不代表低负载路径不可用。' } } }, agent_streaming: { generate: { supported: false, mode: 'non_streaming_only', endpoint: '/api/agent/images/generate' }, edit: { supported: false, mode: 'non_streaming_only', endpoint: '/api/agent/images/edit' }, page_sse: { supported: true, mode: 'form_data_sse', endpoint: '/api/images', contract: 'page_ui_only' } }, agent_jobs: { supported: true, mode: 'job_polling', intended_for: ['quality=high', 'max_edge>=3072', 'long_running_upstream', 'manual_billable_gate'], endpoints: { create_generate_job: '/api/agent/jobs/images/generate', get_job: '/api/agent/jobs/{id}', get_job_result: '/api/agent/jobs/{id}/result' }, states: AGENT_JOB_STATES, current_guidance: '对 4K/high 或长耗时请求优先使用 job polling:先创建 generate job,再轮询状态,最后读取 result。运行中 job 会刷新 lease。当前执行模型为同实例后台任务,不是跨实例持久队列。' }, supported: { models: AGENT_MODELS, output_formats: AGENT_OUTPUT_FORMATS, response_modes: AGENT_RESPONSE_MODES, qualities: AGENT_QUALITIES, backgrounds: AGENT_BACKGROUNDS, moderations: AGENT_MODERATIONS, legacy_sizes: AGENT_LEGACY_SIZES }, storage: { image_storage_mode: env.NEXT_PUBLIC_IMAGE_STORAGE_MODE || (env.VERCEL === '1' ? 'indexeddb' : 'fs'), postgres_configured: Boolean(env.AGENT_DATABASE_URL || env.AGENT_DB_PASSWORD || env.AGENT_DB_PASSWORD_FILE) }, idempotency: { required: true, header: 'Idempotency-Key', ttl_seconds: readAgentRequestTtlSeconds(env) } }; }