Spaces:
Running
Running
| import type { AgentErrorCode } from '@/lib/api-error-response'; | |
| import { isFeedbackStateStore } from '@/lib/feedback-store'; | |
| import { cleanupExpiredWebuiImages } from '@/lib/webui-image-cleanup'; | |
| import Database from 'better-sqlite3'; | |
| import type { NextRequest } from 'next/server'; | |
| import assert from 'node:assert/strict'; | |
| import { access, mkdtemp, readdir, rm, utimes } from 'node:fs/promises'; | |
| import http from 'node:http'; | |
| import type { Socket } from 'node:net'; | |
| import os from 'node:os'; | |
| import path from 'node:path'; | |
| import { afterEach, beforeEach, describe, it } from 'node:test'; | |
| import { Pool } from 'pg'; | |
| const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='; | |
| const PNG_CONVERTIBLE_BASE64 = | |
| 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADUlEQVQImWP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg=='; | |
| let originalEnv: NodeJS.ProcessEnv; | |
| let originalCwd = ''; | |
| let tempDir = ''; | |
| function restoreProcessEnv(snapshot: NodeJS.ProcessEnv) { | |
| for (const key of Object.keys(process.env)) { | |
| if (!(key in snapshot)) { | |
| delete process.env[key]; | |
| } | |
| } | |
| for (const [key, value] of Object.entries(snapshot)) { | |
| process.env[key] = value; | |
| } | |
| } | |
| function setPrimaryChannelRequestModes(requestModes: string) { | |
| process.env.OPENAI_UPSTREAM_REQUEST_MODES = requestModes; | |
| } | |
| beforeEach(async () => { | |
| originalEnv = { ...process.env }; | |
| originalCwd = process.cwd(); | |
| tempDir = await mkdtemp(path.join(os.tmpdir(), 'agent-routes-')); | |
| process.chdir(tempDir); | |
| process.env.AGENT_STATE_BACKEND = 'sqlite'; | |
| process.env.AGENT_SQLITE_PATH = path.join(tempDir, 'agent.sqlite'); | |
| process.env.NEXT_PUBLIC_IMAGE_STORAGE_MODE = 'fs'; | |
| for (const key of Object.keys(process.env)) { | |
| if (/^OPENAI_CHANNEL_\d+_/.test(key)) { | |
| delete process.env[key]; | |
| } | |
| } | |
| delete process.env.APP_PASSWORD; | |
| delete process.env.AGENT_API_TOKEN; | |
| delete process.env.AGENT_PUBLIC_BASE_URL; | |
| delete process.env.AGENT_REQUEST_TTL_SECONDS; | |
| delete process.env.AGENT_ARTIFACT_SHARE_DEFAULT_EXPIRES_MINUTES; | |
| delete process.env.AGENT_ARTIFACT_SHARE_MAX_EXPIRES_MINUTES; | |
| delete process.env.OPENAI_API_KEY; | |
| delete process.env.OPENAI_API_BASE_URL; | |
| delete process.env.OPENAI_UPSTREAM_REQUEST_MODES; | |
| delete process.env.OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY; | |
| delete process.env.OPENAI_UPSTREAM_PROXY_URL; | |
| delete process.env.OPENAI_UPSTREAM_USER_AGENT; | |
| delete process.env.UPSTREAM_USER_AGENT; | |
| delete process.env.OPENAI_CHANNEL_1_ID; | |
| delete process.env.OPENAI_CHANNEL_1_API_KEYS; | |
| delete process.env.OPENAI_CHANNEL_1_BASE_URL; | |
| delete process.env.OPENAI_CHANNEL_1_UPSTREAM_PROFILE; | |
| delete process.env.OPENAI_CHANNEL_1_REQUEST_MODES; | |
| delete process.env.OPENAI_CHANNEL_1_REQUEST_MODE_PRIORITY; | |
| delete process.env.OPENAI_CHANNEL_1_MATSCA_APP_ID; | |
| delete process.env.OPENAI_CHANNEL_1_MATSCA_APP_SECRET; | |
| delete process.env.OPENAI_CHANNEL_1_USER_AGENT; | |
| delete process.env.OPENAI_CHANNEL_1_UPSTREAM_HEADERS_JSON; | |
| delete process.env.OPENAI_CHANNEL_2_ID; | |
| delete process.env.OPENAI_CHANNEL_2_API_KEYS; | |
| delete process.env.OPENAI_CHANNEL_2_BASE_URL; | |
| delete process.env.OPENAI_CHANNEL_2_UPSTREAM_PROFILE; | |
| delete process.env.OPENAI_CHANNEL_2_REQUEST_MODES; | |
| delete process.env.OPENAI_CHANNEL_2_REQUEST_MODE_PRIORITY; | |
| delete process.env.OPENAI_CHANNEL_2_MATSCA_APP_ID; | |
| delete process.env.OPENAI_CHANNEL_2_MATSCA_APP_SECRET; | |
| delete process.env.OPENAI_CHANNEL_2_USER_AGENT; | |
| delete process.env.OPENAI_CHANNEL_2_UPSTREAM_HEADERS_JSON; | |
| delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED; | |
| delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_INTERVAL_MS; | |
| delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_TIMEOUT_MS; | |
| delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_MAX_PER_TICK; | |
| delete process.env.OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY; | |
| delete process.env.OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS; | |
| }); | |
| afterEach(async () => { | |
| const { resetAgentStateStoreForTests, setAgentStateStoreFactoryForTests } = | |
| await import('@/lib/agent-state-runtime'); | |
| const { resetServerChannelStateForTests } = await import('@/lib/server-channel-router'); | |
| restoreProcessEnv(originalEnv); | |
| process.chdir(originalCwd); | |
| setAgentStateStoreFactoryForTests(undefined); | |
| await resetAgentStateStoreForTests(); | |
| resetServerChannelStateForTests(); | |
| await rm(tempDir, { recursive: true, force: true }); | |
| }); | |
| describe('Agent route integration', () => { | |
| it('reports configured Agent auth without exposing secret values', async () => { | |
| const { getCapabilities } = await loadAgentRoutes(); | |
| process.env.AGENT_STATE_BACKEND = 'memory'; | |
| process.env.AGENT_API_TOKEN = 'capability-token'; | |
| process.env.APP_PASSWORD = 'page-access-code'; | |
| const response = await getCapabilities(); | |
| assert.equal(response.status, 200); | |
| const body = await response.json(); | |
| assert.equal(body.auth.required, true); | |
| assert.deepEqual(body.auth.schemes, ['bearer']); | |
| assert.equal(JSON.stringify(body).includes('capability-token'), false); | |
| assert.equal(JSON.stringify(body).includes('page-access-code'), false); | |
| assert.equal(body.defaults.state_backend, 'memory'); | |
| }); | |
| it('does not report auth as required for blank Agent auth settings', async () => { | |
| const { getCapabilities } = await loadAgentRoutes(); | |
| process.env.AGENT_API_TOKEN = ' '; | |
| process.env.APP_PASSWORD = ' '; | |
| const response = await getCapabilities(); | |
| assert.equal(response.status, 200); | |
| const body = await response.json(); | |
| assert.equal(body.auth.required, false); | |
| assert.deepEqual(body.auth.schemes, []); | |
| }); | |
| it('reports deployed upstream profile limits in capabilities without exposing upstream secrets', async () => { | |
| const { getCapabilities } = await loadAgentRoutes(); | |
| process.env.OPENAI_CHANNEL_1_ID = 'matsca'; | |
| process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://img.matsca.com/v1'; | |
| process.env.OPENAI_CHANNEL_1_API_KEYS = 'sk-matsca-secret'; | |
| process.env.OPENAI_CHANNEL_1_UPSTREAM_PROFILE = 'matsca'; | |
| process.env.OPENAI_CHANNEL_1_MATSCA_APP_ID = 'matsca-app-id'; | |
| process.env.OPENAI_CHANNEL_1_MATSCA_APP_SECRET = 'matsca-app-secret'; | |
| process.env.OPENAI_CHANNEL_1_USER_AGENT = 'channel-agent-secret'; | |
| process.env.OPENAI_CHANNEL_1_UPSTREAM_HEADERS_JSON = JSON.stringify({ | |
| 'X-App-Secret': 'channel-header-secret', | |
| 'X-Trace-Token': 'trace-token-secret' | |
| }); | |
| process.env.OPENAI_UPSTREAM_USER_AGENT = 'global-agent-secret'; | |
| const response = await getCapabilities(); | |
| assert.equal(response.status, 200); | |
| const body = await response.json(); | |
| assert.equal(body.upstream_profile.activeProfile, 'matsca'); | |
| assert.deepEqual(body.limits.partial_images, { min: 0, max: 4 }); | |
| assert.equal(body.limits.upload_images.max, 8); | |
| assert.equal(body.model_limits['gpt-image-2'].allow_transparent_background, true); | |
| assert.equal(body.upstream_request_headers.default.user_agent_effective, 'configured'); | |
| assert.deepEqual(body.upstream_request_headers.channels, [ | |
| { | |
| id: 'matsca', | |
| upstream_proxy: { configured: false }, | |
| request_modes: ['images-non-stream'], | |
| request_mode_priority: ['images-non-stream'], | |
| request_headers: { | |
| user_agent_effective: 'configured', | |
| has_extra_headers: true, | |
| allowed_header_names: ['user-agent', 'x-app-id', 'x-app-secret'], | |
| configured_header_names: ['user-agent', 'x-app-id', 'x-app-secret', 'x-trace-token'] | |
| } | |
| } | |
| ]); | |
| const serialized = JSON.stringify(body); | |
| assert.equal(serialized.includes('sk-matsca-secret'), false); | |
| assert.equal(serialized.includes('matsca-app-id'), false); | |
| assert.equal(serialized.includes('matsca-app-secret'), false); | |
| assert.equal(serialized.includes('channel-agent-secret'), false); | |
| assert.equal(serialized.includes('channel-header-secret'), false); | |
| assert.equal(serialized.includes('trace-token-secret'), false); | |
| assert.equal(serialized.includes('global-agent-secret'), false); | |
| }); | |
| it('reports enabled Responses image backend from the deployed runtime environment', async () => { | |
| const { getCapabilities } = await loadAgentRoutes(); | |
| process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; | |
| process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4'; | |
| const response = await getCapabilities(); | |
| assert.equal(response.status, 200); | |
| const body = await response.json(); | |
| assert.deepEqual(body.supported.enabled_image_backends, ['images-api', 'responses-image-generation']); | |
| assert.equal(body.supported.image_backend_requirements['responses-image-generation'].enabled, true); | |
| assert.deepEqual(body.supported.image_backend_requirements['responses-image-generation'].missing_env, []); | |
| assert.deepEqual(body.agent_streaming.upstream_sse.enabled_image_backends, [ | |
| 'images-api', | |
| 'responses-image-generation' | |
| ]); | |
| assert.deepEqual(body.agent_streaming.upstream_sse.request_fields_by_mode, { | |
| generate: ['image_backend', 'stream_mode', 'streaming_strategy', 'partial_images'], | |
| edit: ['stream_mode', 'streaming_strategy', 'partial_images'] | |
| }); | |
| }); | |
| it('reports global request mode priority in deployed capabilities', async () => { | |
| const { getCapabilities } = await loadAgentRoutes(); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = 'https://upstream.example.com/v1'; | |
| process.env.OPENAI_UPSTREAM_REQUEST_MODES = 'images-non-stream,images-sse'; | |
| process.env.OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY = 'images-sse,images-non-stream'; | |
| const response = await getCapabilities(); | |
| assert.equal(response.status, 200); | |
| const body = await response.json(); | |
| assert.deepEqual(body.upstream_request_headers.channels, [ | |
| { | |
| id: 'default', | |
| upstream_proxy: { configured: false }, | |
| request_modes: ['images-non-stream', 'images-sse'], | |
| request_mode_priority: ['images-sse', 'images-non-stream'], | |
| request_headers: { | |
| user_agent_effective: 'visual-journal/2.2.0', | |
| has_extra_headers: false, | |
| allowed_header_names: ['user-agent', 'x-app-id', 'x-app-secret'], | |
| configured_header_names: [] | |
| } | |
| } | |
| ]); | |
| }); | |
| it('reports global and per-channel upstream proxy summaries without exposing endpoints', async () => { | |
| const { getCapabilities } = await loadAgentRoutes(); | |
| process.env.OPENAI_UPSTREAM_PROXY_URL = 'https://global-proxy.integration.example:9443'; | |
| process.env.OPENAI_CHANNEL_1_ID = 'primary'; | |
| process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://primary.example.com/v1'; | |
| process.env.OPENAI_CHANNEL_1_API_KEYS = 'primary-secret'; | |
| process.env.OPENAI_CHANNEL_2_ID = 'backup'; | |
| process.env.OPENAI_CHANNEL_2_BASE_URL = 'https://backup.example.com/v1'; | |
| process.env.OPENAI_CHANNEL_2_API_KEYS = 'backup-secret'; | |
| process.env.OPENAI_CHANNEL_2_PROXY_URL = 'http://channel-proxy.integration.example:8080'; | |
| const response = await getCapabilities(); | |
| assert.equal(response.status, 200); | |
| const body = await response.json(); | |
| assert.deepEqual(body.image_transport.upstream_proxy, { configured: true, protocol: 'https' }); | |
| assert.deepEqual( | |
| body.upstream_request_headers.channels.map((channel: { id: string; upstream_proxy: unknown }) => ({ | |
| id: channel.id, | |
| upstream_proxy: channel.upstream_proxy | |
| })), | |
| [ | |
| { id: 'primary', upstream_proxy: { configured: true, protocol: 'https' } }, | |
| { id: 'backup', upstream_proxy: { configured: true, protocol: 'http' } } | |
| ] | |
| ); | |
| const serialized = JSON.stringify(body); | |
| assert.equal(serialized.includes('global-proxy.integration.example'), false); | |
| assert.equal(serialized.includes('channel-proxy.integration.example'), false); | |
| assert.equal(serialized.includes('9443'), false); | |
| assert.equal(serialized.includes('8080'), false); | |
| assert.equal(serialized.includes('primary-secret'), false); | |
| assert.equal(serialized.includes('backup-secret'), false); | |
| }); | |
| it('registers cleanup-managed artifacts for every request mode', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| const { getAgentStateStore } = await import('@/lib/agent-state-runtime'); | |
| const { resetServerChannelStateForTests } = await import('@/lib/server-channel-router'); | |
| process.env.AGENT_REQUEST_TTL_SECONDS = String(60 * 24 * 60 * 60); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4'; | |
| const cases = [ | |
| { | |
| mode: 'images-non-stream', | |
| startUpstream: () => startImageUpstream(() => ({ data: [{ b64_json: PNG_BASE64 }] })), | |
| request: { | |
| prompt: 'cleanup lifecycle images non stream', | |
| stream_mode: 'non_stream', | |
| streaming_strategy: 'off' | |
| } | |
| }, | |
| { | |
| mode: 'images-sse', | |
| startUpstream: () => | |
| startStreamingImageUpstream(() => [ | |
| { | |
| event: 'image_generation.completed', | |
| data: { type: 'image_generation.completed', b64_json: PNG_BASE64 } | |
| } | |
| ]), | |
| request: { | |
| prompt: 'cleanup lifecycle images sse', | |
| stream_mode: 'stream', | |
| streaming_strategy: 'force-sse', | |
| partial_images: 1 | |
| } | |
| }, | |
| { | |
| mode: 'responses-non-stream', | |
| startUpstream: () => | |
| startResponsesImageJsonUpstream(200, { | |
| id: 'response-cleanup-json', | |
| object: 'response', | |
| status: 'completed', | |
| output: [ | |
| { | |
| id: 'image-cleanup-json', | |
| type: 'image_generation_call', | |
| status: 'completed', | |
| result: PNG_BASE64 | |
| } | |
| ] | |
| }), | |
| request: { | |
| prompt: 'cleanup lifecycle responses non stream', | |
| image_backend: 'responses-image-generation', | |
| stream_mode: 'non_stream', | |
| streaming_strategy: 'off' | |
| } | |
| }, | |
| { | |
| mode: 'responses-sse', | |
| startUpstream: () => | |
| startStreamingResponsesImageUpstream(() => [ | |
| { | |
| event: 'response.output_item.done', | |
| data: { | |
| type: 'response.output_item.done', | |
| item: { | |
| id: 'image-cleanup-sse', | |
| type: 'image_generation_call', | |
| status: 'completed', | |
| result: PNG_BASE64 | |
| } | |
| } | |
| } | |
| ]), | |
| request: { | |
| prompt: 'cleanup lifecycle responses sse', | |
| image_backend: 'responses-image-generation', | |
| stream_mode: 'stream', | |
| streaming_strategy: 'responses-sse', | |
| partial_images: 1 | |
| } | |
| } | |
| ] as const; | |
| const artifacts: Array<{ id: string; filepath: string }> = []; | |
| for (const testCase of cases) { | |
| const upstream = await testCase.startUpstream(); | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| process.env.OPENAI_UPSTREAM_REQUEST_MODES = testCase.mode; | |
| process.env.ENABLE_RESPONSES_IMAGE_BACKEND = testCase.mode.startsWith('responses-') ? 'true' : 'false'; | |
| resetServerChannelStateForTests(); | |
| try { | |
| const response = await generateImage( | |
| agentJsonRequest('cleanup-lifecycle-' + testCase.mode, testCase.request) | |
| ); | |
| assert.equal(response.status, 200, testCase.mode); | |
| const body = await response.json(); | |
| assert.equal(body.execution.channel_request_mode, testCase.mode); | |
| const artifactId = body.images[0].content_url.split('/').at(-2); | |
| assert.equal(typeof artifactId, 'string'); | |
| const filepath = readStoredArtifactFilepath(artifactId); | |
| await access(filepath); | |
| artifacts.push({ id: artifactId, filepath }); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| } | |
| const store = getAgentStateStore(process.env); | |
| const protectedPaths = await store.listArtifactFilepaths(); | |
| assert.deepEqual(artifacts.map((artifact) => artifact.filepath).sort(), protectedPaths.sort()); | |
| const cleanupNow = new Date(); | |
| const expiredWebuiTime = new Date(cleanupNow.getTime() - 31 * 24 * 60 * 60 * 1000); | |
| await Promise.all(artifacts.map((artifact) => utimes(artifact.filepath, expiredWebuiTime, expiredWebuiTime))); | |
| const cleanupResult = await cleanupExpiredWebuiImages({ | |
| outputDir: path.dirname(artifacts[0].filepath), | |
| retentionDays: 30, | |
| protectedArtifactFilepaths: protectedPaths, | |
| now: cleanupNow | |
| }); | |
| assert.equal(cleanupResult.deletedCount, 0); | |
| assert.equal(cleanupResult.protectedCount, 4); | |
| await Promise.all(artifacts.map((artifact) => access(artifact.filepath))); | |
| const purged = await store.purgeExpiredRequests( | |
| new Date(cleanupNow.getTime() + 60 * 24 * 60 * 60 * 1000 + 1000) | |
| ); | |
| assert.equal(purged, 4); | |
| for (const artifact of artifacts) { | |
| assert.equal(await store.getArtifact(artifact.id), undefined); | |
| await assert.rejects(() => access(artifact.filepath)); | |
| } | |
| }); | |
| it('reports Matsca channel limits from the deployed runtime environment without leaking secrets', async () => { | |
| const { getCapabilities } = await loadAgentRoutes(); | |
| process.env.OPENAI_CHANNEL_1_ID = 'matsca'; | |
| process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://img.matsca.com/v1'; | |
| process.env.OPENAI_CHANNEL_1_API_KEYS = 'sk-matsca-secret'; | |
| process.env.OPENAI_CHANNEL_1_UPSTREAM_PROFILE = 'matsca'; | |
| process.env.OPENAI_CHANNEL_1_MATSCA_APP_ID = 'app-id-secret'; | |
| process.env.OPENAI_CHANNEL_1_MATSCA_APP_SECRET = 'app-secret-value'; | |
| process.env.OPENAI_CHANNEL_1_USER_AGENT = 'channel-agent-secret'; | |
| process.env.OPENAI_CHANNEL_1_UPSTREAM_HEADERS_JSON = JSON.stringify({ | |
| 'X-App-Secret': 'channel-header-secret', | |
| 'X-Trace-Token': 'trace-token-secret' | |
| }); | |
| process.env.UPSTREAM_USER_AGENT = 'global-agent-secret'; | |
| const response = await getCapabilities(); | |
| assert.equal(response.status, 200); | |
| const body = await response.json(); | |
| assert.equal(body.limits.max_images, 4); | |
| assert.equal(body.limits.upload_images.max, 8); | |
| assert.equal(body.limits.max_upload_mb, 10); | |
| assert.equal(body.limits.max_total_upload_mb, 80); | |
| assert.deepEqual(body.limits.partial_images, { min: 0, max: 4 }); | |
| assert.equal(body.model_limits['gpt-image-2'].allow_transparent_background, true); | |
| assert.equal(body.model_limits['gpt-image-2'].size_policy, 'positive-integer'); | |
| assert.equal(body.upstream_request_headers.default.user_agent_effective, 'configured'); | |
| assert.deepEqual(body.upstream_request_headers.channels, [ | |
| { | |
| id: 'matsca', | |
| upstream_proxy: { configured: false }, | |
| request_modes: ['images-non-stream'], | |
| request_mode_priority: ['images-non-stream'], | |
| request_headers: { | |
| user_agent_effective: 'configured', | |
| has_extra_headers: true, | |
| allowed_header_names: ['user-agent', 'x-app-id', 'x-app-secret'], | |
| configured_header_names: ['user-agent', 'x-app-id', 'x-app-secret', 'x-trace-token'] | |
| } | |
| } | |
| ]); | |
| const serialized = JSON.stringify(body); | |
| assert.equal(serialized.includes('sk-matsca-secret'), false); | |
| assert.equal(serialized.includes('app-id-secret'), false); | |
| assert.equal(serialized.includes('app-secret-value'), false); | |
| assert.equal(serialized.includes('channel-agent-secret'), false); | |
| assert.equal(serialized.includes('channel-header-secret'), false); | |
| assert.equal(serialized.includes('trace-token-secret'), false); | |
| assert.equal(serialized.includes('global-agent-secret'), false); | |
| }); | |
| it('generates through a compatible upstream once and exposes request diagnostics for the same state record', async () => { | |
| const { generateImage, getAgentRequestDiagnostics, getArtifactContent, lookupAgentRequestDiagnostics } = | |
| await loadAgentRoutes(); | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| return { | |
| data: [{ b64_json: PNG_CONVERTIBLE_BASE64 }], | |
| usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } | |
| }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| try { | |
| const first = await generateImage( | |
| agentJsonRequest('route-cache-key', { prompt: 'agent route success', output_format: 'webp' }) | |
| ); | |
| assert.equal(first.status, 200); | |
| const firstBody = await first.json(); | |
| assert.equal(firstBody.cached, false); | |
| assert.equal(firstBody.images.length, 1); | |
| assert.ok(firstBody.images[0].content_url); | |
| assert.equal('b64_json' in firstBody.images[0], false); | |
| assert.equal(firstBody.images[0].output_format, 'webp'); | |
| assert.equal(firstBody.images[0].mime_type, 'image/webp'); | |
| const artifactId = firstBody.images[0].content_url.split('/').at(-2); | |
| assert.equal(typeof artifactId, 'string'); | |
| const artifactContent = await getArtifactContent( | |
| new Request(`http://localhost/api/agent/artifacts/${artifactId}/content`), | |
| { params: Promise.resolve({ id: artifactId }) } | |
| ); | |
| assert.equal(artifactContent.status, 200); | |
| assert.equal(artifactContent.headers.get('content-type'), 'image/webp'); | |
| assert.equal(Buffer.from(await artifactContent.arrayBuffer()).toString('ascii', 8, 12), 'WEBP'); | |
| assert.equal(firstBody.execution.transport, 'agent_json'); | |
| assert.equal(firstBody.execution.endpoint, '/api/agent/images/generate'); | |
| assert.equal(firstBody.execution.route_mode, 'agent'); | |
| assert.equal(firstBody.execution.operation, 'generate'); | |
| assert.equal(firstBody.execution.image_backend, 'images-api'); | |
| assert.equal(firstBody.execution.stream_mode, 'non_stream'); | |
| assert.equal(firstBody.execution.streaming_strategy, 'auto'); | |
| assert.equal(firstBody.execution.channel_request_mode, 'images-non-stream'); | |
| assert.equal(firstBody.execution.channel_request_mode_fallback_applied, false); | |
| assert.deepEqual(firstBody.execution.route_decision, { | |
| requested_backend: 'images-api', | |
| candidate_channel_request_modes: ['images-non-stream'], | |
| request_mode_priority: ['images-non-stream'], | |
| preferred_channel_request_mode: 'images-non-stream', | |
| selected_channel_request_mode: 'images-non-stream', | |
| fallback_applied: false, | |
| selected_channel_id: 'default', | |
| upstream_host: new URL(upstream.baseUrl).host | |
| }); | |
| assert.equal(firstBody.execution.upstream_host, new URL(upstream.baseUrl).host); | |
| assert.equal(firstBody.execution.request_headers.user_agent_effective, 'visual-journal/2.2.0'); | |
| assert.equal(firstBody.execution.request_headers.has_extra_headers, false); | |
| assert.equal(typeof firstBody.timing.elapsed_ms, 'number'); | |
| assert.equal(firstBody.timing.elapsed_ms >= 0, true); | |
| assert.equal(firstBody.timing.server_elapsed_ms, firstBody.timing.elapsed_ms); | |
| assert.equal(typeof firstBody.timing.started_at, 'string'); | |
| assert.equal(typeof firstBody.timing.completed_at, 'string'); | |
| const second = await generateImage( | |
| agentJsonRequest('route-cache-key', { prompt: 'agent route success', output_format: 'webp' }) | |
| ); | |
| assert.equal(second.status, 200); | |
| const secondBody = await second.json(); | |
| assert.equal(secondBody.cached, true); | |
| assert.equal(second.headers.get('x-idempotent-replay'), 'true'); | |
| assert.equal(second.headers.get('x-request-id'), firstBody.request_id); | |
| assert.equal(secondBody.request_id, firstBody.request_id); | |
| assert.deepEqual(secondBody.execution, firstBody.execution); | |
| assert.deepEqual(secondBody.timing, firstBody.timing); | |
| assert.equal(upstreamCalls, 1); | |
| const byRequestId = await getAgentRequestDiagnostics( | |
| new Request(`http://localhost/api/agent/diagnostics/requests/${firstBody.request_id}`), | |
| { params: Promise.resolve({ id: firstBody.request_id }) } | |
| ); | |
| assert.equal(byRequestId.status, 200); | |
| const byRequestIdBody = await byRequestId.json(); | |
| assert.equal(byRequestIdBody.found, true); | |
| assert.equal(byRequestIdBody.diagnostics.request.request_id, firstBody.request_id); | |
| assert.equal(byRequestIdBody.diagnostics.request.idempotency_key, 'route-cache-key'); | |
| assert.equal(byRequestIdBody.diagnostics.request.status, 'succeeded'); | |
| assert.equal(byRequestIdBody.diagnostics.response.image_count, 1); | |
| assert.deepEqual(byRequestIdBody.diagnostics.response.timing, firstBody.timing); | |
| assert.deepEqual(byRequestIdBody.diagnostics.response.execution, firstBody.execution); | |
| assert.deepEqual(byRequestIdBody.diagnostics.response.content_urls, [firstBody.images[0].content_url]); | |
| assert.equal(byRequestIdBody.diagnostics.artifacts[0].id, firstBody.images[0].id); | |
| assert.equal(byRequestIdBody.diagnostics.state_backend, 'sqlite'); | |
| assert.equal(byRequestIdBody.diagnostics.diagnostics_retention.storage, 'agent_state'); | |
| assert.equal(byRequestIdBody.diagnostics.diagnostics_boundary.not_page_request_log, true); | |
| assert.equal(JSON.stringify(byRequestIdBody).includes(PNG_BASE64), false); | |
| const byIdempotencyKey = await lookupAgentRequestDiagnostics( | |
| new Request('http://localhost/api/agent/diagnostics/requests?idempotency_key=route-cache-key') | |
| ); | |
| assert.equal(byIdempotencyKey.status, 200); | |
| const byIdempotencyKeyBody = await byIdempotencyKey.json(); | |
| assert.equal(byIdempotencyKeyBody.diagnostics.request.request_id, firstBody.request_id); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('retries accepted async image tasks through Agent generate with the same upstream idempotency key', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| const upstreamIdempotencyKeys: Array<string | undefined> = []; | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream((_body, _url, request, response) => { | |
| upstreamCalls += 1; | |
| const idempotencyKey = request.headers['idempotency-key']; | |
| upstreamIdempotencyKeys.push(Array.isArray(idempotencyKey) ? idempotencyKey.join(',') : idempotencyKey); | |
| if (upstreamCalls === 1) { | |
| response.setHeader('Retry-After', '1'); | |
| return { | |
| object: 'image.task', | |
| status: 'pending', | |
| task_id: 'agent-route-accepted-task', | |
| poll_url: '/v1/image-tasks?ids=agent-route-accepted-task' | |
| }; | |
| } | |
| return { data: [{ b64_json: PNG_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| try { | |
| const response = await generateImage( | |
| agentJsonRequest('agent-route-accepted-task-key', { | |
| prompt: 'agent route accepted task', | |
| response_mode: 'base64' | |
| }) | |
| ); | |
| assert.equal(response.status, 200); | |
| const body = await response.json(); | |
| assert.equal(body.cached, false); | |
| assert.equal(body.images[0].b64_json, PNG_BASE64); | |
| assert.deepEqual(upstreamIdempotencyKeys, [ | |
| 'agent-route-accepted-task-key', | |
| 'agent-route-accepted-task-key' | |
| ]); | |
| assert.equal(upstreamCalls, 2); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('keeps Agent request diagnostics available when feedback lookup fails', async () => { | |
| const { generateImage, getAgentRequestDiagnostics } = await loadAgentRoutes(); | |
| const upstream = await startImageUpstream(() => ({ data: [{ b64_json: PNG_BASE64 }] })); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| try { | |
| const first = await generateImage( | |
| agentJsonRequest('route-feedback-diagnostics-key', { prompt: 'agent route success' }) | |
| ); | |
| assert.equal(first.status, 200); | |
| const firstBody = await first.json(); | |
| const { ensureAgentStateStoreReady } = await import('@/lib/agent-state-runtime'); | |
| const store = await ensureAgentStateStoreReady(); | |
| if (!isFeedbackStateStore(store)) { | |
| assert.fail('Agent state store should expose feedback methods for this route test.'); | |
| } | |
| const originalReadFeedback = store.readFeedback.bind(store); | |
| store.readFeedback = async () => { | |
| throw new Error('feedback store unavailable'); | |
| }; | |
| try { | |
| const response = await getAgentRequestDiagnostics( | |
| new Request(`http://localhost/api/agent/diagnostics/requests/${firstBody.request_id}`), | |
| { params: Promise.resolve({ id: firstBody.request_id }) } | |
| ); | |
| assert.equal(response.status, 200); | |
| const body = await response.json(); | |
| assert.equal(body.found, true); | |
| assert.equal(body.diagnostics.request.request_id, firstBody.request_id); | |
| assert.equal(body.diagnostics.response.image_count, 1); | |
| assert.equal(body.diagnostics.feedback, undefined); | |
| } finally { | |
| store.readFeedback = originalReadFeedback; | |
| } | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('rejects Agent generate image counts outside the selected Matsca profile before calling upstream', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| return { data: [{ b64_json: PNG_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_CHANNEL_1_ID = 'matsca'; | |
| process.env.OPENAI_CHANNEL_1_BASE_URL = upstream.baseUrl; | |
| process.env.OPENAI_CHANNEL_1_API_KEYS = 'test-key'; | |
| process.env.OPENAI_CHANNEL_1_UPSTREAM_PROFILE = 'matsca'; | |
| process.env.OPENAI_CHANNEL_1_MATSCA_APP_ID = 'app-id'; | |
| process.env.OPENAI_CHANNEL_1_MATSCA_APP_SECRET = 'app-secret'; | |
| try { | |
| const response = await generateImage( | |
| agentJsonRequest('agent-matsca-n-limit-key', { | |
| prompt: 'agent matsca n limit', | |
| n: 5 | |
| }) | |
| ); | |
| assert.equal(response.status, 422); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'validation_error'); | |
| assert.match(body.error.details.fields.n, /1 到 4/); | |
| assert.equal(upstreamCalls, 0); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('does not send upstream stream parameters when Agent streaming_strategy is off', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| let upstreamBody = ''; | |
| const upstream = await startImageUpstream((body) => { | |
| upstreamBody = body; | |
| return { data: [{ b64_json: PNG_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| try { | |
| const response = await generateImage( | |
| agentJsonRequest('agent-upstream-stream-off-key', { | |
| prompt: 'agent upstream stream off', | |
| streaming_strategy: 'off' | |
| }) | |
| ); | |
| assert.equal(response.status, 200); | |
| const body = await response.json(); | |
| assert.equal(body.execution.channel_request_mode, 'images-non-stream'); | |
| assert.equal(body.execution.channel_request_mode_fallback_applied, false); | |
| const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>; | |
| assert.equal(upstreamJson.stream, false); | |
| assert.equal(Object.hasOwn(upstreamJson, 'partial_images'), false); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('uses the lower-cost non-streaming channel request mode for Agent auto streaming by default', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| const { getServerChannelState } = await import('@/lib/server-channel-router'); | |
| let upstreamBody = ''; | |
| const upstream = await startImageUpstream((body) => { | |
| upstreamBody = body; | |
| return { data: [{ b64_json: PNG_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| setPrimaryChannelRequestModes('images-non-stream,images-sse'); | |
| try { | |
| const response = await generateImage( | |
| agentJsonRequest('agent-default-auto-stream-key', { | |
| prompt: 'agent default auto stream', | |
| stream_mode: 'auto' | |
| }) | |
| ); | |
| assert.equal(response.status, 200); | |
| assert.notEqual(response.headers.get('content-type'), 'text/event-stream'); | |
| const body = await response.json(); | |
| assert.equal(body.images[0].content_url.startsWith('/api/agent/artifacts/'), true); | |
| assert.equal(body.execution.channel_request_mode, 'images-non-stream'); | |
| assert.equal(body.execution.channel_request_mode_fallback_applied, false); | |
| assert.deepEqual(body.execution.route_decision.candidate_channel_request_modes, [ | |
| 'images-non-stream', | |
| 'images-sse' | |
| ]); | |
| assert.deepEqual(body.execution.route_decision.request_mode_priority, ['images-non-stream', 'images-sse']); | |
| const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>; | |
| assert.equal(upstreamJson.stream, false); | |
| assert.equal(Object.hasOwn(upstreamJson, 'partial_images'), false); | |
| assert.equal(getServerChannelState().streamingAvailability.summary().mark_count, 0); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('uses a non-streaming channel request mode when Agent auto streaming has no SSE channel', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| let upstreamBody = ''; | |
| const upstream = await startImageUpstream((body) => { | |
| upstreamBody = body; | |
| return { data: [{ b64_json: PNG_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_CHANNEL_1_ID = 'json-only'; | |
| process.env.OPENAI_CHANNEL_1_BASE_URL = upstream.baseUrl; | |
| process.env.OPENAI_CHANNEL_1_API_KEYS = 'test-key'; | |
| process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream'; | |
| try { | |
| const response = await generateImage( | |
| agentJsonRequest('agent-auto-json-channel-key', { | |
| prompt: 'agent auto json channel', | |
| stream_mode: 'auto' | |
| }) | |
| ); | |
| assert.equal(response.status, 200); | |
| const body = await response.json(); | |
| assert.equal(body.execution.channel_request_mode, 'images-non-stream'); | |
| assert.equal(body.execution.channel_request_mode_fallback_applied, false); | |
| assert.deepEqual(body.execution.route_decision, { | |
| requested_backend: 'images-api', | |
| candidate_channel_request_modes: ['images-non-stream', 'images-sse'], | |
| request_mode_priority: ['images-non-stream'], | |
| preferred_channel_request_mode: 'images-non-stream', | |
| fallback_channel_request_mode: 'images-sse', | |
| selected_channel_request_mode: 'images-non-stream', | |
| fallback_applied: false, | |
| selected_channel_id: 'json-only', | |
| upstream_host: new URL(upstream.baseUrl).host | |
| }); | |
| const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>; | |
| assert.equal(upstreamJson.stream, false); | |
| assert.equal(Object.hasOwn(upstreamJson, 'partial_images'), false); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('fails explicit Agent stream requests instead of falling back to non-streaming request modes', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| return { data: [{ b64_json: PNG_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_CHANNEL_1_ID = 'json-only'; | |
| process.env.OPENAI_CHANNEL_1_BASE_URL = upstream.baseUrl; | |
| process.env.OPENAI_CHANNEL_1_API_KEYS = 'test-key'; | |
| process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream'; | |
| try { | |
| const response = await generateImage( | |
| agentJsonRequest('agent-explicit-stream-no-sse-key', { | |
| prompt: 'agent explicit stream no sse', | |
| stream_mode: 'stream', | |
| streaming_strategy: 'openai-sse', | |
| partial_images: 2 | |
| }) | |
| ); | |
| assert.equal(response.status, 503); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'configuration_error'); | |
| assert.equal(body.error.diagnostics.channel_request_mode, 'images-sse'); | |
| assert.equal(body.error.diagnostics.channel_request_mode_fallback_applied, false); | |
| assert.deepEqual(body.error.diagnostics.route_decision, { | |
| requested_backend: 'images-api', | |
| candidate_channel_request_modes: ['images-sse'], | |
| request_mode_priority: ['images-sse'], | |
| preferred_channel_request_mode: 'images-sse', | |
| selected_channel_request_mode: 'images-sse', | |
| fallback_applied: false, | |
| no_channel_reason: | |
| '当前没有支持 images-sse 的健康渠道凭证。请调整请求策略或 OPENAI_CHANNEL_N_REQUEST_MODES。' | |
| }); | |
| assert.equal(upstreamCalls, 0); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('rejects OpenAI-compatible Agent generate profile violations before calling upstream', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| return { data: [{ b64_json: PNG_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| process.env.OPENAI_UPSTREAM_PROFILE = 'openai-compatible'; | |
| try { | |
| const response = await generateImage( | |
| agentJsonRequest('agent-generate-profile-violation-key', { | |
| prompt: 'transparent object', | |
| background: 'transparent', | |
| partial_images: 0 | |
| }) | |
| ); | |
| assert.equal(response.status, 422); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'validation_error'); | |
| assert.equal(upstreamCalls, 0); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('does not consume generate idempotency keys for local profile validation failures', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| return { data: [{ b64_json: PNG_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| process.env.OPENAI_UPSTREAM_PROFILE = 'openai-compatible'; | |
| const idempotencyKey = 'agent-generate-profile-validation-key'; | |
| try { | |
| const invalid = await generateImage( | |
| agentJsonRequest(idempotencyKey, { | |
| prompt: 'transparent object', | |
| background: 'transparent' | |
| }) | |
| ); | |
| assert.equal(invalid.status, 422); | |
| assert.equal(upstreamCalls, 0); | |
| const corrected = await generateImage( | |
| agentJsonRequest(idempotencyKey, { | |
| prompt: 'transparent object', | |
| background: 'auto' | |
| }) | |
| ); | |
| assert.equal(corrected.status, 200); | |
| assert.equal(upstreamCalls, 1); | |
| const body = await corrected.json(); | |
| assert.equal(body.idempotency_key, idempotencyKey); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('consumes upstream image SSE internally while keeping the Agent generate response non-streaming', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| let upstreamBody = ''; | |
| const upstream = await startStreamingImageUpstream((body) => { | |
| upstreamBody = body; | |
| return [ | |
| { | |
| event: 'image_generation.partial_image', | |
| data: { type: 'image_generation.partial_image', b64_json: 'agent-partial-base64' } | |
| }, | |
| { | |
| event: 'image_generation.completed', | |
| data: { type: 'image_generation.completed', b64_json: PNG_BASE64 } | |
| } | |
| ]; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| setPrimaryChannelRequestModes('images-sse'); | |
| try { | |
| const response = await generateImage( | |
| agentJsonRequest('agent-upstream-sse-key', { | |
| prompt: 'agent upstream sse', | |
| response_mode: 'base64', | |
| streaming_strategy: 'newapi-keepalive-sse', | |
| partial_images: 2 | |
| }) | |
| ); | |
| assert.equal(response.status, 200); | |
| assert.notEqual(response.headers.get('content-type'), 'text/event-stream'); | |
| const body = await response.json(); | |
| assert.equal(body.cached, false); | |
| assert.equal(body.images[0].b64_json, PNG_BASE64); | |
| const upstreamJson = JSON.parse(upstreamBody); | |
| assert.equal(upstreamJson.stream, true); | |
| assert.equal(upstreamJson.partial_images, 2); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('consumes JSON Images responses returned to Agent stream requests as final results', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| let upstreamBody = ''; | |
| const upstream = await startImageUpstream((body) => { | |
| upstreamBody = body; | |
| return { data: [{ b64_json: PNG_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| setPrimaryChannelRequestModes('images-sse'); | |
| try { | |
| const response = await generateImage( | |
| agentJsonRequest('agent-stream-json-fallback-key', { | |
| prompt: 'agent stream json fallback', | |
| response_mode: 'base64', | |
| streaming_strategy: 'newapi-keepalive-sse', | |
| partial_images: 2 | |
| }) | |
| ); | |
| assert.equal(response.status, 200); | |
| assert.notEqual(response.headers.get('content-type'), 'text/event-stream'); | |
| const body = await response.json(); | |
| assert.equal(body.images[0].b64_json, PNG_BASE64); | |
| const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>; | |
| assert.equal(upstreamJson.stream, true); | |
| assert.equal(upstreamJson.partial_images, 2); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('uses force-sse for Agent upstream image SSE while keeping the final JSON contract', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| let upstreamBody = ''; | |
| const upstream = await startStreamingImageUpstream((body) => { | |
| upstreamBody = body; | |
| return [ | |
| { | |
| event: 'image_generation.completed', | |
| data: { type: 'image_generation.completed', b64_json: PNG_BASE64 } | |
| } | |
| ]; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| setPrimaryChannelRequestModes('images-sse'); | |
| try { | |
| const response = await generateImage( | |
| agentJsonRequest('agent-force-sse-key', { | |
| prompt: 'agent force sse', | |
| response_mode: 'base64', | |
| streaming_strategy: 'force-sse', | |
| partial_images: 3 | |
| }) | |
| ); | |
| assert.equal(response.status, 200); | |
| assert.notEqual(response.headers.get('content-type'), 'text/event-stream'); | |
| const body = await response.json(); | |
| assert.equal(body.images[0].b64_json, PNG_BASE64); | |
| const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>; | |
| assert.equal(upstreamJson.stream, true); | |
| assert.equal(upstreamJson.partial_images, 3); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('consumes Responses image_generation SSE internally while keeping the Agent generate response non-streaming', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| let upstreamBody = ''; | |
| const upstream = await startStreamingResponsesImageUpstream((body) => { | |
| upstreamBody = body; | |
| return [ | |
| { | |
| event: 'response.image_generation_call.partial_image', | |
| data: { | |
| type: 'response.image_generation_call.partial_image', | |
| partial_image_b64: 'agent-responses-partial-base64', | |
| partial_image_index: 0 | |
| } | |
| }, | |
| { | |
| event: 'response.output_item.done', | |
| data: { | |
| type: 'response.output_item.done', | |
| item: { | |
| type: 'image_generation_call', | |
| status: 'completed', | |
| result: PNG_BASE64 | |
| } | |
| } | |
| } | |
| ]; | |
| }); | |
| process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; | |
| process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4'; | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| setPrimaryChannelRequestModes('responses-sse'); | |
| try { | |
| const response = await generateImage( | |
| agentJsonRequest('agent-responses-upstream-sse-key', { | |
| prompt: 'agent responses upstream sse', | |
| response_mode: 'base64', | |
| image_backend: 'responses-image-generation', | |
| streaming_strategy: 'responses-sse', | |
| partial_images: 2 | |
| }) | |
| ); | |
| assert.equal(response.status, 200); | |
| assert.notEqual(response.headers.get('content-type'), 'text/event-stream'); | |
| const body = await response.json(); | |
| assert.equal(body.cached, false); | |
| assert.equal(body.images[0].b64_json, PNG_BASE64); | |
| const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>; | |
| assert.equal(upstreamJson.model, 'gpt-5.4'); | |
| assert.equal(upstreamJson.stream, true); | |
| const tools = upstreamJson.tools as Array<Record<string, unknown>>; | |
| assert.equal(tools[0].type, 'image_generation'); | |
| assert.equal(tools[0].partial_images, 2); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('marks Responses image_generation-disabled 403s as unavailable request modes', async () => { | |
| process.env.AGENT_STATE_BACKEND = 'memory'; | |
| process.env.OPENAI_CHANNEL_FAILURE_COOLDOWN_ENABLED = 'true'; | |
| delete process.env.AGENT_SQLITE_PATH; | |
| const { generateImage } = await loadAgentRoutes(); | |
| const upstream = await startResponsesImageJsonUpstream(403, { | |
| error: { message: 'Image generation is not enabled for this group' } | |
| }); | |
| process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; | |
| process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4'; | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| setPrimaryChannelRequestModes('responses-sse'); | |
| try { | |
| const response = await generateImage( | |
| agentJsonRequest('agent-responses-image-disabled-key', { | |
| prompt: 'agent responses disabled', | |
| image_backend: 'responses-image-generation', | |
| streaming_strategy: 'responses-sse', | |
| partial_images: 2 | |
| }) | |
| ); | |
| assert.equal(response.status, 502); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'upstream_unavailable'); | |
| assert.equal(body.error.upstream_status, 403); | |
| assert.equal(body.error.diagnostics.channel_request_mode, 'responses-sse'); | |
| assert.equal(body.error.diagnostics.channel_cooldown_scope, 'channel'); | |
| assert.equal(body.error.diagnostics.cooldown_target.channel_id, 'default'); | |
| assert.equal(body.error.diagnostics.cooldown_target.request_mode, 'responses-sse'); | |
| assert.equal(JSON.stringify(body).includes('test-key'), false); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('uses force-sse for Agent Responses image_generation SSE while keeping the final JSON contract', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| let upstreamBody = ''; | |
| const upstream = await startStreamingResponsesImageUpstream((body) => { | |
| upstreamBody = body; | |
| return [ | |
| { | |
| event: 'response.output_item.done', | |
| data: { | |
| type: 'response.output_item.done', | |
| item: { | |
| type: 'image_generation_call', | |
| status: 'completed', | |
| result: PNG_BASE64 | |
| } | |
| } | |
| } | |
| ]; | |
| }); | |
| process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; | |
| process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4'; | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| setPrimaryChannelRequestModes('responses-sse'); | |
| try { | |
| const response = await generateImage( | |
| agentJsonRequest('agent-responses-force-sse-key', { | |
| prompt: 'agent responses force sse', | |
| response_mode: 'base64', | |
| image_backend: 'responses-image-generation', | |
| streaming_strategy: 'force-sse', | |
| partial_images: 3 | |
| }) | |
| ); | |
| assert.equal(response.status, 200); | |
| assert.notEqual(response.headers.get('content-type'), 'text/event-stream'); | |
| const body = await response.json(); | |
| assert.equal(body.images[0].b64_json, PNG_BASE64); | |
| const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>; | |
| assert.equal(upstreamJson.model, 'gpt-5.4'); | |
| assert.equal(upstreamJson.stream, true); | |
| const tools = upstreamJson.tools as Array<Record<string, unknown>>; | |
| assert.equal(tools[0].type, 'image_generation'); | |
| assert.equal(tools[0].partial_images, 3); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('fails Agent Responses upstream SSE requests when partial images arrive without a final image', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| const upstream = await startStreamingResponsesImageUpstream(() => [ | |
| { | |
| event: 'response.image_generation_call.partial_image', | |
| data: { | |
| type: 'response.image_generation_call.partial_image', | |
| partial_image_b64: 'agent-responses-partial-only', | |
| partial_image_index: 0 | |
| } | |
| } | |
| ]); | |
| process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; | |
| process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4'; | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| setPrimaryChannelRequestModes('responses-sse'); | |
| try { | |
| const response = await generateImage( | |
| agentJsonRequest('agent-responses-upstream-sse-partial-only-key', { | |
| prompt: 'agent responses upstream sse partial only', | |
| image_backend: 'responses-image-generation', | |
| stream_mode: 'stream', | |
| streaming_strategy: 'responses-sse', | |
| partial_images: 2 | |
| }) | |
| ); | |
| assert.equal(response.status, 502); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'upstream_unavailable'); | |
| assert.match(body.error.message, /最终图片 b64_json/); | |
| assert.equal(body.error.diagnostics.upstream_event_type, 'response.image_generation_call.partial_image'); | |
| assert.equal(body.error.diagnostics.partial_image_count, 1); | |
| assert.equal(body.error.diagnostics.retry_after_seconds, 15); | |
| assert.equal(JSON.stringify(body).includes('agent-responses-partial-only'), false); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('rejects Agent Responses upstream SSE partial_images outside the Responses backend contract', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; | |
| process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4'; | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| setPrimaryChannelRequestModes('responses-sse'); | |
| const response = await generateImage( | |
| agentJsonRequest('agent-responses-partial-range-key', { | |
| prompt: 'agent responses partial range', | |
| image_backend: 'responses-image-generation', | |
| stream_mode: 'stream', | |
| streaming_strategy: 'responses-sse', | |
| partial_images: 4 | |
| }) | |
| ); | |
| assert.equal(response.status, 422); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'validation_error'); | |
| assert.match(body.error.details.fields.partial_images, /1 到 3/); | |
| }); | |
| it('rejects Agent Images partial_images outside the selected OpenAI-compatible profile before calling upstream', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| return { data: [{ b64_json: PNG_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| try { | |
| const response = await generateImage( | |
| agentJsonRequest('agent-openai-partial-limit-key', { | |
| prompt: 'agent openai partial limit', | |
| stream_mode: 'stream', | |
| streaming_strategy: 'openai-sse', | |
| partial_images: 0 | |
| }) | |
| ); | |
| assert.equal(response.status, 422); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'validation_error'); | |
| assert.match(body.error.details.fields.partial_images, /1 到 3/); | |
| assert.equal(upstreamCalls, 0); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('keeps selected upstream image count limits enforced when force_request is enabled', async () => { | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| return { data: [{ b64_json: PNG_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_CHANNEL_1_ID = 'matsca'; | |
| process.env.OPENAI_CHANNEL_1_API_KEYS = 'test-key'; | |
| process.env.OPENAI_CHANNEL_1_BASE_URL = upstream.baseUrl; | |
| process.env.OPENAI_CHANNEL_1_UPSTREAM_PROFILE = 'matsca'; | |
| process.env.OPENAI_CHANNEL_1_MATSCA_APP_ID = 'app-id'; | |
| process.env.OPENAI_CHANNEL_1_MATSCA_APP_SECRET = 'app-secret'; | |
| const { generateImage } = await loadAgentRoutes(); | |
| try { | |
| const response = await generateImage( | |
| agentJsonRequest('agent-force-count-limit-key', { | |
| prompt: 'agent force still respects count limits', | |
| n: 5, | |
| force_request: true | |
| }) | |
| ); | |
| assert.equal(response.status, 422); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'validation_error'); | |
| assert.match(body.error.details.fields.n, /1 到 4/); | |
| assert.equal(upstreamCalls, 0); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('fails Agent Responses upstream SSE requests when the image_generation_call fails', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| const upstream = await startStreamingResponsesImageUpstream(() => [ | |
| { | |
| event: 'response.output_item.done', | |
| data: { | |
| type: 'response.output_item.done', | |
| item: { | |
| type: 'image_generation_call', | |
| status: 'failed', | |
| error: { | |
| code: 'content_policy_violation', | |
| message: 'blocked by upstream policy' | |
| } | |
| } | |
| } | |
| } | |
| ]); | |
| process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; | |
| process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4'; | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| setPrimaryChannelRequestModes('responses-sse'); | |
| try { | |
| const response = await generateImage( | |
| agentJsonRequest('agent-responses-upstream-sse-failed-call-key', { | |
| prompt: 'agent responses upstream sse failed call', | |
| image_backend: 'responses-image-generation', | |
| stream_mode: 'stream', | |
| streaming_strategy: 'responses-sse', | |
| partial_images: 2 | |
| }) | |
| ); | |
| assert.equal(response.status, 502); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'upstream_unavailable'); | |
| assert.match(body.error.message, /blocked by upstream policy/); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('fails Agent upstream SSE requests when partial images arrive without a final image', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| const upstream = await startStreamingImageUpstream(() => [ | |
| { | |
| event: 'image_generation.partial_image', | |
| data: { type: 'image_generation.partial_image', b64_json: 'agent-partial-only' } | |
| } | |
| ]); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| setPrimaryChannelRequestModes('images-sse'); | |
| try { | |
| const response = await generateImage( | |
| agentJsonRequest('agent-upstream-sse-partial-only-key', { | |
| prompt: 'agent upstream sse partial only', | |
| stream_mode: 'stream', | |
| streaming_strategy: 'newapi-keepalive-sse', | |
| partial_images: 2 | |
| }) | |
| ); | |
| assert.equal(response.status, 502); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'upstream_unavailable'); | |
| assert.match(body.error.message, /最终图片 b64_json/); | |
| assert.equal(body.error.diagnostics.upstream_event_type, 'image_generation.partial_image'); | |
| assert.equal(body.error.diagnostics.partial_image_count, 1); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('generates and replays through the memory state backend without creating SQLite state', async () => { | |
| process.env.AGENT_STATE_BACKEND = 'memory'; | |
| delete process.env.AGENT_SQLITE_PATH; | |
| const { generateImage } = await loadAgentRoutes(); | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| return { data: [{ b64_json: PNG_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| try { | |
| const first = await generateImage( | |
| agentJsonRequest('route-memory-cache-key', { prompt: 'agent memory route success' }) | |
| ); | |
| assert.equal(first.status, 200); | |
| const firstBody = await first.json(); | |
| assert.equal(firstBody.cached, false); | |
| const second = await generateImage( | |
| agentJsonRequest('route-memory-cache-key', { prompt: 'agent memory route success' }) | |
| ); | |
| assert.equal(second.status, 200); | |
| const secondBody = await second.json(); | |
| assert.equal(secondBody.cached, true); | |
| assert.equal(second.headers.get('x-idempotent-replay'), 'true'); | |
| assert.equal(secondBody.request_id, firstBody.request_id); | |
| assert.equal(upstreamCalls, 1); | |
| assert.deepEqual(await listAgentStateFiles(), []); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('returns explicit base64 without storing complete base64 in the request state', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| return { data: [{ b64_json: PNG_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| const first = await generateImage( | |
| agentJsonRequest('route-base64-key', { | |
| prompt: 'agent route base64', | |
| response_mode: 'base64' | |
| }) | |
| ); | |
| assert.equal(first.status, 200); | |
| const firstBody = await first.json(); | |
| assert.equal(firstBody.cached, false); | |
| assert.equal(firstBody.images[0].b64_json, PNG_BASE64); | |
| const storedResponse = readStoredResponseJson('route-base64-key'); | |
| assert.equal(storedResponse.includes(PNG_BASE64), false); | |
| assert.equal(storedResponse.includes('b64_json'), false); | |
| const second = await generateImage( | |
| agentJsonRequest('route-base64-key', { | |
| prompt: 'agent route base64', | |
| response_mode: 'base64' | |
| }) | |
| ); | |
| assert.equal(second.status, 200); | |
| const secondBody = await second.json(); | |
| assert.equal(secondBody.cached, true); | |
| assert.equal(secondBody.images[0].b64_json, PNG_BASE64); | |
| assert.equal(upstreamCalls, 1); | |
| await upstream.close(); | |
| }); | |
| it('returns field-level errors for invalid generate JSON requests', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| const response = await generateImage( | |
| agentJsonRequest('route-validation-key', { | |
| prompt: '', | |
| n: 99, | |
| response_mode: 'url' | |
| }) | |
| ); | |
| assert.equal(response.status, 422); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'validation_error'); | |
| assert.match(body.error.details.fields.prompt, /必填/); | |
| assert.match(body.error.details.fields.n, /1 到 10/); | |
| assert.match(body.error.details.fields.response_mode, /path/); | |
| }); | |
| it('returns validation errors for malformed generate JSON requests', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| const response = await generateImage( | |
| new Request('http://localhost/api/agent/images/generate', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Idempotency-Key': 'route-malformed-json-key' | |
| }, | |
| body: '{"prompt":' | |
| }) | |
| ); | |
| assert.equal(response.status, 422); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'validation_error'); | |
| assert.equal(body.error.retryable, false); | |
| }); | |
| it('returns sanitized upstream diagnostics for failed generate requests', async () => { | |
| process.env.OPENAI_CHANNEL_FAILURE_COOLDOWN_ENABLED = 'true'; | |
| const { generateImage, lookupAgentRequestDiagnostics } = await loadAgentRoutes(); | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| throw new Error('upstream failed'); | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| try { | |
| const idempotencyKey = 'route-upstream-diagnostics-key'; | |
| const response = await generateImage(agentJsonRequest(idempotencyKey, { prompt: 'diagnostics' })); | |
| assert.equal(response.status, 502); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'upstream_unavailable'); | |
| assert.equal(body.error.upstream_status, 500); | |
| assert.equal(body.error.diagnostics.upstream_status, 500); | |
| assert.equal(body.error.diagnostics.selected_channel_id, 'default'); | |
| assert.match(body.error.diagnostics.upstream_host, /^127\.0\.0\.1:\d+$/); | |
| assert.equal(body.error.diagnostics.channel_cooldown_scope, 'channel'); | |
| assert.equal(body.error.diagnostics.cooldown_target.channel_id, 'default'); | |
| assert.equal(body.error.diagnostics.cooldown_target.request_mode, 'images-non-stream'); | |
| assert.equal(typeof body.error.diagnostics.retry_after_ms, 'number'); | |
| assert.equal(typeof body.error.diagnostics.cooldown_until, 'string'); | |
| assert.equal(typeof body.error.diagnostics.elapsed_ms, 'number'); | |
| assert.equal(JSON.stringify(body).includes('test-key'), false); | |
| const upstreamCallsAfterFirstFailure = upstreamCalls; | |
| const replay = await generateImage(agentJsonRequest(idempotencyKey, { prompt: 'diagnostics' })); | |
| assert.equal(replay.status, 502); | |
| assert.equal(replay.headers.get('x-idempotent-replay'), 'true'); | |
| assert.equal(replay.headers.get('x-request-id'), body.error.request_id); | |
| const replayBody = await replay.json(); | |
| assert.equal(replayBody.error.code, 'upstream_unavailable'); | |
| assert.equal(replayBody.error.retryable, false); | |
| assert.equal(replayBody.error.request_id, body.error.request_id); | |
| assert.equal(upstreamCalls, upstreamCallsAfterFirstFailure); | |
| const diagnostics = await lookupAgentRequestDiagnostics( | |
| new Request(`http://localhost/api/agent/diagnostics/requests?idempotency_key=${idempotencyKey}`) | |
| ); | |
| assert.equal(diagnostics.status, 200); | |
| const diagnosticsBody = await diagnostics.json(); | |
| assert.equal(diagnosticsBody.found, true); | |
| assert.equal(diagnosticsBody.diagnostics.request.request_id, body.error.request_id); | |
| assert.equal(diagnosticsBody.diagnostics.request.status, 'failed'); | |
| assert.equal(diagnosticsBody.diagnostics.error.code, 'upstream_unavailable'); | |
| assert.equal(diagnosticsBody.diagnostics.error.retryable, true); | |
| assert.equal(diagnosticsBody.diagnostics.error.diagnostics.selected_channel_id, 'default'); | |
| assert.match(diagnosticsBody.diagnostics.error.diagnostics.upstream_host, /^127\.0\.0\.1:\d+$/); | |
| assert.equal(diagnosticsBody.diagnostics.error.diagnostics.cooldown_target.channel_id, 'default'); | |
| assert.equal( | |
| diagnosticsBody.diagnostics.error.diagnostics.cooldown_target.request_mode, | |
| 'images-non-stream' | |
| ); | |
| assert.equal(typeof diagnosticsBody.diagnostics.error.diagnostics.retry_after_ms, 'number'); | |
| assert.equal(JSON.stringify(diagnosticsBody).includes('test-key'), false); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('creates a generate job, exposes running status, and returns the completed result', async () => { | |
| const { createGenerateJob, getJob, getJobResult } = await loadAgentRoutes(); | |
| let releaseUpstream: (() => void) | undefined; | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(async () => { | |
| upstreamCalls += 1; | |
| await new Promise<void>((resolve) => { | |
| releaseUpstream = resolve; | |
| }); | |
| return { data: [{ b64_json: PNG_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| try { | |
| const created = await createGenerateJob( | |
| agentJobJsonRequest('route-job-key', { prompt: 'agent route job' }) | |
| ); | |
| assert.equal(created.status, 202); | |
| const createdBody = await created.json(); | |
| assert.equal(createdBody.job.state, 'running'); | |
| assert.equal(createdBody.job.idempotency_key, 'route-job-key'); | |
| assert.equal(createdBody.job.result_url, `/api/agent/jobs/${createdBody.job.id}/result`); | |
| await waitFor(() => upstreamCalls === 1); | |
| const running = await getJob(new Request(`http://localhost/api/agent/jobs/${createdBody.job.id}`), { | |
| params: Promise.resolve({ id: createdBody.job.id }) | |
| }); | |
| assert.equal(running.status, 200); | |
| assert.equal((await running.json()).job.state, 'running'); | |
| releaseUpstream?.(); | |
| const result = await waitForJobResult(getJobResult, createdBody.job.id); | |
| assert.equal(result.status, 200); | |
| const resultBody = await result.json(); | |
| assert.equal(resultBody.request_id, createdBody.job.id); | |
| assert.equal(resultBody.cached, false); | |
| assert.equal(resultBody.images.length, 1); | |
| assert.equal('b64_json' in resultBody.images[0], false); | |
| assert.equal(resultBody.execution.transport, 'agent_job_polling'); | |
| assert.equal(resultBody.execution.endpoint, '/api/agent/jobs/images/generate'); | |
| assert.equal(resultBody.execution.route_mode, 'job'); | |
| assert.equal(resultBody.execution.channel_request_mode, 'images-non-stream'); | |
| assert.equal(resultBody.execution.channel_request_mode_fallback_applied, false); | |
| assert.equal(typeof resultBody.timing.elapsed_ms, 'number'); | |
| assert.equal(resultBody.timing.elapsed_ms >= 0, true); | |
| } finally { | |
| releaseUpstream?.(); | |
| await upstream.close(); | |
| } | |
| }); | |
| it('creates a server-orchestrated image request job and records the orchestration endpoint', async () => { | |
| const { createImageRequest, getJobResult } = await loadAgentRoutes(); | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| return { data: [{ b64_json: PNG_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| try { | |
| const created = await createImageRequest( | |
| agentImageRequest('route-orchestrated-key', { prompt: 'server orchestrated generate' }) | |
| ); | |
| assert.equal(created.status, 202); | |
| const createdBody = await created.json(); | |
| assert.equal(createdBody.job.state, 'running'); | |
| assert.equal(createdBody.job.idempotency_key, 'route-orchestrated-key'); | |
| const result = await waitForJobResult(getJobResult, createdBody.job.id); | |
| assert.equal(result.status, 200); | |
| const resultBody = await result.json(); | |
| assert.equal(resultBody.request_id, createdBody.job.id); | |
| assert.equal(resultBody.cached, false); | |
| assert.equal(resultBody.images.length, 1); | |
| assert.equal(resultBody.execution.transport, 'agent_job_polling'); | |
| assert.equal(resultBody.execution.endpoint, '/api/agent/image-requests'); | |
| assert.equal(resultBody.execution.route_mode, 'job'); | |
| assert.equal(resultBody.execution.channel_request_mode, 'images-non-stream'); | |
| assert.equal(resultBody.execution.channel_request_mode_fallback_applied, false); | |
| assert.equal(upstreamCalls, 1); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('does not consume generate job idempotency keys for local profile validation failures', async () => { | |
| const { createGenerateJob, getJobResult } = await loadAgentRoutes(); | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| return { data: [{ b64_json: PNG_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| process.env.OPENAI_UPSTREAM_PROFILE = 'openai-compatible'; | |
| const idempotencyKey = 'route-job-local-validation-key'; | |
| try { | |
| const invalid = await createGenerateJob( | |
| agentJobJsonRequest(idempotencyKey, { | |
| prompt: 'job transparent object', | |
| background: 'transparent' | |
| }) | |
| ); | |
| assert.equal(invalid.status, 422); | |
| assert.equal((await invalid.json()).error.code, 'validation_error'); | |
| assert.equal(upstreamCalls, 0); | |
| const corrected = await createGenerateJob( | |
| agentJobJsonRequest(idempotencyKey, { | |
| prompt: 'job transparent object', | |
| background: 'auto' | |
| }) | |
| ); | |
| assert.equal(corrected.status, 202); | |
| const correctedBody = await corrected.json(); | |
| assert.equal(correctedBody.job.idempotency_key, idempotencyKey); | |
| const result = await waitForJobResult(getJobResult, correctedBody.job.id); | |
| assert.equal(result.status, 200); | |
| assert.equal((await result.json()).idempotency_key, idempotencyKey); | |
| assert.equal(upstreamCalls, 1); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('creates a generate job that consumes upstream image SSE and saves the final artifact', async () => { | |
| const { createGenerateJob, getJobResult, getArtifactContent } = await loadAgentRoutes(); | |
| let upstreamBody = ''; | |
| const upstream = await startStreamingImageUpstream((body) => { | |
| upstreamBody = body; | |
| return [ | |
| { | |
| event: 'image_generation.partial_image', | |
| data: { type: 'image_generation.partial_image', b64_json: 'job-partial-base64' } | |
| }, | |
| { | |
| event: 'image_generation.completed', | |
| data: { type: 'image_generation.completed', b64_json: PNG_BASE64 } | |
| } | |
| ]; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| setPrimaryChannelRequestModes('images-sse'); | |
| try { | |
| const created = await createGenerateJob( | |
| agentJobJsonRequest('route-job-upstream-sse-key', { | |
| prompt: 'agent job upstream sse', | |
| streaming_strategy: 'newapi-keepalive-sse', | |
| partial_images: 2 | |
| }) | |
| ); | |
| assert.equal(created.status, 202); | |
| const createdBody = await created.json(); | |
| const result = await waitForJobResult(getJobResult, createdBody.job.id); | |
| assert.equal(result.status, 200); | |
| assert.notEqual(result.headers.get('content-type'), 'text/event-stream'); | |
| const resultBody = await result.json(); | |
| assert.equal(resultBody.request_id, createdBody.job.id); | |
| assert.equal(resultBody.cached, false); | |
| assert.equal(resultBody.images.length, 1); | |
| assert.equal('b64_json' in resultBody.images[0], false); | |
| assert.match(resultBody.images[0].content_url, /^\/api\/agent\/artifacts\/[^/]+\/content$/); | |
| const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>; | |
| assert.equal(upstreamJson.stream, true); | |
| assert.equal(upstreamJson.partial_images, 2); | |
| const artifactId = resultBody.images[0].content_url.split('/').at(-2); | |
| assert.equal(typeof artifactId, 'string'); | |
| const content = await getArtifactContent( | |
| new Request(`http://localhost/api/agent/artifacts/${artifactId}/content`), | |
| { params: Promise.resolve({ id: artifactId }) } | |
| ); | |
| assert.equal(content.status, 200); | |
| assert.equal( | |
| Buffer.compare(Buffer.from(await content.arrayBuffer()), Buffer.from(PNG_BASE64, 'base64')), | |
| 0 | |
| ); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('fails image upstream SSE generate jobs when partial images arrive without a final image', async () => { | |
| const { createGenerateJob, getJob, getJobResult } = await loadAgentRoutes(); | |
| const upstream = await startStreamingImageUpstream(() => [ | |
| { | |
| event: 'image_generation.partial_image', | |
| data: { type: 'image_generation.partial_image', b64_json: 'job-images-partial-only' } | |
| } | |
| ]); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| setPrimaryChannelRequestModes('images-sse'); | |
| try { | |
| const created = await createGenerateJob( | |
| agentJobJsonRequest('route-job-images-missing-final-key', { | |
| prompt: 'agent job images partial only', | |
| stream_mode: 'stream', | |
| streaming_strategy: 'newapi-keepalive-sse', | |
| partial_images: 2 | |
| }) | |
| ); | |
| assert.equal(created.status, 202); | |
| const createdBody = await created.json(); | |
| const result = await waitForJobResult(getJobResult, createdBody.job.id); | |
| assert.equal(result.status, 502); | |
| const resultBody = await result.json(); | |
| assert.equal(resultBody.error.code, 'upstream_unavailable'); | |
| assert.match(resultBody.error.message, /最终图片 b64_json/); | |
| assert.equal(resultBody.error.retryable, false); | |
| assert.equal(resultBody.error.diagnostics.upstream_event_type, 'image_generation.partial_image'); | |
| assert.equal(resultBody.error.diagnostics.partial_image_count, 1); | |
| assert.equal(resultBody.error.diagnostics.retry_after_seconds, undefined); | |
| assert.equal(JSON.stringify(resultBody).includes('job-images-partial-only'), false); | |
| const status = await getJob(new Request(`http://localhost/api/agent/jobs/${createdBody.job.id}`), { | |
| params: Promise.resolve({ id: createdBody.job.id }) | |
| }); | |
| assert.equal(status.status, 200); | |
| const statusBody = await status.json(); | |
| assert.equal(statusBody.job.state, 'failed'); | |
| assert.equal(statusBody.job.error.code, 'upstream_unavailable'); | |
| assert.match(statusBody.job.error.message, /最终图片 b64_json/); | |
| assert.equal(statusBody.job.error.retryable, false); | |
| assert.equal(statusBody.job.error.diagnostics.upstream_event_type, 'image_generation.partial_image'); | |
| assert.equal(statusBody.job.error.diagnostics.partial_image_count, 1); | |
| assert.equal(statusBody.job.error.diagnostics.retry_after_seconds, undefined); | |
| assert.equal(JSON.stringify(statusBody).includes('job-images-partial-only'), false); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('creates a generate job that consumes Responses image_generation SSE and saves the final artifact', async () => { | |
| const { createGenerateJob, getJobResult, getArtifactContent } = await loadAgentRoutes(); | |
| let upstreamBody = ''; | |
| const upstream = await startStreamingResponsesImageUpstream((body) => { | |
| upstreamBody = body; | |
| return [ | |
| { | |
| event: 'response.image_generation_call.partial_image', | |
| data: { | |
| type: 'response.image_generation_call.partial_image', | |
| partial_image_b64: 'job-responses-partial-base64', | |
| partial_image_index: 0 | |
| } | |
| }, | |
| { | |
| event: 'response.output_item.done', | |
| data: { | |
| type: 'response.output_item.done', | |
| item: { | |
| type: 'image_generation_call', | |
| status: 'completed', | |
| result: PNG_BASE64 | |
| } | |
| } | |
| } | |
| ]; | |
| }); | |
| process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; | |
| process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4'; | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| setPrimaryChannelRequestModes('responses-sse'); | |
| try { | |
| const created = await createGenerateJob( | |
| agentJobJsonRequest('route-job-responses-upstream-sse-key', { | |
| prompt: 'agent job responses upstream sse', | |
| image_backend: 'responses-image-generation', | |
| streaming_strategy: 'responses-sse', | |
| partial_images: 2 | |
| }) | |
| ); | |
| assert.equal(created.status, 202); | |
| const createdBody = await created.json(); | |
| const result = await waitForJobResult(getJobResult, createdBody.job.id); | |
| assert.equal(result.status, 200); | |
| assert.notEqual(result.headers.get('content-type'), 'text/event-stream'); | |
| const resultBody = await result.json(); | |
| assert.equal(resultBody.request_id, createdBody.job.id); | |
| assert.equal(resultBody.cached, false); | |
| assert.equal(resultBody.images.length, 1); | |
| assert.equal('b64_json' in resultBody.images[0], false); | |
| assert.match(resultBody.images[0].content_url, /^\/api\/agent\/artifacts\/[^/]+\/content$/); | |
| const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>; | |
| assert.equal(upstreamJson.model, 'gpt-5.4'); | |
| assert.equal(upstreamJson.stream, true); | |
| assert.equal((upstreamJson.tool_choice as Record<string, unknown>).type, 'image_generation'); | |
| const tools = upstreamJson.tools as Array<Record<string, unknown>>; | |
| assert.equal(tools[0].type, 'image_generation'); | |
| assert.equal(tools[0].partial_images, 2); | |
| const artifactId = resultBody.images[0].content_url.split('/').at(-2); | |
| assert.equal(typeof artifactId, 'string'); | |
| const content = await getArtifactContent( | |
| new Request(`http://localhost/api/agent/artifacts/${artifactId}/content`), | |
| { params: Promise.resolve({ id: artifactId }) } | |
| ); | |
| assert.equal(content.status, 200); | |
| assert.equal( | |
| Buffer.compare(Buffer.from(await content.arrayBuffer()), Buffer.from(PNG_BASE64, 'base64')), | |
| 0 | |
| ); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('fails Responses upstream SSE generate jobs when partial images arrive without a final image', async () => { | |
| const { createGenerateJob, getJob, getJobResult } = await loadAgentRoutes(); | |
| const upstream = await startStreamingResponsesImageUpstream(() => [ | |
| { | |
| event: 'response.image_generation_call.partial_image', | |
| data: { | |
| type: 'response.image_generation_call.partial_image', | |
| partial_image_b64: 'job-responses-partial-only', | |
| partial_image_index: 0 | |
| } | |
| } | |
| ]); | |
| process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; | |
| process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4'; | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| setPrimaryChannelRequestModes('responses-sse'); | |
| try { | |
| const created = await createGenerateJob( | |
| agentJobJsonRequest('route-job-responses-missing-final-key', { | |
| prompt: 'agent job responses partial only', | |
| image_backend: 'responses-image-generation', | |
| stream_mode: 'stream', | |
| streaming_strategy: 'responses-sse', | |
| partial_images: 2 | |
| }) | |
| ); | |
| assert.equal(created.status, 202); | |
| const createdBody = await created.json(); | |
| const result = await waitForJobResult(getJobResult, createdBody.job.id); | |
| assert.equal(result.status, 502); | |
| const resultBody = await result.json(); | |
| assert.equal(resultBody.error.code, 'upstream_unavailable'); | |
| assert.match(resultBody.error.message, /最终图片 b64_json/); | |
| assert.equal(resultBody.error.retryable, false); | |
| assert.equal( | |
| resultBody.error.diagnostics.upstream_event_type, | |
| 'response.image_generation_call.partial_image' | |
| ); | |
| assert.equal(resultBody.error.diagnostics.partial_image_count, 1); | |
| assert.equal(resultBody.error.diagnostics.retry_after_seconds, undefined); | |
| assert.equal(JSON.stringify(resultBody).includes('job-responses-partial-only'), false); | |
| const status = await getJob(new Request(`http://localhost/api/agent/jobs/${createdBody.job.id}`), { | |
| params: Promise.resolve({ id: createdBody.job.id }) | |
| }); | |
| assert.equal(status.status, 200); | |
| const statusBody = await status.json(); | |
| assert.equal(statusBody.job.state, 'failed'); | |
| assert.equal(statusBody.job.error.code, 'upstream_unavailable'); | |
| assert.match(statusBody.job.error.message, /最终图片 b64_json/); | |
| assert.equal(statusBody.job.error.retryable, false); | |
| assert.equal( | |
| statusBody.job.error.diagnostics.upstream_event_type, | |
| 'response.image_generation_call.partial_image' | |
| ); | |
| assert.equal(statusBody.job.error.diagnostics.partial_image_count, 1); | |
| assert.equal(statusBody.job.error.diagnostics.retry_after_seconds, undefined); | |
| assert.equal(JSON.stringify(statusBody).includes('job-responses-partial-only'), false); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('reuses the running generate job for the same idempotency key', async () => { | |
| const { createGenerateJob, getJobResult } = await loadAgentRoutes(); | |
| let releaseUpstream: (() => void) | undefined; | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(async () => { | |
| upstreamCalls += 1; | |
| await new Promise<void>((resolve) => { | |
| releaseUpstream = resolve; | |
| }); | |
| return { data: [{ b64_json: PNG_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| try { | |
| const first = await createGenerateJob(agentJobJsonRequest('route-job-reuse-key', { prompt: 'job reuse' })); | |
| assert.equal(first.status, 202); | |
| const firstBody = await first.json(); | |
| await waitFor(() => upstreamCalls === 1); | |
| const second = await createGenerateJob(agentJobJsonRequest('route-job-reuse-key', { prompt: 'job reuse' })); | |
| assert.equal(second.status, 202); | |
| assert.equal(second.headers.get('x-idempotent-replay'), 'true'); | |
| const secondBody = await second.json(); | |
| assert.equal(secondBody.job.id, firstBody.job.id); | |
| assert.equal(secondBody.job.state, 'running'); | |
| assert.equal(upstreamCalls, 1); | |
| releaseUpstream?.(); | |
| const result = await waitForJobResult(getJobResult, firstBody.job.id); | |
| assert.equal(result.status, 200); | |
| } finally { | |
| releaseUpstream?.(); | |
| await upstream.close(); | |
| } | |
| }); | |
| it('rejects generate job idempotency keys reused with a different request body', async () => { | |
| const { createGenerateJob, getJobResult } = await loadAgentRoutes(); | |
| let releaseUpstream: (() => void) | undefined; | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(async () => { | |
| upstreamCalls += 1; | |
| await new Promise<void>((resolve) => { | |
| releaseUpstream = resolve; | |
| }); | |
| return { data: [{ b64_json: PNG_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| try { | |
| const first = await createGenerateJob( | |
| agentJobJsonRequest('route-job-conflict-key', { prompt: 'first job body' }) | |
| ); | |
| assert.equal(first.status, 202); | |
| const firstBody = await first.json(); | |
| await waitFor(() => upstreamCalls === 1); | |
| const conflict = await createGenerateJob( | |
| agentJobJsonRequest('route-job-conflict-key', { prompt: 'different job body' }) | |
| ); | |
| assert.equal(conflict.status, 409); | |
| assert.equal((await conflict.json()).error.code, 'idempotency_conflict'); | |
| assert.equal(upstreamCalls, 1); | |
| releaseUpstream?.(); | |
| const result = await waitForJobResult(getJobResult, firstBody.job.id); | |
| assert.equal(result.status, 200); | |
| } finally { | |
| releaseUpstream?.(); | |
| await upstream.close(); | |
| } | |
| }); | |
| it('returns stored Agent errors for failed generate jobs', async () => { | |
| const { createGenerateJob, getJob, getJobResult } = await loadAgentRoutes(); | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| throw new Error('job upstream failed'); | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| try { | |
| const created = await createGenerateJob( | |
| agentJobJsonRequest('route-job-failure-key', { prompt: 'job failure' }) | |
| ); | |
| assert.equal(created.status, 202); | |
| const createdBody = await created.json(); | |
| const result = await waitForJobResult(getJobResult, createdBody.job.id); | |
| assert.equal(result.status, 502); | |
| const resultBody = await result.json(); | |
| assert.equal(resultBody.error.code, 'upstream_unavailable'); | |
| assert.equal(resultBody.error.retryable, false); | |
| assert.equal(resultBody.error.upstream_status, 500); | |
| assert.equal(resultBody.error.diagnostics.upstream_status, 500); | |
| assert.equal(resultBody.error.request_id, createdBody.job.id); | |
| assert.equal(JSON.stringify(resultBody).includes('test-key'), false); | |
| const status = await getJob(new Request(`http://localhost/api/agent/jobs/${createdBody.job.id}`), { | |
| params: Promise.resolve({ id: createdBody.job.id }) | |
| }); | |
| assert.equal(status.status, 200); | |
| const statusBody = await status.json(); | |
| assert.equal(statusBody.job.state, 'failed'); | |
| assert.equal(statusBody.job.error.code, 'upstream_unavailable'); | |
| assert.equal(statusBody.job.error.retryable, false); | |
| assert.equal(statusBody.job.error.upstream_status, 500); | |
| assert.equal(statusBody.job.error.diagnostics.upstream_status, 500); | |
| assert.equal(upstreamCalls > 0, true); | |
| const replay = await createGenerateJob( | |
| agentJobJsonRequest('route-job-failure-key', { prompt: 'job failure' }) | |
| ); | |
| assert.equal(replay.status, 202); | |
| assert.equal(replay.headers.get('x-idempotent-replay'), 'true'); | |
| const replayBody = await replay.json(); | |
| assert.equal(replayBody.job.id, createdBody.job.id); | |
| assert.equal(replayBody.job.state, 'failed'); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('marks generate jobs as failed when completion state persistence fails', async () => { | |
| const { createGenerateJob, getJob } = await loadAgentRoutes(); | |
| const { setAgentStateStoreFactoryForTests } = await import('@/lib/agent-state-runtime'); | |
| const requestId = 'job-completion-failure-request'; | |
| let failErrorCode: AgentErrorCode | undefined; | |
| let saveCalls = 0; | |
| const upstream = await startImageUpstream(() => ({ data: [{ b64_json: PNG_BASE64 }] })); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| setAgentStateStoreFactoryForTests(() => ({ | |
| async init() {}, | |
| async recoverExpiredRequests() { | |
| return 0; | |
| }, | |
| async purgeExpiredRequests() { | |
| return 0; | |
| }, | |
| async beginRequest() { | |
| return { | |
| type: 'acquired', | |
| record: { | |
| requestId, | |
| idempotencyKey: 'job-completion-failure-key', | |
| requestHash: 'hash', | |
| mode: 'generate', | |
| status: 'running', | |
| requestJson: { prompt: 'job completion persistence failure' }, | |
| createdAt: '2026-05-12T00:00:00.000Z', | |
| updatedAt: '2026-05-12T00:00:00.000Z', | |
| expiresAt: '2099-05-13T00:00:00.000Z' | |
| } | |
| }; | |
| }, | |
| async refreshRequestLease() { | |
| return true; | |
| }, | |
| async saveArtifacts() { | |
| saveCalls += 1; | |
| }, | |
| async completeRequest() { | |
| throw new Error('job completion persistence failed'); | |
| }, | |
| async failRequest(input: { requestId: string; error: { error: { code: AgentErrorCode } } }) { | |
| assert.equal(input.requestId, requestId); | |
| failErrorCode = input.error.error.code; | |
| }, | |
| async getRequest(id: string) { | |
| if (id !== requestId || !failErrorCode) return undefined; | |
| return { | |
| requestId, | |
| idempotencyKey: 'job-completion-failure-key', | |
| requestHash: 'hash', | |
| mode: 'generate', | |
| status: 'failed', | |
| requestJson: { prompt: 'job completion persistence failure' }, | |
| errorJson: { | |
| error: { | |
| code: failErrorCode, | |
| message: '保存请求完成状态失败。', | |
| retryable: true, | |
| request_id: requestId | |
| } | |
| }, | |
| createdAt: '2026-05-12T00:00:00.000Z', | |
| updatedAt: '2026-05-12T00:00:01.000Z', | |
| expiresAt: '2099-05-13T00:00:00.000Z' | |
| }; | |
| }, | |
| async getRequestByIdempotencyKey() { | |
| return undefined; | |
| }, | |
| async getArtifact() { | |
| return undefined; | |
| }, | |
| async listArtifactsForRequest() { | |
| return []; | |
| }, | |
| async listArtifactFilepaths() { | |
| return []; | |
| }, | |
| async deleteArtifact() { | |
| return false; | |
| } | |
| })); | |
| const originalConsoleError = console.error; | |
| console.error = () => {}; | |
| try { | |
| const created = await createGenerateJob( | |
| agentJobJsonRequest('job-completion-failure-key', { prompt: 'job completion persistence failure' }) | |
| ); | |
| assert.equal(created.status, 202); | |
| await waitFor(() => failErrorCode === 'unexpected_error'); | |
| assert.equal(saveCalls, 1); | |
| const status = await getJob(new Request(`http://localhost/api/agent/jobs/${requestId}`), { | |
| params: Promise.resolve({ id: requestId }) | |
| }); | |
| assert.equal(status.status, 200); | |
| const statusBody = await status.json(); | |
| assert.equal(statusBody.job.state, 'failed'); | |
| assert.equal(statusBody.job.error.code, 'unexpected_error'); | |
| assert.equal(statusBody.job.error.retryable, false); | |
| } finally { | |
| console.error = originalConsoleError; | |
| await upstream.close(); | |
| } | |
| }); | |
| it('keeps a long-running generate job leased while the upstream call is still active', async () => { | |
| process.env.AGENT_REQUEST_LEASE_MS = '200'; | |
| process.env.AGENT_RECOVERY_INTERVAL_MS = '50'; | |
| const { createGenerateJob, getJob, getJobResult } = await loadAgentRoutes(); | |
| let releaseUpstream: (() => void) | undefined; | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(async () => { | |
| upstreamCalls += 1; | |
| await new Promise<void>((resolve) => { | |
| releaseUpstream = resolve; | |
| }); | |
| return { data: [{ b64_json: PNG_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| try { | |
| const created = await createGenerateJob( | |
| agentJobJsonRequest('route-job-lease-key', { prompt: 'job lease' }) | |
| ); | |
| assert.equal(created.status, 202); | |
| const createdBody = await created.json(); | |
| await waitFor(() => upstreamCalls === 1); | |
| await new Promise((resolve) => setTimeout(resolve, 350)); | |
| const status = await getJob(new Request(`http://localhost/api/agent/jobs/${createdBody.job.id}`), { | |
| params: Promise.resolve({ id: createdBody.job.id }) | |
| }); | |
| assert.equal(status.status, 200); | |
| const statusBody = await status.json(); | |
| assert.equal(statusBody.job.state, 'running'); | |
| releaseUpstream?.(); | |
| const result = await waitForJobResult(getJobResult, createdBody.job.id); | |
| assert.equal(result.status, 200); | |
| } finally { | |
| releaseUpstream?.(); | |
| await upstream.close(); | |
| } | |
| }); | |
| it('returns structured errors for missing and expired jobs', async () => { | |
| const { getJob, getJobResult } = await loadAgentRoutes(); | |
| const { resetAgentStateStoreForTests, setAgentStateStoreFactoryForTests } = | |
| await import('@/lib/agent-state-runtime'); | |
| const missing = await getJob(new Request('http://localhost/api/agent/jobs/missing-job'), { | |
| params: Promise.resolve({ id: 'missing-job' }) | |
| }); | |
| assert.equal(missing.status, 404); | |
| assert.equal((await missing.json()).error.code, 'job_not_found'); | |
| setAgentStateStoreFactoryForTests(() => ({ | |
| async init() {}, | |
| async recoverExpiredRequests() { | |
| return 0; | |
| }, | |
| async purgeExpiredRequests() { | |
| return 0; | |
| }, | |
| async beginRequest() { | |
| throw new Error('not used'); | |
| }, | |
| async refreshRequestLease() { | |
| return false; | |
| }, | |
| async saveArtifacts() {}, | |
| async completeRequest() {}, | |
| async failRequest() {}, | |
| async getRequest() { | |
| return { | |
| requestId: 'expired-job', | |
| idempotencyKey: 'expired-key', | |
| requestHash: 'hash', | |
| mode: 'generate', | |
| status: 'running', | |
| requestJson: { prompt: 'expired' }, | |
| createdAt: '2026-05-12T00:00:00.000Z', | |
| updatedAt: '2026-05-12T00:00:00.000Z', | |
| expiresAt: '2026-05-12T00:00:01.000Z' | |
| }; | |
| }, | |
| async getRequestByIdempotencyKey() { | |
| return undefined; | |
| }, | |
| async getArtifact() { | |
| return undefined; | |
| }, | |
| async listArtifactsForRequest() { | |
| return []; | |
| }, | |
| async listArtifactFilepaths() { | |
| return []; | |
| }, | |
| async deleteArtifact() { | |
| return false; | |
| } | |
| })); | |
| await resetAgentStateStoreForTests(); | |
| const expired = await getJobResult(new Request('http://localhost/api/agent/jobs/expired-job/result'), { | |
| params: Promise.resolve({ id: 'expired-job' }) | |
| }); | |
| assert.equal(expired.status, 410); | |
| assert.equal((await expired.json()).error.code, 'job_expired'); | |
| }); | |
| it('edits through multipart input and replays the cached response for the same idempotency key', async () => { | |
| const { editImage } = await loadAgentRoutes(); | |
| let upstreamCalls = 0; | |
| let upstreamBody = ''; | |
| const upstream = await startImageUpstream((body) => { | |
| upstreamCalls += 1; | |
| upstreamBody = body; | |
| return { data: [{ b64_json: PNG_CONVERTIBLE_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| const first = await editImage(agentEditRequest('route-edit-key', 'agent edit success')); | |
| assert.equal(first.status, 200); | |
| const firstBody = await first.json(); | |
| assert.equal(firstBody.cached, false); | |
| assert.equal(firstBody.images[0].output_format, 'webp'); | |
| assert.equal(firstBody.images[0].mime_type, 'image/webp'); | |
| assert.equal(firstBody.images[0].filename.endsWith('.webp'), true); | |
| assert.equal('b64_json' in firstBody.images[0], false); | |
| assert.doesNotMatch(upstreamBody, /name="output_format"/); | |
| assert.doesNotMatch(upstreamBody, /name="response_format"/); | |
| const second = await editImage(agentEditRequest('route-edit-key', 'agent edit success')); | |
| assert.equal(second.status, 200); | |
| const secondBody = await second.json(); | |
| assert.equal(secondBody.cached, true); | |
| assert.equal(second.headers.get('x-idempotent-replay'), 'true'); | |
| assert.equal(second.headers.get('x-request-id'), firstBody.request_id); | |
| assert.equal(secondBody.request_id, firstBody.request_id); | |
| assert.equal(upstreamCalls, 1); | |
| await upstream.close(); | |
| }); | |
| it('does not consume edit idempotency keys for local input validation failures', async () => { | |
| const { editImage } = await loadAgentRoutes(); | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| return { data: [{ b64_json: PNG_CONVERTIBLE_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| const idempotencyKey = 'route-edit-local-validation-key'; | |
| const invalidFormData = new FormData(); | |
| invalidFormData.append('prompt', 'agent edit without image'); | |
| invalidFormData.append('model', 'gpt-image-2'); | |
| invalidFormData.append('stream_mode', 'non_stream'); | |
| try { | |
| const invalid = await editImage( | |
| new Request('http://localhost/api/agent/images/edit', { | |
| method: 'POST', | |
| headers: { 'Idempotency-Key': idempotencyKey }, | |
| body: invalidFormData | |
| }) | |
| ); | |
| assert.equal(invalid.status, 422); | |
| assert.equal(upstreamCalls, 0); | |
| const corrected = await editImage(agentEditRequest(idempotencyKey, 'agent edit without image')); | |
| assert.equal(corrected.status, 200); | |
| assert.equal(upstreamCalls, 1); | |
| const body = await corrected.json(); | |
| assert.equal(body.idempotency_key, idempotencyKey); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('can consume Agent edit upstream SSE internally while returning final JSON', async () => { | |
| const { editImage } = await loadAgentRoutes(); | |
| let upstreamBody = ''; | |
| const upstream = await startStreamingImageUpstream((body) => { | |
| upstreamBody = body; | |
| return [ | |
| { | |
| event: 'image_edit.completed', | |
| data: { type: 'image_edit.completed', b64_json: PNG_CONVERTIBLE_BASE64 } | |
| } | |
| ]; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| setPrimaryChannelRequestModes('images-sse'); | |
| try { | |
| const response = await editImage( | |
| agentEditRequest( | |
| 'route-edit-upstream-sse-key', | |
| 'agent edit stream', | |
| {}, | |
| { | |
| stream_mode: 'stream', | |
| streaming_strategy: 'openai-sse', | |
| partial_images: '2' | |
| } | |
| ) | |
| ); | |
| assert.equal(response.status, 200); | |
| assert.notEqual(response.headers.get('content-type'), 'text/event-stream'); | |
| const body = await response.json(); | |
| assert.equal(body.images[0].content_url.startsWith('/api/agent/artifacts/'), true); | |
| assert.match(upstreamBody, /name="stream"/); | |
| assert.match(upstreamBody, /name="partial_images"/); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('aborts Agent edit upstream calls when the client request signal aborts', async () => { | |
| const { editImage } = await loadAgentRoutes(); | |
| const { getServerChannelState } = await import('@/lib/server-channel-router'); | |
| const upstream = await startHangingImageEditUpstream(); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| process.env.OPENAI_UPSTREAM_REQUEST_MODES = 'images-sse'; | |
| const abortController = new AbortController(); | |
| try { | |
| const responsePromise = editImage( | |
| agentEditRequest( | |
| 'route-edit-abort-key', | |
| 'agent edit abort', | |
| {}, | |
| { stream_mode: 'auto', streaming_strategy: 'openai-sse' }, | |
| { signal: abortController.signal } | |
| ) | |
| ); | |
| await waitFor(() => upstream.requests === 1); | |
| abortController.abort(); | |
| const response = await Promise.race([ | |
| responsePromise, | |
| new Promise<Response>((_, reject) => | |
| setTimeout(() => reject(new Error('Agent edit upstream call did not abort')), 1500) | |
| ) | |
| ]); | |
| assert.notEqual(response.status, 200); | |
| assert.equal(getServerChannelState().streamingAvailability.summary().mark_count, 0); | |
| } finally { | |
| abortController.abort(); | |
| await upstream.close(); | |
| } | |
| }); | |
| it('returns field-level errors for invalid edit multipart requests', async () => { | |
| const { editImage } = await loadAgentRoutes(); | |
| const upstream = await startImageUpstream(() => ({ data: [{ b64_json: PNG_BASE64 }] })); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| const response = await editImage( | |
| agentEditRequest('route-edit-validation-key', 'agent edit invalid', {}, 'url') | |
| ); | |
| assert.equal(response.status, 422); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'validation_error'); | |
| assert.match(body.error.details.fields.response_mode, /path/); | |
| await upstream.close(); | |
| }); | |
| it('rejects Agent edit masks without transparent pixels before contacting upstream', async () => { | |
| const { editImage } = await loadAgentRoutes(); | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| return { data: [{ b64_json: PNG_CONVERTIBLE_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| try { | |
| const response = await editImage( | |
| agentEditRequest( | |
| 'route-edit-mask-alpha-key', | |
| 'agent edit mask alpha', | |
| {}, | |
| { | |
| mask: Buffer.from(PNG_BASE64, 'base64') | |
| } | |
| ) | |
| ); | |
| assert.equal(response.status, 422); | |
| const body = await response.json(); | |
| assert.match(body.error.details.fields.mask, /mask 必须包含透明区域/); | |
| assert.equal(upstreamCalls, 0); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('rejects unsupported fields on Agent edit requests before calling upstream', async () => { | |
| const { editImage } = await loadAgentRoutes(); | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| return { data: [{ b64_json: PNG_CONVERTIBLE_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| try { | |
| const response = await editImage( | |
| agentEditRequest( | |
| 'route-edit-generate-only-fields-key', | |
| 'agent edit invalid fields', | |
| {}, | |
| { | |
| imageBackend: 'responses-image-generation', | |
| image_backend: 'responses-image-generation', | |
| format: 'webp', | |
| outputFormat: 'jpeg', | |
| output_format: 'jpeg', | |
| outputCompression: '80', | |
| output_compression: '80', | |
| responsesModel: 'gpt-4.1', | |
| responses_model: 'gpt-4.1', | |
| background: 'opaque', | |
| moderation: 'auto' | |
| } | |
| ) | |
| ); | |
| assert.equal(response.status, 422); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'validation_error'); | |
| assert.match(body.error.details.fields.imageBackend, /不接受该字段/); | |
| assert.match(body.error.details.fields.image_backend, /不接受该字段/); | |
| assert.match(body.error.details.fields.format, /不接受该字段/); | |
| assert.match(body.error.details.fields.outputFormat, /不接受该字段/); | |
| assert.match(body.error.details.fields.output_format, /不接受该字段/); | |
| assert.match(body.error.details.fields.outputCompression, /不接受该字段/); | |
| assert.match(body.error.details.fields.output_compression, /不接受该字段/); | |
| assert.match(body.error.details.fields.responsesModel, /不接受该字段/); | |
| assert.match(body.error.details.fields.responses_model, /不接受该字段/); | |
| assert.match(body.error.details.fields.background, /不接受该字段/); | |
| assert.match(body.error.details.fields.moderation, /不接受该字段/); | |
| assert.equal(upstreamCalls, 0); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('rejects page-only streaming strategy fields on Agent edit requests before calling upstream', async () => { | |
| const { editImage } = await loadAgentRoutes(); | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| return { data: [{ b64_json: PNG_CONVERTIBLE_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| try { | |
| const response = await editImage( | |
| agentEditRequest( | |
| 'route-edit-page-streaming-field-key', | |
| 'agent edit invalid page streaming field', | |
| {}, | |
| { | |
| image_streaming_strategy: 'force-sse', | |
| imageStreamingStrategy: 'force-sse' | |
| } | |
| ) | |
| ); | |
| assert.equal(response.status, 422); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'validation_error'); | |
| assert.match(body.error.details.fields.image_streaming_strategy, /streaming_strategy/); | |
| assert.match(body.error.details.fields.imageStreamingStrategy, /streaming_strategy/); | |
| assert.equal(upstreamCalls, 0); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('rejects OpenAI-compatible Agent edit partial_images violations before calling upstream', async () => { | |
| const { editImage } = await loadAgentRoutes(); | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| return { data: [{ b64_json: PNG_CONVERTIBLE_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| process.env.OPENAI_UPSTREAM_PROFILE = 'openai-compatible'; | |
| setPrimaryChannelRequestModes('images-sse'); | |
| try { | |
| const response = await editImage( | |
| agentEditRequest( | |
| 'route-edit-profile-partial-key', | |
| 'agent edit invalid profile partial', | |
| {}, | |
| { | |
| stream_mode: 'stream', | |
| streaming_strategy: 'force-sse', | |
| partial_images: '0' | |
| } | |
| ) | |
| ); | |
| assert.equal(response.status, 422); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'validation_error'); | |
| assert.match(body.error.details.fields.partial_images, /1 到 3/); | |
| assert.equal(upstreamCalls, 0); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('allows high-resolution Agent edit requests as an explicit fallback path', async () => { | |
| const { editImage } = await loadAgentRoutes(); | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| return { data: [{ b64_json: PNG_CONVERTIBLE_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| try { | |
| const response = await editImage( | |
| agentEditRequest('route-edit-high-resolution-key', 'high resolution edit', {}, { size: '3072x2048' }) | |
| ); | |
| assert.equal(response.status, 200); | |
| const body = await response.json(); | |
| assert.equal(body.images[0].output_format, 'webp'); | |
| assert.equal(body.images[0].mime_type, 'image/webp'); | |
| assert.equal(body.images[0].filename.endsWith('.webp'), true); | |
| assert.equal(upstreamCalls, 1); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('allows Agent edit force_request to bypass local fixed-size profile limits', async () => { | |
| const { editImage } = await loadAgentRoutes(); | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| return { data: [{ b64_json: PNG_CONVERTIBLE_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| process.env.OPENAI_UPSTREAM_PROFILE = 'openai-compatible'; | |
| try { | |
| const rejected = await editImage( | |
| agentEditRequest('route-edit-small-size-rejected-key', 'small edit', {}, { size: '512x512' }) | |
| ); | |
| assert.equal(rejected.status, 422); | |
| const rejectedBody = await rejected.json(); | |
| assert.equal(rejectedBody.error.code, 'validation_error'); | |
| assert.match(rejectedBody.error.details.fields.size, /总像素必须至少/); | |
| assert.equal(upstreamCalls, 0); | |
| const forced = await editImage( | |
| agentEditRequest( | |
| 'route-edit-small-size-forced-key', | |
| 'small edit', | |
| {}, | |
| { | |
| size: '512x512', | |
| force_request: 'true' | |
| } | |
| ) | |
| ); | |
| assert.equal(forced.status, 200); | |
| assert.equal(upstreamCalls, 1); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('reports missing image files for high-resolution Agent edit before API credentials', async () => { | |
| const { editImage } = await loadAgentRoutes(); | |
| delete process.env.OPENAI_API_KEY; | |
| delete process.env.OPENAI_API_BASE_URL; | |
| const formData = new FormData(); | |
| formData.append('prompt', 'high resolution edit without file'); | |
| formData.append('model', 'gpt-image-2'); | |
| formData.append('size', '3072x2048'); | |
| formData.append('response_mode', 'path'); | |
| const response = await editImage( | |
| new Request('http://localhost/api/agent/images/edit', { | |
| method: 'POST', | |
| headers: { | |
| 'Idempotency-Key': 'route-edit-high-resolution-no-file-key' | |
| }, | |
| body: formData | |
| }) | |
| ); | |
| assert.equal(response.status, 422); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'validation_error'); | |
| assert.match(body.error.message, /图片文件/); | |
| assert.doesNotMatch(body.error.message, /API Key/); | |
| }); | |
| it('allows auto-size Agent edit when the uploaded source image is high resolution', async () => { | |
| const { editImage } = await loadAgentRoutes(); | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| return { data: [{ b64_json: PNG_CONVERTIBLE_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| try { | |
| const response = await editImage( | |
| agentEditRequest( | |
| 'route-edit-auto-high-resolution-source-key', | |
| 'auto high resolution source', | |
| {}, | |
| { | |
| size: 'auto', | |
| image_0: createPngWithDimensions(3072, 2048) | |
| } | |
| ) | |
| ); | |
| assert.equal(response.status, 200); | |
| const body = await response.json(); | |
| assert.equal(body.images[0].output_format, 'webp'); | |
| assert.equal(body.images[0].mime_type, 'image/webp'); | |
| assert.equal(body.images[0].filename.endsWith('.webp'), true); | |
| assert.equal(upstreamCalls, 1); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('returns validation errors for non-multipart edit requests', async () => { | |
| const { editImage } = await loadAgentRoutes(); | |
| const response = await editImage( | |
| new Request('http://localhost/api/agent/images/edit', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Idempotency-Key': 'route-edit-non-multipart-key' | |
| }, | |
| body: JSON.stringify({ prompt: 'not multipart' }) | |
| }) | |
| ); | |
| assert.equal(response.status, 415); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'validation_error'); | |
| assert.equal(body.error.retryable, false); | |
| }); | |
| it('returns validation errors for malformed edit multipart requests', async () => { | |
| const { editImage } = await loadAgentRoutes(); | |
| const response = await editImage( | |
| new Request('http://localhost/api/agent/images/edit', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'multipart/form-data; boundary=broken-boundary', | |
| 'Idempotency-Key': 'route-edit-malformed-multipart-key' | |
| }, | |
| body: '--not-the-declared-boundary\r\n' | |
| }) | |
| ); | |
| assert.equal(response.status, 422); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'validation_error'); | |
| assert.equal(body.error.retryable, false); | |
| }); | |
| it('returns sanitized upstream diagnostics for failed edit requests', async () => { | |
| const { editImage } = await loadAgentRoutes(); | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| throw new Error('edit upstream failed'); | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| try { | |
| const idempotencyKey = 'route-edit-upstream-diagnostics-key'; | |
| const response = await editImage(agentEditRequest(idempotencyKey, 'edit diagnostics')); | |
| assert.equal(response.status, 502); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'upstream_unavailable'); | |
| assert.equal(body.error.upstream_status, 500); | |
| assert.equal(body.error.diagnostics.upstream_status, 500); | |
| assert.equal(body.error.diagnostics.selected_channel_id, 'default'); | |
| assert.match(body.error.diagnostics.upstream_host, /^127\.0\.0\.1:\d+$/); | |
| assert.equal(body.error.diagnostics.channel_cooldown_scope, 'channel'); | |
| assert.equal(typeof body.error.diagnostics.elapsed_ms, 'number'); | |
| assert.equal(JSON.stringify(body).includes('test-key'), false); | |
| const upstreamCallsAfterFirstFailure = upstreamCalls; | |
| const replay = await editImage(agentEditRequest(idempotencyKey, 'edit diagnostics')); | |
| assert.equal(replay.status, 502); | |
| assert.equal(replay.headers.get('x-idempotent-replay'), 'true'); | |
| assert.equal(replay.headers.get('x-request-id'), body.error.request_id); | |
| const replayBody = await replay.json(); | |
| assert.equal(replayBody.error.code, 'upstream_unavailable'); | |
| assert.equal(replayBody.error.retryable, false); | |
| assert.equal(replayBody.error.request_id, body.error.request_id); | |
| assert.equal(upstreamCalls, upstreamCallsAfterFirstFailure); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('does not mark a real upstream success as failed when edit state completion fails', async () => { | |
| const { editImage } = await loadAgentRoutes(); | |
| const { setAgentStateStoreFactoryForTests } = await import('@/lib/agent-state-runtime'); | |
| let upstreamCalls = 0; | |
| let failCalls = 0; | |
| let saveCalls = 0; | |
| const requestId = 'edit-completion-failure-request'; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| return { data: [{ b64_json: PNG_CONVERTIBLE_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| setAgentStateStoreFactoryForTests(() => ({ | |
| async init() {}, | |
| async recoverExpiredRequests() { | |
| return 0; | |
| }, | |
| async purgeExpiredRequests() { | |
| return 0; | |
| }, | |
| async beginRequest() { | |
| return { | |
| type: 'acquired', | |
| record: { | |
| requestId, | |
| idempotencyKey: 'edit-completion-failure-key', | |
| requestHash: 'hash', | |
| mode: 'edit', | |
| status: 'running', | |
| requestJson: { fields: { prompt: 'state completion failure' } }, | |
| createdAt: '2026-05-12T00:00:00.000Z', | |
| updatedAt: '2026-05-12T00:00:00.000Z', | |
| expiresAt: '2026-05-13T00:00:00.000Z' | |
| } | |
| }; | |
| }, | |
| async refreshRequestLease() { | |
| return false; | |
| }, | |
| async saveArtifacts() { | |
| saveCalls += 1; | |
| }, | |
| async completeRequest() { | |
| throw new Error('state completion failed'); | |
| }, | |
| async failRequest() { | |
| failCalls += 1; | |
| }, | |
| async getRequest() { | |
| return undefined; | |
| }, | |
| async getRequestByIdempotencyKey() { | |
| return undefined; | |
| }, | |
| async getArtifact() { | |
| return undefined; | |
| }, | |
| async listArtifactsForRequest() { | |
| return []; | |
| }, | |
| async listArtifactFilepaths() { | |
| return []; | |
| }, | |
| async deleteArtifact() { | |
| return false; | |
| } | |
| })); | |
| const originalConsoleError = console.error; | |
| console.error = () => {}; | |
| try { | |
| const response = await editImage( | |
| agentEditRequest('edit-completion-failure-key', 'state completion failure') | |
| ); | |
| assert.equal(response.status, 500); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'unexpected_error'); | |
| assert.equal(body.error.retryable, true); | |
| assert.equal(body.error.request_id, requestId); | |
| assert.equal(upstreamCalls, 1); | |
| assert.equal(saveCalls, 1); | |
| assert.equal(failCalls, 0); | |
| } finally { | |
| console.error = originalConsoleError; | |
| await upstream.close(); | |
| } | |
| }); | |
| it('requires artifact content authorization and returns image bytes when authorized', async () => { | |
| const { generateImage, getArtifact, getArtifactContent, createArtifactShare, getShareContent, deleteArtifact } = | |
| await loadAgentRoutes(); | |
| const upstream = await startImageUpstream(() => ({ data: [{ b64_json: PNG_BASE64 }] })); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| process.env.AGENT_API_TOKEN = 'artifact-token'; | |
| const generated = await generateImage( | |
| agentJsonRequest( | |
| 'artifact-auth-key', | |
| { prompt: 'artifact auth' }, | |
| { Authorization: 'Bearer artifact-token' } | |
| ) | |
| ); | |
| const body = await generated.json(); | |
| const artifactId = body.images[0].id; | |
| const denied = await getArtifactContent( | |
| new Request(`http://localhost/api/agent/artifacts/${artifactId}/content`), | |
| { | |
| params: Promise.resolve({ id: artifactId }) | |
| } | |
| ); | |
| assert.equal(denied.status, 401); | |
| assert.equal((await denied.json()).error.code, 'unauthorized'); | |
| const allowed = await getArtifactContent( | |
| new Request(`http://localhost/api/agent/artifacts/${artifactId}/content`, { | |
| headers: { Authorization: 'Bearer artifact-token' } | |
| }), | |
| { params: Promise.resolve({ id: artifactId }) } | |
| ); | |
| assert.equal(allowed.status, 200); | |
| assert.equal(allowed.headers.get('content-type'), 'image/png'); | |
| assert.ok((await allowed.arrayBuffer()).byteLength > 0); | |
| const invalidContentTypeShare = await createArtifactShare( | |
| new Request(`http://internal.local/api/agent/artifacts/${artifactId}/share`, { | |
| method: 'POST', | |
| headers: { Authorization: 'Bearer artifact-token' }, | |
| body: JSON.stringify({ access_code: '12345678' }) | |
| }), | |
| { params: Promise.resolve({ id: artifactId }) } | |
| ); | |
| assert.equal(invalidContentTypeShare.status, 400); | |
| assert.equal((await invalidContentTypeShare.json()).error.code, 'validation_error'); | |
| const deniedShare = await createArtifactShare( | |
| new Request(`http://localhost/api/agent/artifacts/${artifactId}/share`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ expires_in_minutes: 60 }) | |
| }), | |
| { params: Promise.resolve({ id: artifactId }) } | |
| ); | |
| assert.equal(deniedShare.status, 401); | |
| assert.equal((await deniedShare.json()).error.code, 'unauthorized'); | |
| const relativeShare = await createArtifactShare( | |
| new Request(`http://spoofed.local/api/agent/artifacts/${artifactId}/share`, { | |
| method: 'POST', | |
| headers: { Authorization: 'Bearer artifact-token', 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ expires_in_minutes: 60 }) | |
| }), | |
| { params: Promise.resolve({ id: artifactId }) } | |
| ); | |
| assert.equal(relativeShare.status, 201); | |
| const relativeShareBody = await relativeShare.json(); | |
| assert.equal(relativeShareBody.share_url, `/share/${relativeShareBody.token}`); | |
| assert.equal(relativeShareBody.direct_content_url, `/api/shares/${relativeShareBody.token}/content`); | |
| process.env.AGENT_PUBLIC_BASE_URL = 'https://public.example.test'; | |
| const publicShare = await createArtifactShare( | |
| new Request(`http://internal.local/api/agent/artifacts/${artifactId}/share`, { | |
| method: 'POST', | |
| headers: { Authorization: 'Bearer artifact-token', 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ expires_in_minutes: 60 }) | |
| }), | |
| { params: Promise.resolve({ id: artifactId }) } | |
| ); | |
| assert.equal(publicShare.status, 201); | |
| const publicShareBody = await publicShare.json(); | |
| assert.equal(publicShareBody.artifact_id, artifactId); | |
| assert.match(publicShareBody.token, /^[a-f0-9]{24}$/); | |
| assert.equal(publicShareBody.share_url, `https://public.example.test/share/${publicShareBody.token}`); | |
| assert.equal( | |
| publicShareBody.direct_content_url, | |
| `https://public.example.test/api/shares/${publicShareBody.token}/content` | |
| ); | |
| assert.equal(publicShareBody.access_code_required, false); | |
| assert.equal(typeof publicShareBody.expires_at, 'string'); | |
| process.env.AGENT_PUBLIC_BASE_URL = 'https://public.example.test/playground'; | |
| const publicShareWithPath = await createArtifactShare( | |
| new Request(`http://internal.local/api/agent/artifacts/${artifactId}/share`, { | |
| method: 'POST', | |
| headers: { Authorization: 'Bearer artifact-token', 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ expires_in_minutes: 60 }) | |
| }), | |
| { params: Promise.resolve({ id: artifactId }) } | |
| ); | |
| assert.equal(publicShareWithPath.status, 201); | |
| const publicShareWithPathBody = await publicShareWithPath.json(); | |
| assert.equal( | |
| publicShareWithPathBody.share_url, | |
| `https://public.example.test/playground/share/${publicShareWithPathBody.token}` | |
| ); | |
| assert.equal( | |
| publicShareWithPathBody.direct_content_url, | |
| `https://public.example.test/playground/api/shares/${publicShareWithPathBody.token}/content` | |
| ); | |
| const publicShareContent = await getShareContent(new Request(publicShareBody.direct_content_url), { | |
| params: Promise.resolve({ token: publicShareBody.token }) | |
| }); | |
| assert.equal(publicShareContent.status, 200); | |
| assert.equal(publicShareContent.headers.get('content-type'), 'image/png'); | |
| assert.ok((await publicShareContent.arrayBuffer()).byteLength > 0); | |
| const protectedShare = await createArtifactShare( | |
| new Request(`http://localhost/api/agent/artifacts/${artifactId}/share`, { | |
| method: 'POST', | |
| headers: { Authorization: 'Bearer artifact-token', 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ access_code: '12345678', expires_in_minutes: null }) | |
| }), | |
| { params: Promise.resolve({ id: artifactId }) } | |
| ); | |
| assert.equal(protectedShare.status, 201); | |
| const protectedShareBody = await protectedShare.json(); | |
| assert.equal(protectedShareBody.access_code_required, true); | |
| assert.equal(protectedShareBody.expires_at, null); | |
| const protectedGet = await getShareContent(new Request(protectedShareBody.direct_content_url), { | |
| params: Promise.resolve({ token: protectedShareBody.token }) | |
| }); | |
| assert.equal(protectedGet.status, 401); | |
| assert.equal((await protectedGet.json()).code, 'share_access_code_required'); | |
| process.env.AGENT_ARTIFACT_SHARE_MAX_EXPIRES_MINUTES = '30'; | |
| const defaultExpiryShare = await createArtifactShare( | |
| new Request(`http://internal.local/api/agent/artifacts/${artifactId}/share`, { | |
| method: 'POST', | |
| headers: { Authorization: 'Bearer artifact-token' } | |
| }), | |
| { params: Promise.resolve({ id: artifactId }) } | |
| ); | |
| assert.equal(defaultExpiryShare.status, 201); | |
| const defaultExpiryShareBody = await defaultExpiryShare.json(); | |
| const defaultExpiryMs = new Date(defaultExpiryShareBody.expires_at).getTime() - Date.now(); | |
| assert.ok(defaultExpiryMs > 0); | |
| assert.ok(defaultExpiryMs <= 30 * 60 * 1000); | |
| const metadata = await getArtifact( | |
| new Request(`http://localhost/api/agent/artifacts/${artifactId}`, { | |
| headers: { Authorization: 'Bearer artifact-token' } | |
| }), | |
| { params: Promise.resolve({ id: artifactId }) } | |
| ); | |
| assert.equal(metadata.status, 200); | |
| const metadataBody = await metadata.json(); | |
| assert.equal(metadataBody.artifact.id, artifactId); | |
| assert.equal(metadataBody.artifact.output_format, 'png'); | |
| assert.equal(metadataBody.artifact.mime_type, 'image/png'); | |
| assert.equal('filepath' in metadataBody.artifact, false); | |
| const deleted = await deleteArtifact( | |
| new Request(`http://localhost/api/agent/artifacts/${artifactId}`, { | |
| method: 'DELETE', | |
| headers: { Authorization: 'Bearer artifact-token' } | |
| }), | |
| { params: Promise.resolve({ id: artifactId }) } | |
| ); | |
| assert.equal(deleted.status, 200); | |
| assert.equal((await deleted.json()).deleted, true); | |
| const replayAfterDelete = await generateImage( | |
| agentJsonRequest( | |
| 'artifact-auth-key', | |
| { prompt: 'artifact auth' }, | |
| { Authorization: 'Bearer artifact-token' } | |
| ) | |
| ); | |
| assert.equal(replayAfterDelete.status, 404); | |
| assert.equal((await replayAfterDelete.json()).error.code, 'artifact_not_found'); | |
| await upstream.close(); | |
| }); | |
| it('returns not found when artifact metadata exists but content file is missing', async () => { | |
| const { generateImage, getArtifactContent } = await loadAgentRoutes(); | |
| const upstream = await startImageUpstream(() => ({ data: [{ b64_json: PNG_BASE64 }] })); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| try { | |
| const generated = await generateImage( | |
| agentJsonRequest('artifact-missing-content-key', { prompt: 'missing content' }) | |
| ); | |
| const body = await generated.json(); | |
| const image = body.images[0]; | |
| await rm(readStoredArtifactFilepath(image.id), { force: true }); | |
| const missing = await getArtifactContent( | |
| new Request(`http://localhost/api/agent/artifacts/${image.id}/content`), | |
| { params: Promise.resolve({ id: image.id }) } | |
| ); | |
| assert.equal(missing.status, 404); | |
| assert.equal((await missing.json()).error.code, 'artifact_not_found'); | |
| } finally { | |
| await upstream.close(); | |
| } | |
| }); | |
| it('persists page feedback and lets Agent clients read it by page request id', async () => { | |
| const { putFeedback, getPageRequestFeedback, getPageRequestFeedbackBatch } = await loadAgentRoutes(); | |
| process.env.AGENT_API_TOKEN = 'feedback-token'; | |
| const saved = await putFeedback( | |
| new Request('http://localhost/api/feedback', { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| targets: [{ type: 'page_request', id: 'web-feedback-agent-route', filename: 'feedback.png' }], | |
| value: 'needs_revision', | |
| note: 'text overlaps subject', | |
| updatedAt: '2026-05-12T00:00:00.000Z' | |
| }) | |
| }) | |
| ); | |
| assert.equal(saved.status, 200); | |
| const savedBody = await saved.json(); | |
| assert.equal(savedBody.feedback.length, 1); | |
| const response = await getPageRequestFeedback( | |
| new Request('http://localhost/api/agent/page-requests/web-feedback-agent-route/feedback', { | |
| headers: { Authorization: 'Bearer feedback-token' } | |
| }), | |
| { params: Promise.resolve({ id: 'web-feedback-agent-route' }) } | |
| ); | |
| assert.equal(response.status, 200); | |
| const body = await response.json(); | |
| assert.deepEqual(body.feedback, { | |
| target_type: 'page_request', | |
| target_id: 'web-feedback-agent-route', | |
| value: 'needs_revision', | |
| note: 'text overlaps subject', | |
| source: 'webui', | |
| updated_at: '2026-05-12T00:00:00.000Z' | |
| }); | |
| const batchResponse = await getPageRequestFeedbackBatch( | |
| new Request('http://localhost/api/agent/page-requests/feedback', { | |
| method: 'POST', | |
| headers: { Authorization: 'Bearer feedback-token', 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ ids: ['web-feedback-agent-route', 'web-feedback-missing'] }) | |
| }) | |
| ); | |
| assert.equal(batchResponse.status, 200); | |
| const batchBody = await batchResponse.json(); | |
| assert.deepEqual(batchBody.targets, [ | |
| { type: 'page_request', id: 'web-feedback-agent-route' }, | |
| { type: 'page_request', id: 'web-feedback-missing' } | |
| ]); | |
| assert.deepEqual(batchBody.feedback, [body.feedback]); | |
| }); | |
| it('returns validation errors for malformed page feedback batch JSON requests', async () => { | |
| const { getPageRequestFeedbackBatch } = await loadAgentRoutes(); | |
| process.env.AGENT_API_TOKEN = 'feedback-malformed-json-token'; | |
| const response = await getPageRequestFeedbackBatch( | |
| new Request('http://localhost/api/agent/page-requests/feedback', { | |
| method: 'POST', | |
| headers: { | |
| Authorization: 'Bearer feedback-malformed-json-token', | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: '{"ids":' | |
| }) | |
| ); | |
| assert.equal(response.status, 422); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'validation_error'); | |
| assert.equal(body.error.retryable, false); | |
| }); | |
| it('returns validation errors for non-string page feedback batch ids', async () => { | |
| const { getPageRequestFeedbackBatch } = await loadAgentRoutes(); | |
| process.env.AGENT_API_TOKEN = 'feedback-invalid-ids-token'; | |
| const response = await getPageRequestFeedbackBatch( | |
| new Request('http://localhost/api/agent/page-requests/feedback', { | |
| method: 'POST', | |
| headers: { | |
| Authorization: 'Bearer feedback-invalid-ids-token', | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ ids: ['web-feedback-id', 123] }) | |
| }) | |
| ); | |
| assert.equal(response.status, 422); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'validation_error'); | |
| assert.equal(body.error.message, 'ids 数组必须只包含字符串 ID。'); | |
| }); | |
| it('deduplicates page feedback batch ids before applying the max-id limit', async () => { | |
| const { getPageRequestFeedbackBatch } = await loadAgentRoutes(); | |
| process.env.AGENT_API_TOKEN = 'feedback-id-dedupe-token'; | |
| const response = await getPageRequestFeedbackBatch( | |
| new Request('http://localhost/api/agent/page-requests/feedback', { | |
| method: 'POST', | |
| headers: { | |
| Authorization: 'Bearer feedback-id-dedupe-token', | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ ids: Array.from({ length: 60 }, () => 'web-feedback-same-id') }) | |
| }) | |
| ); | |
| assert.equal(response.status, 200); | |
| const body = await response.json(); | |
| assert.deepEqual(body.targets, [{ type: 'page_request', id: 'web-feedback-same-id' }]); | |
| assert.deepEqual(body.feedback, []); | |
| }); | |
| it('deduplicates repeated page feedback targets before persisting them', async () => { | |
| const { putFeedback, getPageRequestFeedback } = await loadAgentRoutes(); | |
| process.env.AGENT_API_TOKEN = 'feedback-dedupe-token'; | |
| const saved = await putFeedback( | |
| new Request('http://localhost/api/feedback', { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| targets: [ | |
| { type: 'page_request', id: 'web-feedback-duplicate' }, | |
| { type: 'page_request', id: 'web-feedback-duplicate' } | |
| ], | |
| value: 'usable', | |
| updatedAt: '2026-05-12T00:00:00.000Z' | |
| }) | |
| }) | |
| ); | |
| assert.equal(saved.status, 200); | |
| const savedBody = await saved.json(); | |
| assert.deepEqual( | |
| savedBody.feedback.map((item: { target_id: string }) => item.target_id), | |
| ['web-feedback-duplicate'] | |
| ); | |
| const response = await getPageRequestFeedback( | |
| new Request('http://localhost/api/agent/page-requests/web-feedback-duplicate/feedback', { | |
| headers: { Authorization: 'Bearer feedback-dedupe-token' } | |
| }), | |
| { params: Promise.resolve({ id: 'web-feedback-duplicate' }) } | |
| ); | |
| assert.equal(response.status, 200); | |
| assert.equal((await response.json()).feedback.value, 'usable'); | |
| }); | |
| it('returns validation errors for non-serializable WebUI feedback timestamps', async () => { | |
| const { putFeedback, deleteFeedback } = await loadAgentRoutes(); | |
| const invalidUpdatedAt = await putFeedback( | |
| new Request('http://localhost/api/feedback', { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| targets: [{ type: 'page_request', id: 'web-feedback-invalid-updated-at' }], | |
| value: 'usable', | |
| updatedAt: 1e20 | |
| }) | |
| }) | |
| ); | |
| assert.equal(invalidUpdatedAt.status, 400); | |
| const invalidUpdatedAtBody = await invalidUpdatedAt.json(); | |
| assert.equal(invalidUpdatedAtBody.code, 'invalid_feedback_request'); | |
| assert.equal(invalidUpdatedAtBody.error, '反馈更新时间无效。'); | |
| const invalidDeletedAt = await deleteFeedback( | |
| new Request('http://localhost/api/feedback', { | |
| method: 'DELETE', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| targets: [{ type: 'page_request', id: 'web-feedback-invalid-deleted-at' }], | |
| deletedAt: 1e20 | |
| }) | |
| }) | |
| ); | |
| assert.equal(invalidDeletedAt.status, 400); | |
| const invalidDeletedAtBody = await invalidDeletedAt.json(); | |
| assert.equal(invalidDeletedAtBody.code, 'invalid_feedback_delete_request'); | |
| assert.equal(invalidDeletedAtBody.error, '反馈删除时间无效。'); | |
| }); | |
| it('deletes page feedback when the WebUI clears matching page request targets', async () => { | |
| const { putFeedback, deleteFeedback, getPageRequestFeedback } = await loadAgentRoutes(); | |
| process.env.AGENT_API_TOKEN = 'feedback-delete-token'; | |
| const saved = await putFeedback( | |
| new Request('http://localhost/api/feedback', { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| targets: [{ type: 'page_request', id: 'web-feedback-delete-route' }], | |
| value: 'usable', | |
| updatedAt: '2026-05-12T00:00:00.000Z' | |
| }) | |
| }) | |
| ); | |
| assert.equal(saved.status, 200); | |
| const deleted = await deleteFeedback( | |
| new Request('http://localhost/api/feedback', { | |
| method: 'DELETE', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ targets: [{ type: 'page_request', id: 'web-feedback-delete-route' }] }) | |
| }) | |
| ); | |
| assert.equal(deleted.status, 200); | |
| assert.equal((await deleted.json()).deleted, 1); | |
| const response = await getPageRequestFeedback( | |
| new Request('http://localhost/api/agent/page-requests/web-feedback-delete-route/feedback', { | |
| headers: { Authorization: 'Bearer feedback-delete-token' } | |
| }), | |
| { params: Promise.resolve({ id: 'web-feedback-delete-route' }) } | |
| ); | |
| assert.equal(response.status, 200); | |
| assert.equal((await response.json()).feedback, null); | |
| }); | |
| it('does not let stale WebUI feedback deletes remove newer feedback', async () => { | |
| const { putFeedback, deleteFeedback, getPageRequestFeedback } = await loadAgentRoutes(); | |
| process.env.AGENT_API_TOKEN = 'feedback-delete-stale-token'; | |
| const saved = await putFeedback( | |
| new Request('http://localhost/api/feedback', { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| targets: [{ type: 'page_request', id: 'web-feedback-delete-stale-route' }], | |
| value: 'needs_revision', | |
| note: 'latest feedback', | |
| updatedAt: '2026-05-12T00:02:00.000Z' | |
| }) | |
| }) | |
| ); | |
| assert.equal(saved.status, 200); | |
| const staleDeleted = await deleteFeedback( | |
| new Request('http://localhost/api/feedback', { | |
| method: 'DELETE', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| targets: [{ type: 'page_request', id: 'web-feedback-delete-stale-route' }], | |
| deletedAt: '2026-05-12T00:01:00.000Z' | |
| }) | |
| }) | |
| ); | |
| assert.equal(staleDeleted.status, 200); | |
| assert.equal((await staleDeleted.json()).deleted, 0); | |
| const retained = await getPageRequestFeedback( | |
| new Request('http://localhost/api/agent/page-requests/web-feedback-delete-stale-route/feedback', { | |
| headers: { Authorization: 'Bearer feedback-delete-stale-token' } | |
| }), | |
| { params: Promise.resolve({ id: 'web-feedback-delete-stale-route' }) } | |
| ); | |
| assert.equal(retained.status, 200); | |
| assert.equal((await retained.json()).feedback.note, 'latest feedback'); | |
| const currentDeleted = await deleteFeedback( | |
| new Request('http://localhost/api/feedback', { | |
| method: 'DELETE', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| targets: [{ type: 'page_request', id: 'web-feedback-delete-stale-route' }], | |
| deletedAt: '2026-05-12T00:03:00.000Z' | |
| }) | |
| }) | |
| ); | |
| assert.equal(currentDeleted.status, 200); | |
| assert.equal((await currentDeleted.json()).deleted, 1); | |
| const cleared = await getPageRequestFeedback( | |
| new Request('http://localhost/api/agent/page-requests/web-feedback-delete-stale-route/feedback', { | |
| headers: { Authorization: 'Bearer feedback-delete-stale-token' } | |
| }), | |
| { params: Promise.resolve({ id: 'web-feedback-delete-stale-route' }) } | |
| ); | |
| assert.equal(cleared.status, 200); | |
| assert.equal((await cleared.json()).feedback, null); | |
| }); | |
| it('returns machine-readable Agent diagnostics for page request logs', async () => { | |
| const { getPageRequestDiagnostics, getPageRequestDiagnosticsBatch } = await loadAgentRoutes(); | |
| const { appLogger, clearAppLogEntriesForTest } = await import('@/lib/app-logger'); | |
| process.env.AGENT_API_TOKEN = 'diagnostics-token'; | |
| clearAppLogEntriesForTest(); | |
| appLogger.info('流式生成完成。', { | |
| clientRequestId: 'web-diagnostics-request', | |
| filenames: ['diagnostic.png'], | |
| providerDialect: 'sdk_parsed_fallback', | |
| normalizedEventCount: 1, | |
| reason: 'json_final_fallback', | |
| image_backend: 'images-api', | |
| operation: 'generate' | |
| }); | |
| appLogger.warn('流式生成回退。', { | |
| clientRequestId: 'web-diagnostics-request-2', | |
| filenames: ['diagnostic-2.png'], | |
| reason: 'json_final_fallback', | |
| image_backend: 'images-api', | |
| operation: 'generate' | |
| }); | |
| const response = await getPageRequestDiagnostics( | |
| new Request('http://localhost/api/agent/diagnostics/page-requests/web-diagnostics-request', { | |
| headers: { Authorization: 'Bearer diagnostics-token' } | |
| }), | |
| { params: Promise.resolve({ id: 'web-diagnostics-request' }) } | |
| ); | |
| assert.equal(response.status, 200); | |
| const body = await response.json(); | |
| assert.equal(body.scope.request_ids[0], 'web-diagnostics-request'); | |
| assert.equal(body.matched_log_count, 1); | |
| assert.equal(body.events[0].client_request_id, 'web-diagnostics-request'); | |
| assert.equal(body.events[0].diagnostics.providerDialect, 'sdk_parsed_fallback'); | |
| assert.equal(body.events[0].diagnostics.normalizedEventCount, 1); | |
| assert.equal(body.events[0].diagnostics.reason, 'json_final_fallback'); | |
| assert.equal(body.diagnostics_retention.storage, 'bounded_local_jsonl'); | |
| assert.equal(body.diagnostics_retention.configured_by, 'APP_LOG_MAX_ENTRIES'); | |
| const missingResponse = await getPageRequestDiagnostics( | |
| new Request('http://localhost/api/agent/diagnostics/page-requests/web-diagnostics-missing', { | |
| headers: { Authorization: 'Bearer diagnostics-token' } | |
| }), | |
| { params: Promise.resolve({ id: 'web-diagnostics-missing' }) } | |
| ); | |
| assert.equal(missingResponse.status, 200); | |
| const missingBody = await missingResponse.json(); | |
| assert.equal(missingBody.matched_log_count, 0); | |
| assert.equal(missingBody.diagnostics_note.code, 'no_matching_logs_in_retention_window'); | |
| assert.equal(missingBody.diagnostics_note.retention.max_entries, missingBody.diagnostics_retention.max_entries); | |
| const batchResponse = await getPageRequestDiagnosticsBatch( | |
| new Request('http://localhost/api/agent/diagnostics/page-requests', { | |
| method: 'POST', | |
| headers: { Authorization: 'Bearer diagnostics-token', 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| ids: ['web-diagnostics-request', 'web-diagnostics-request-2'], | |
| filenames: ['diagnostic-2.png'] | |
| }) | |
| }) | |
| ); | |
| assert.equal(batchResponse.status, 200); | |
| const batchBody = await batchResponse.json(); | |
| assert.deepEqual(batchBody.targets, [ | |
| { type: 'page_request', id: 'web-diagnostics-request' }, | |
| { type: 'page_request', id: 'web-diagnostics-request-2' } | |
| ]); | |
| assert.deepEqual( | |
| batchBody.diagnostics.map((item: { client_request_id: string }) => item.client_request_id), | |
| ['web-diagnostics-request', 'web-diagnostics-request-2'] | |
| ); | |
| assert.equal(batchBody.diagnostics[0].matched_log_count, 2); | |
| assert.deepEqual(batchBody.diagnostics[0].scope.filename_matched_request_ids, ['web-diagnostics-request-2']); | |
| assert.equal(batchBody.diagnostics[1].matched_log_count, 1); | |
| assert.equal(batchBody.diagnostics_retention.storage, 'bounded_local_jsonl'); | |
| assert.equal( | |
| batchBody.diagnostics[0].diagnostics_retention.max_entries, | |
| batchBody.diagnostics_retention.max_entries | |
| ); | |
| }); | |
| it('returns validation errors for malformed page diagnostics batch JSON requests', async () => { | |
| const { getPageRequestDiagnosticsBatch } = await loadAgentRoutes(); | |
| process.env.AGENT_API_TOKEN = 'diagnostics-malformed-json-token'; | |
| const response = await getPageRequestDiagnosticsBatch( | |
| new Request('http://localhost/api/agent/diagnostics/page-requests', { | |
| method: 'POST', | |
| headers: { | |
| Authorization: 'Bearer diagnostics-malformed-json-token', | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: '{"ids":' | |
| }) | |
| ); | |
| assert.equal(response.status, 422); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'validation_error'); | |
| assert.equal(body.error.retryable, false); | |
| }); | |
| it('does not mark a real upstream success as failed when state completion fails', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| const { setAgentStateStoreFactoryForTests } = await import('@/lib/agent-state-runtime'); | |
| let upstreamCalls = 0; | |
| let failCalls = 0; | |
| let saveCalls = 0; | |
| const requestId = 'completion-failure-request'; | |
| const upstream = await startImageUpstream(() => { | |
| upstreamCalls += 1; | |
| return { data: [{ b64_json: PNG_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| setAgentStateStoreFactoryForTests(() => ({ | |
| async init() {}, | |
| async recoverExpiredRequests() { | |
| return 0; | |
| }, | |
| async purgeExpiredRequests() { | |
| return 0; | |
| }, | |
| async beginRequest() { | |
| return { | |
| type: 'acquired', | |
| record: { | |
| requestId, | |
| idempotencyKey: 'completion-failure-key', | |
| requestHash: 'hash', | |
| mode: 'generate', | |
| status: 'running', | |
| requestJson: { prompt: 'state completion failure' }, | |
| createdAt: '2026-05-12T00:00:00.000Z', | |
| updatedAt: '2026-05-12T00:00:00.000Z', | |
| expiresAt: '2026-05-13T00:00:00.000Z' | |
| } | |
| }; | |
| }, | |
| async refreshRequestLease() { | |
| return false; | |
| }, | |
| async saveArtifacts() { | |
| saveCalls += 1; | |
| }, | |
| async completeRequest() { | |
| throw new Error('state completion failed'); | |
| }, | |
| async failRequest() { | |
| failCalls += 1; | |
| }, | |
| async getRequest() { | |
| return undefined; | |
| }, | |
| async getRequestByIdempotencyKey() { | |
| return undefined; | |
| }, | |
| async getArtifact() { | |
| return undefined; | |
| }, | |
| async listArtifactsForRequest() { | |
| return []; | |
| }, | |
| async listArtifactFilepaths() { | |
| return []; | |
| }, | |
| async deleteArtifact() { | |
| return false; | |
| } | |
| })); | |
| const originalConsoleError = console.error; | |
| console.error = () => {}; | |
| try { | |
| const response = await generateImage( | |
| agentJsonRequest('completion-failure-key', { prompt: 'state completion failure' }) | |
| ); | |
| assert.equal(response.status, 500); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'unexpected_error'); | |
| assert.equal(body.error.retryable, true); | |
| assert.equal(body.error.request_id, requestId); | |
| assert.equal(upstreamCalls, 1); | |
| assert.equal(saveCalls, 1); | |
| assert.equal(failCalls, 0); | |
| } finally { | |
| console.error = originalConsoleError; | |
| } | |
| await upstream.close(); | |
| }); | |
| it('does not return artifact URLs when artifact metadata persistence fails', async () => { | |
| const { generateImage } = await loadAgentRoutes(); | |
| const { setAgentStateStoreFactoryForTests } = await import('@/lib/agent-state-runtime'); | |
| const requestId = 'artifact-save-failure-request'; | |
| const upstream = await startImageUpstream(() => ({ data: [{ b64_json: PNG_BASE64 }] })); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| setAgentStateStoreFactoryForTests(() => ({ | |
| async init() {}, | |
| async recoverExpiredRequests() { | |
| return 0; | |
| }, | |
| async purgeExpiredRequests() { | |
| return 0; | |
| }, | |
| async beginRequest() { | |
| return { | |
| type: 'acquired', | |
| record: { | |
| requestId, | |
| idempotencyKey: 'artifact-save-failure-key', | |
| requestHash: 'hash', | |
| mode: 'generate', | |
| status: 'running', | |
| requestJson: { prompt: 'artifact save failure' }, | |
| createdAt: '2026-05-12T00:00:00.000Z', | |
| updatedAt: '2026-05-12T00:00:00.000Z', | |
| expiresAt: '2026-05-13T00:00:00.000Z' | |
| } | |
| }; | |
| }, | |
| async refreshRequestLease() { | |
| return false; | |
| }, | |
| async saveArtifacts() { | |
| throw new Error('artifact metadata save failed'); | |
| }, | |
| async completeRequest() {}, | |
| async failRequest(input: { requestId: string; error: { error: { retryable: boolean } } }) { | |
| assert.equal(input.requestId, requestId); | |
| assert.equal(input.error.error.retryable, true); | |
| }, | |
| async getRequest() { | |
| return undefined; | |
| }, | |
| async getRequestByIdempotencyKey() { | |
| return undefined; | |
| }, | |
| async getArtifact() { | |
| return undefined; | |
| }, | |
| async listArtifactsForRequest() { | |
| return []; | |
| }, | |
| async listArtifactFilepaths() { | |
| return []; | |
| }, | |
| async deleteArtifact() { | |
| return false; | |
| } | |
| })); | |
| const originalConsoleError = console.error; | |
| console.error = () => {}; | |
| try { | |
| const response = await generateImage( | |
| agentJsonRequest('artifact-save-failure-key', { prompt: 'artifact save failure' }) | |
| ); | |
| assert.equal(response.status, 500); | |
| const body = await response.json(); | |
| assert.equal(body.error.code, 'unexpected_error'); | |
| assert.equal(body.error.retryable, true); | |
| assert.equal(body.error.request_id, requestId); | |
| assert.deepEqual(await listGeneratedImageFiles(), []); | |
| } finally { | |
| console.error = originalConsoleError; | |
| await upstream.close(); | |
| } | |
| }); | |
| }); | |
| const livePostgresUrl = process.env.AGENT_POSTGRES_TEST_DATABASE_URL; | |
| describe( | |
| 'Agent route PostgreSQL integration', | |
| { skip: livePostgresUrl ? false : 'AGENT_POSTGRES_TEST_DATABASE_URL is not set' }, | |
| () => { | |
| it('allows only one upstream winner for concurrent identical idempotency requests', async () => { | |
| assert.ok(livePostgresUrl); | |
| const { generateImage } = await loadAgentRoutes(); | |
| const schemaName = `agent_route_${Date.now().toString(36)}`; | |
| const pool = new Pool({ connectionString: livePostgresUrl }); | |
| process.env.AGENT_STATE_BACKEND = 'postgres'; | |
| process.env.AGENT_DATABASE_URL = `${livePostgresUrl}${livePostgresUrl.includes('?') ? '&' : '?'}options=-c%20search_path%3D${schemaName}`; | |
| process.env.AGENT_REQUEST_LEASE_MS = '60000'; | |
| const admin = await pool.connect(); | |
| let releaseUpstream: (() => void) | undefined; | |
| let upstreamCalls = 0; | |
| const upstream = await startImageUpstream(async () => { | |
| upstreamCalls += 1; | |
| await new Promise<void>((resolve) => { | |
| releaseUpstream = resolve; | |
| }); | |
| return { data: [{ b64_json: PNG_BASE64 }] }; | |
| }); | |
| process.env.OPENAI_API_KEY = 'test-key'; | |
| process.env.OPENAI_API_BASE_URL = upstream.baseUrl; | |
| try { | |
| await admin.query(`CREATE SCHEMA "${schemaName}"`); | |
| const firstRequest = generateImage( | |
| agentJsonRequest('pg-route-concurrent-key', { prompt: 'pg concurrent' }) | |
| ); | |
| await waitFor(() => upstreamCalls === 1); | |
| const second = await generateImage( | |
| agentJsonRequest('pg-route-concurrent-key', { prompt: 'pg concurrent' }) | |
| ); | |
| assert.equal(second.status, 409); | |
| const secondBody = await second.json(); | |
| assert.equal(secondBody.error.code, 'request_in_progress'); | |
| assert.equal(secondBody.error.retryable, true); | |
| assert.equal(second.headers.has('retry-after'), true); | |
| releaseUpstream?.(); | |
| const first = await firstRequest; | |
| assert.equal(first.status, 200); | |
| const replay = await generateImage( | |
| agentJsonRequest('pg-route-concurrent-key', { prompt: 'pg concurrent' }) | |
| ); | |
| assert.equal(replay.status, 200); | |
| assert.equal((await replay.json()).cached, true); | |
| assert.equal(upstreamCalls, 1); | |
| } finally { | |
| releaseUpstream?.(); | |
| await upstream.close(); | |
| await admin.query(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`); | |
| admin.release(); | |
| await pool.end(); | |
| } | |
| }); | |
| } | |
| ); | |
| async function loadAgentRoutes() { | |
| const { resetAgentStateStoreForTests } = await import('@/lib/agent-state-runtime'); | |
| const { resetServerChannelStateForTests } = await import('@/lib/server-channel-router'); | |
| await resetAgentStateStoreForTests(); | |
| resetServerChannelStateForTests(); | |
| const generateRoute = await import('./images/generate/route'); | |
| const editRoute = await import('./images/edit/route'); | |
| const artifactRoute = await import('./artifacts/[id]/route'); | |
| const artifactContentRoute = await import('./artifacts/[id]/content/route'); | |
| const artifactShareRoute = await import('./artifacts/[id]/share/route'); | |
| const capabilitiesRoute = await import('./capabilities/route'); | |
| const imageRequestRoute = await import('./image-requests/route'); | |
| const createGenerateJobRoute = await import('./jobs/images/generate/route'); | |
| const jobRoute = await import('./jobs/[id]/route'); | |
| const jobResultRoute = await import('./jobs/[id]/result/route'); | |
| const feedbackRoute = await import('../feedback/route'); | |
| const pageRequestFeedbackBatchRoute = await import('./page-requests/feedback/route'); | |
| const pageRequestFeedbackRoute = await import('./page-requests/[id]/feedback/route'); | |
| const agentRequestDiagnosticsLookupRoute = await import('./diagnostics/requests/route'); | |
| const agentRequestDiagnosticsRoute = await import('./diagnostics/requests/[id]/route'); | |
| const pageRequestDiagnosticsBatchRoute = await import('./diagnostics/page-requests/route'); | |
| const pageRequestDiagnosticsRoute = await import('./diagnostics/page-requests/[id]/route'); | |
| const shareContentRoute = await import('../shares/[token]/content/route'); | |
| return { | |
| getCapabilities: () => capabilitiesRoute.GET(), | |
| generateImage: (request: Request) => generateRoute.POST(asNextRequest(request)), | |
| editImage: (request: Request) => editRoute.POST(asNextRequest(request)), | |
| createImageRequest: (request: Request) => imageRequestRoute.POST(asNextRequest(request)), | |
| createGenerateJob: (request: Request) => createGenerateJobRoute.POST(asNextRequest(request)), | |
| getJob: (request: Request, context: AgentRouteContext) => jobRoute.GET(asNextRequest(request), context), | |
| getJobResult: (request: Request, context: AgentRouteContext) => | |
| jobResultRoute.GET(asNextRequest(request), context), | |
| getArtifact: (request: Request, context: AgentRouteContext) => | |
| artifactRoute.GET(asNextRequest(request), context), | |
| deleteArtifact: (request: Request, context: AgentRouteContext) => | |
| artifactRoute.DELETE(asNextRequest(request), context), | |
| getArtifactContent: (request: Request, context: AgentRouteContext) => | |
| artifactContentRoute.GET(asNextRequest(request), context), | |
| createArtifactShare: (request: Request, context: AgentRouteContext) => | |
| artifactShareRoute.POST(asNextRequest(request), context), | |
| getShareContent: (request: Request, context: { params: Promise<{ token: string }> }) => | |
| shareContentRoute.GET(asNextRequest(request), context), | |
| putFeedback: (request: Request) => feedbackRoute.PUT(asNextRequest(request)), | |
| deleteFeedback: (request: Request) => feedbackRoute.DELETE(asNextRequest(request)), | |
| getPageRequestFeedbackBatch: (request: Request) => pageRequestFeedbackBatchRoute.POST(asNextRequest(request)), | |
| getPageRequestFeedback: (request: Request, context: AgentRouteContext) => | |
| pageRequestFeedbackRoute.GET(asNextRequest(request), context), | |
| lookupAgentRequestDiagnostics: (request: Request) => | |
| agentRequestDiagnosticsLookupRoute.GET(asNextRequest(request)), | |
| getAgentRequestDiagnostics: (request: Request, context: AgentRouteContext) => | |
| agentRequestDiagnosticsRoute.GET(asNextRequest(request), context), | |
| getPageRequestDiagnosticsBatch: (request: Request) => | |
| pageRequestDiagnosticsBatchRoute.POST(asNextRequest(request)), | |
| getPageRequestDiagnostics: (request: Request, context: AgentRouteContext) => | |
| pageRequestDiagnosticsRoute.GET(asNextRequest(request), context) | |
| }; | |
| } | |
| type AgentRouteContext = { params: Promise<{ id: string }> }; | |
| function asNextRequest(request: Request): NextRequest { | |
| return request as unknown as NextRequest; | |
| } | |
| function agentJsonRequest(idempotencyKey: string, body: Record<string, unknown>, headers: Record<string, string> = {}) { | |
| const requestBody = buildAgentGenerateRequestBody(body); | |
| return new Request('http://localhost/api/agent/images/generate', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Idempotency-Key': idempotencyKey, | |
| ...headers | |
| }, | |
| body: JSON.stringify(requestBody) | |
| }); | |
| } | |
| function agentJobJsonRequest( | |
| idempotencyKey: string, | |
| body: Record<string, unknown>, | |
| headers: Record<string, string> = {} | |
| ) { | |
| const requestBody = buildAgentGenerateRequestBody(body); | |
| return new Request('http://localhost/api/agent/jobs/images/generate', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Idempotency-Key': idempotencyKey, | |
| ...headers | |
| }, | |
| body: JSON.stringify(requestBody) | |
| }); | |
| } | |
| function agentImageRequest( | |
| idempotencyKey: string, | |
| body: Record<string, unknown>, | |
| headers: Record<string, string> = {} | |
| ) { | |
| const requestBody = buildAgentGenerateRequestBody(body); | |
| return new Request('http://localhost/api/agent/image-requests', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Idempotency-Key': idempotencyKey, | |
| ...headers | |
| }, | |
| body: JSON.stringify(requestBody) | |
| }); | |
| } | |
| function buildAgentGenerateRequestBody(body: Record<string, unknown>): Record<string, unknown> { | |
| return { | |
| ...body, | |
| ...('stream_mode' in body || 'streaming_strategy' in body ? {} : { stream_mode: 'non_stream' }), | |
| ...('output_format' in body ? {} : { output_format: 'png' }) | |
| }; | |
| } | |
| type AgentEditRequestFields = { | |
| image_0?: Buffer; | |
| mask?: Buffer; | |
| [field: string]: string | Buffer | undefined; | |
| }; | |
| function agentEditRequest( | |
| idempotencyKey: string, | |
| prompt: string, | |
| headers: Record<string, string> = {}, | |
| responseModeOrFields: string | AgentEditRequestFields = 'path', | |
| options: { signal?: AbortSignal } = {} | |
| ) { | |
| const fields: AgentEditRequestFields = | |
| typeof responseModeOrFields === 'string' | |
| ? { response_mode: responseModeOrFields } | |
| : { response_mode: 'path', ...responseModeOrFields }; | |
| if (!('stream_mode' in fields) && !('streaming_strategy' in fields)) { | |
| fields.stream_mode = 'non_stream'; | |
| } | |
| const imageBuffer = fields.image_0 ?? Buffer.from(PNG_BASE64, 'base64'); | |
| const formData = new FormData(); | |
| formData.append('prompt', prompt); | |
| formData.append('model', 'gpt-image-2'); | |
| for (const [key, value] of Object.entries(fields)) { | |
| if (key === 'image_0' || key === 'mask') continue; | |
| if (typeof value !== 'string') { | |
| throw new TypeError(`Agent edit test field ${key} must be a string.`); | |
| } | |
| formData.append(key, value); | |
| } | |
| formData.append('image_0', new File([imageBuffer], 'input.png', { type: 'image/png' })); | |
| if (fields.mask) { | |
| formData.append('mask', new File([fields.mask], 'mask.png', { type: 'image/png' })); | |
| } | |
| return new Request('http://localhost/api/agent/images/edit', { | |
| method: 'POST', | |
| headers: { | |
| 'Idempotency-Key': idempotencyKey, | |
| ...headers | |
| }, | |
| body: formData, | |
| signal: options.signal | |
| }); | |
| } | |
| function createPngWithDimensions(width: number, height: number): Buffer { | |
| const buffer = Buffer.from(PNG_BASE64, 'base64'); | |
| buffer.writeUInt32BE(width, 16); | |
| buffer.writeUInt32BE(height, 20); | |
| return buffer; | |
| } | |
| async function startImageUpstream( | |
| handler: ( | |
| body: string, | |
| url: string, | |
| request: http.IncomingMessage, | |
| response: http.ServerResponse | |
| ) => unknown | Promise<unknown> | |
| ): Promise<{ baseUrl: string; close: () => Promise<void> }> { | |
| const server = http.createServer(async (request, response) => { | |
| if ( | |
| request.method !== 'POST' || | |
| (!request.url?.endsWith('/images/generations') && !request.url?.endsWith('/images/edits')) | |
| ) { | |
| response.writeHead(404, { 'Content-Type': 'application/json' }); | |
| response.end(JSON.stringify({ error: { message: 'not found' } })); | |
| return; | |
| } | |
| const chunks: Buffer[] = []; | |
| request.on('data', (chunk: Buffer) => chunks.push(chunk)); | |
| await new Promise<void>((resolve) => request.on('end', resolve)); | |
| try { | |
| const body = await handler(Buffer.concat(chunks).toString('utf8'), request.url || '', request, response); | |
| response.writeHead(200, { 'Content-Type': 'application/json' }); | |
| response.end(JSON.stringify(body)); | |
| } catch (error) { | |
| response.writeHead(500, { 'Content-Type': 'application/json' }); | |
| response.end(JSON.stringify({ error: { message: error instanceof Error ? error.message : '上游失败' } })); | |
| } | |
| }); | |
| await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); | |
| const address = server.address(); | |
| assert.ok(address && typeof address === 'object'); | |
| const baseUrl = `http://127.0.0.1:${address.port}/v1`; | |
| return { | |
| baseUrl, | |
| close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) | |
| }; | |
| } | |
| async function startHangingImageEditUpstream(): Promise<{ | |
| baseUrl: string; | |
| readonly requests: number; | |
| close: () => Promise<void>; | |
| }> { | |
| let requests = 0; | |
| const sockets = new Set<Socket>(); | |
| const server = http.createServer(async (request, response) => { | |
| if (request.method === 'POST' && request.url?.endsWith('/images/edits')) { | |
| requests += 1; | |
| request.resume(); | |
| return; | |
| } | |
| request.resume(); | |
| response.writeHead(404, { 'Content-Type': 'application/json' }); | |
| response.end(JSON.stringify({ error: { message: 'not found' } })); | |
| }); | |
| server.on('connection', (socket) => { | |
| sockets.add(socket); | |
| socket.on('close', () => sockets.delete(socket)); | |
| }); | |
| await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); | |
| const address = server.address(); | |
| assert.ok(address && typeof address === 'object'); | |
| return { | |
| baseUrl: `http://127.0.0.1:${address.port}/v1`, | |
| get requests() { | |
| return requests; | |
| }, | |
| close: () => | |
| new Promise((resolve, reject) => { | |
| for (const socket of sockets) socket.destroy(); | |
| server.close((error) => (error ? reject(error) : resolve())); | |
| }) | |
| }; | |
| } | |
| async function startStreamingImageUpstream( | |
| handler: ( | |
| body: string | |
| ) => Array<{ event?: string; data: unknown }> | Promise<Array<{ event?: string; data: unknown }>> | |
| ): Promise<{ baseUrl: string; close: () => Promise<void> }> { | |
| const server = http.createServer(async (request, response) => { | |
| const isImageStreamPath = | |
| request.url?.endsWith('/images/generations') || request.url?.endsWith('/images/edits'); | |
| if (request.method !== 'POST' || !isImageStreamPath) { | |
| response.writeHead(404, { 'Content-Type': 'application/json' }); | |
| response.end(JSON.stringify({ error: { message: 'not found' } })); | |
| return; | |
| } | |
| const chunks: Buffer[] = []; | |
| request.on('data', (chunk: Buffer) => chunks.push(chunk)); | |
| await new Promise<void>((resolve) => request.on('end', resolve)); | |
| const events = await handler(Buffer.concat(chunks).toString('utf8')); | |
| response.writeHead(200, { 'Content-Type': 'text/event-stream' }); | |
| for (const event of events) { | |
| if (event.event) { | |
| response.write(`event: ${event.event}\n`); | |
| } | |
| response.write(`data: ${JSON.stringify(event.data)}\n\n`); | |
| } | |
| response.write('data: [DONE]\n\n'); | |
| response.end(); | |
| }); | |
| await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); | |
| const address = server.address(); | |
| assert.ok(address && typeof address === 'object'); | |
| return { | |
| baseUrl: `http://127.0.0.1:${address.port}/v1`, | |
| close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) | |
| }; | |
| } | |
| async function startStreamingResponsesImageUpstream( | |
| handler: ( | |
| body: string | |
| ) => Array<{ event?: string; data: unknown }> | Promise<Array<{ event?: string; data: unknown }>> | |
| ): Promise<{ baseUrl: string; close: () => Promise<void> }> { | |
| const server = http.createServer(async (request, response) => { | |
| if (request.method !== 'POST' || !request.url?.endsWith('/responses')) { | |
| response.writeHead(404, { 'Content-Type': 'application/json' }); | |
| response.end(JSON.stringify({ error: { message: 'not found' } })); | |
| return; | |
| } | |
| const chunks: Buffer[] = []; | |
| request.on('data', (chunk: Buffer) => chunks.push(chunk)); | |
| await new Promise<void>((resolve) => request.on('end', resolve)); | |
| const events = await handler(Buffer.concat(chunks).toString('utf8')); | |
| response.writeHead(200, { 'Content-Type': 'text/event-stream' }); | |
| for (const event of events) { | |
| if (event.event) { | |
| response.write(`event: ${event.event}\n`); | |
| } | |
| response.write(`data: ${JSON.stringify(event.data)}\n\n`); | |
| } | |
| response.write('data: [DONE]\n\n'); | |
| response.end(); | |
| }); | |
| await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); | |
| const address = server.address(); | |
| assert.ok(address && typeof address === 'object'); | |
| return { | |
| baseUrl: `http://127.0.0.1:${address.port}/v1`, | |
| close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) | |
| }; | |
| } | |
| async function startResponsesImageJsonUpstream( | |
| status: number, | |
| body: unknown | |
| ): Promise<{ baseUrl: string; close: () => Promise<void> }> { | |
| const server = http.createServer((request, response) => { | |
| if (request.method !== 'POST' || !request.url?.endsWith('/responses')) { | |
| response.writeHead(404, { 'Content-Type': 'application/json' }); | |
| response.end(JSON.stringify({ error: { message: 'not found' } })); | |
| return; | |
| } | |
| request.resume(); | |
| response.writeHead(status, { 'Content-Type': 'application/json' }); | |
| response.end(JSON.stringify(body)); | |
| }); | |
| await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); | |
| const address = server.address(); | |
| assert.ok(address && typeof address === 'object'); | |
| return { | |
| baseUrl: `http://127.0.0.1:${address.port}/v1`, | |
| close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) | |
| }; | |
| } | |
| function readStoredResponseJson(idempotencyKey: string): string { | |
| const db = new Database(path.join(tempDir, 'agent.sqlite'), { readonly: true }); | |
| try { | |
| const row = db | |
| .prepare('SELECT response_json FROM agent_requests WHERE idempotency_key = ?') | |
| .get(idempotencyKey) as { response_json: string } | undefined; | |
| assert.ok(row); | |
| return row.response_json; | |
| } finally { | |
| db.close(); | |
| } | |
| } | |
| function readStoredArtifactFilepath(id: string): string { | |
| const db = new Database(path.join(tempDir, 'agent.sqlite'), { readonly: true }); | |
| try { | |
| const row = db.prepare('SELECT filepath FROM agent_artifacts WHERE id = ?').get(id) as | |
| { filepath: string } | undefined; | |
| assert.ok(row); | |
| return row.filepath; | |
| } finally { | |
| db.close(); | |
| } | |
| } | |
| async function listGeneratedImageFiles(): Promise<string[]> { | |
| try { | |
| return (await readdir(path.join(tempDir, 'generated-images'))).filter((entry) => | |
| /\.(png|jpe?g|webp)$/i.test(entry) | |
| ); | |
| } catch { | |
| return []; | |
| } | |
| } | |
| async function listAgentStateFiles(): Promise<string[]> { | |
| try { | |
| return await readdir(path.join(tempDir, 'generated-images', '.agent-state')); | |
| } catch { | |
| return []; | |
| } | |
| } | |
| async function waitFor(predicate: () => boolean): Promise<void> { | |
| for (let attempt = 0; attempt < 50; attempt += 1) { | |
| if (predicate()) return; | |
| await new Promise((resolve) => setTimeout(resolve, 20)); | |
| } | |
| throw new Error('等待条件超时'); | |
| } | |
| async function waitForJobResult( | |
| getJobResult: (request: Request, context: { params: Promise<{ id: string }> }) => Promise<Response>, | |
| id: string | |
| ): Promise<Response> { | |
| for (let attempt = 0; attempt < 200; attempt += 1) { | |
| const response = await getJobResult(new Request(`http://localhost/api/agent/jobs/${id}/result`), { | |
| params: Promise.resolve({ id }) | |
| }); | |
| if (response.status !== 409) { | |
| return response; | |
| } | |
| await new Promise((resolve) => setTimeout(resolve, 20)); | |
| } | |
| throw new Error('等待 job result 超时'); | |
| } | |